Cognitive Load Analyzer

SkillProductivity

Evaluate interface complexity by measuring information density, decision points, visual hierarchy, and task completion paths to reduce user cognitive burden.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Cognitive Load Analyzer skill

What this skill tells your AI

The instructions your AI receives, as published by pramoddutta/qaskills in seed-skills/cognitive-load-analyzer/SKILL.md and read by ahel’s review.

You are an expert QA engineer specializing in cognitive load assessment, usability heuristic evaluation, and information architecture analysis. When asked to evaluate interface complexity, measure decision overload, audit visual hierarchy, or analyze task completion paths in a web application, follow these comprehensive instructions to systematically identify and quantify sources of unnecessary cognitive burden.

Core Principles

  1. Cognitive Load Is Measurable -- While cognitive load is a psychological phenomenon, its proxies are measurable: number of choices per screen, information density per viewport, navigation depth to complete tasks, consistency of patterns, and visual hierarchy clarity. By quantifying these proxies, you can objectively compare designs and detect regressions.

  2. Three Types of Cognitive Load -- Intrinsic load comes from the inherent complexity of the task itself. Extraneous load comes from poor interface design that adds unnecessary complexity. Germane load is the productive mental effort of learning and understanding. The goal is to minimize extraneous load while preserving intrinsic and germane load.

  3. Miller's Law Applies to Interfaces -- The human working memory can hold roughly 7 plus or minus 2 items simultaneously. Navigation menus with 15 items, forms with 20 fields, and dashboards with 12 data widgets all exceed cognitive capacity. Chunk information into groups of 5-7 items maximum.

  4. Hick's Law Governs Decision Time -- The time to make a decision increases logarithmically with the number of choices. A page with 3 clear options is cognitively easy. A page with 30 options of similar visual weight creates decision paralysis. Reduce choices or create clear visual hierarchy to guide attention.

  5. Consistency Reduces Load -- When interface patterns are consistent, users build mental models that reduce the cognitive effort of future interactions. When the same action requires different steps on different pages, users must relearn the interface each time.

  6. Progressive Disclosure Is a Strategy -- Not all information needs to be visible at once. Show the essential information first and provide clear paths to details. An accordion, a "Show more" link, or a drill-down pattern reduces the initial cognitive load without hiding information.

  7. Visual Hierarchy Guides Attention -- When everything on a page has equal visual weight, the user must scan everything to find what matters. Clear size, color, contrast, and spacing differences create a hierarchy that guides the eye from most important to least important.

Project Structure

Organize your cognitive load analysis suite with this directory structure:

tests/
  cognitive-load/
    information-density.spec.ts
    choice-overload.spec.ts
    navigation-complexity.spec.ts
    form-complexity.spec.ts
    visual-hierarchy.spec.ts
    consistency-audit.spec.ts
    task-completion-paths.spec.ts
  fixtures/
    cognitive-page.fixture.ts
  helpers/
    density-calculator.ts
    choice-counter.ts
    hierarchy-analyzer.ts
    consistency-checker.ts
    task-path-tracer.ts
    cognitive-score.ts
  reports/
    cognitive-load-report.json
    cognitive-load-report.html
  thresholds/
    cognitive-thresholds.json
playwright.config.ts

Each spec file measures a different dimension of cognitive load. The helpers directory contains the measurement algorithms. Thresholds define the acceptable limits for each metric.

Detailed Guide

Step 1: Build an Information Density Calculator

Information density measures how much content is presented per unit of viewport area. High density overwhelms users; low density wastes space and requires excessive scrolling.

// helpers/density-calculator.ts
import { Page } from '@playwright/test';

export interface DensityMetrics {
  totalTextElements: number;
  totalWordCount: number;
  totalInteractiveElements: number;
  totalImages: number;
  viewportArea: number;
  visibleContentArea: number;
  textDensity: number;          // words per 1000px of viewport height
  interactiveDensity: number;   // interactive elements per viewport
  informationUnits: number;     // distinct information groups
  densityScore: number;         // 0-100 composite score
}

export class DensityCalculator {
  async calculate(page: Page): Promise<DensityMetrics> {
    const viewport = page.viewportSize();
    if (!viewport) throw new Error('No viewport size available');

    const viewportArea = viewport.width * viewport.height;

    const measurements = await page.evaluate(() => {
      // Count visible text elements
      const textSelectors = 'p, h1, h2, h3, h4, h5, h6, span, li, td, th, label, a, button';
      const textElements = document.querySelectorAll(textSelectors);
      let totalWords = 0;
      let visibleTextElements = 0;

      textElements.forEach((el) => {
        const style = window.getComputedStyle(el);
        if (style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0') {
          const text = el.textContent?.trim();
          if (text && text.length > 0) {
            visibleTextElements++;
            totalWords += text.split(/\s+/).filter((w) => w.length > 0).length;
          }
        }
      });

      // Count interactive elements visible in the viewport
      const interactiveSelectors = [
        'a[href]', 'button', 'input', 'select', 'textarea',
        '[role="button"]', '[role="link"]', '[role="tab"]',
        '[role="menuitem"]', '[onclick]', '[tabindex]:not([tabindex="-1"])',
      ];
      const interactiveElements = document.querySelectorAll(interactiveSelectors.join(', '));
      let visibleInteractive = 0;
      interactiveElements.forEach((el) => {
        const rect = el.getBoundingClientRect();
        if (rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0) {
          visibleInteractive++;
        }
      });

      // Count images in viewport
      const images = document.querySelectorAll('img, svg, [role="img"]');
      let visibleImages = 0;
      images.forEach((el) => {
        const rect = el.getBoundingClientRect();
        if (rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight) {
          visibleImages++;
        }
      });

      // Calculate visible content area
      const bodyRect = document.body.getBoundingClientRect();
      const visibleContentArea = Math.min(bodyRect.height, window.innerHeight) * bodyRect.width;

      // Count distinct information groups (sections, cards, panels)
      const groupSelectors = 'section, article, .card, .panel, [role="region"], [role="group"], fieldset';
      const groups = document.querySelectorAll(groupSelectors);
      let visibleGroups = 0;
      groups.forEach((el) => {
        const rect = el.getBoundingClientRect();
        if (rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight) {
          visibleGroups++;
        }
      });

      return {
        visibleTextElements,
        totalWords,
        visibleInteractive,
        visibleImages,
        visibleContentArea,
        visibleGroups,
      };
    });

    const viewportHeightK = viewport.height / 1000;
    const textDensity = measurements.totalWords / viewportHeightK;
    const interactiveDensity = measurements.visibleInteractive;

    // Composite density score (0-100, lower is less dense)
    let densityScore = 50;
    if (textDensity > 500) densityScore += 15;
    if (textDensity > 800) densityScore += 15;
    if (textDensity < 100) densityScore -= 10;
    if (interactiveDensity > 20) densityScore += 10;
    if (interactiveDensity > 40) densityScore += 10;
    if (measurements.visibleGroups > 8) densityScore += 10;
    densityScore = Math.max(0, Math.min(100, densityScore));

    return {
      totalTextElements: measurements.visibleTextElements,
      totalWordCount: measurements.totalWords,
      totalInteractiveElements: measurements.visibleInteractive,
      totalImages: measurements.visibleImages,
      viewportArea,
      visibleContentArea: measurements.visibleContentArea,
      textDensity,
      interactiveDensity,
      informationUnits: measurements.visibleGroups,
      densityScore,
    };
  }
}

Step 2: Build a Choice Overload Counter

Choice overload occurs when users face too many options of similar visual weight, causing decision paralysis.

// helpers/choice-counter.ts
import { Page } from '@playwright/test';

export interface ChoiceMetrics {
  navigationItemCount: number;
  formFieldCount: number;
  callToActionCount: number;
  filterOptionCount: number;
  tabCount: number;
  cardChoiceCount: number;
  totalDecisionPoints: number;
  choiceOverloadScore: number;  // 0-100, higher = more overload
  issues: string[];
}

export class ChoiceCounter {
  async count(page: Page): Promise<ChoiceMetrics> {
    const metrics = await page.evaluate(() => {
      const issues: string[] = [];

      // Count top-level navigation items
      const navItems = document.querySelectorAll(
        'nav a, nav button, [role="navigation"] a, [role="navigation"] button'
      );
      const visibleNavItems = Array.from(navItems).filter((el) => {
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
      });
      const navigationItemCount = visibleNavItems.length;
      if (navigationItemCount > 7) {
        issues.push(`Navigation has ${navigationItemCount} items (recommended: 5-7)`);
      }

      // Count visible form fields
      const formFields = document.querySelectorAll(
        'input:not([type="hidden"]), select, textarea, [role="combobox"]'
      );
      const visibleFormFields = Array.from(formFields).filter((el) => {
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight;
      });
      const formFieldCount = visibleFormFields.length;
      if (formFieldCount > 7) {
        issues.push(`${formFieldCount} form fields visible (recommended: 3-5 per step)`);
      }

      // Count call-to-action buttons
      const ctaButtons = document.querySelectorAll(
        'button[type="submit"], .btn-primary, .cta, [data-testid*="cta"]'
      );
      const visibleCTAs = Array.from(ctaButtons).filter((el) => {
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight;
      });
      const callToActionCount = visibleCTAs.length;
      if (callToActionCount > 2) {
        issues.push(`${callToActionCount} CTAs competing for attention (recommended: 1-2)`);
      }

      // Count filter options
      const filterElements = document.querySelectorAll(
        '[data-testid*="filter"], .filter-option, .facet'
      );
      const filterOptionCount = filterElements.length;
      if (filterOptionCount > 10) {
        issues.push(`${filterOptionCount} filter options visible (use progressive disclosure)`);
      }

      // Count tabs
      const tabs = document.querySelectorAll('[role="tab"], .tab, .nav-tab');
      const tabCount = tabs.length;
      if (tabCount > 6) {
        issues.push(`${tabCount} tabs visible (recommended: 4-6, use overflow for more)`);
      }

      // Count choice cards in viewport
      const cards = document.querySelectorAll('.card, article, .product-card, .pricing-card');
      const visibleCards = Array.from(cards).filter((el) => {
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight;
      });
      const cardChoiceCount = visibleCards.length;
      if (cardChoiceCount > 9) {
        issues.push(`${cardChoiceCount} choice cards in viewport (recommended: 6-9)`);
      }

      return {
        navigationItemCount,
        formFieldCount,
        callToActionCount,
        filterOptionCount,
        tabCount,
        cardChoiceCount,
        issues,
      };
    });

    const totalDecisionPoints =
      metrics.navigationItemCount +
      metrics.formFieldCount +
      metrics.callToActionCount +
      metrics.tabCount +
      metrics.cardChoiceCount;

    let overloadScore = 0;
    if (totalDecisionPoints > 10) overloadScore += 15;
    if (totalDecisionPoints > 20) overloadScore += 15;
    if (totalDecisionPoints > 30) overloadScore += 20;
    if (metrics.navigationItemCount > 7) overloadScore += 10;
    if (metrics.formFieldCount > 7) overloadScore += 10;
    if (metrics.callToActionCount > 2) overloadScore += 10;
    if (metrics.cardChoiceCount > 9) overloadScore += 10;
    if (metrics.tabCount > 6) overloadScore += 10;
    overloadScore = Math.min(100, overloadScore);

    return {
      ...metrics,
      totalDecisionPoints,
      choiceOverloadScore: overloadScore,
    };
  }
}

Step 3: Build a Visual Hierarchy Analyzer

Visual hierarchy determines the order in which users process page information. A clear hierarchy guides the eye efficiently; a flat hierarchy forces exhaustive scanning.

// helpers/hierarchy-analyzer.ts
import { Page } from '@playwright/test';

export interface HierarchyMetrics {
  headingLevels: Array<{ level: number; count: number; text: string[] }>;
  headingHierarchyValid: boolean;
  fontSizeVariations: number;
  whitespaceRatio: number;
  primaryActionClear: boolean;
  visualWeightDistribution: 'balanced' | 'top-heavy' | 'flat' | 'clear-hierarchy';
  hierarchyScore: number;  // 0-100, higher is better
  issues: string[];
}

export class HierarchyAnalyzer {
  async analyze(page: Page): Promise<HierarchyMetrics> {
    const data = await page.evaluate(() => {
      const issues: string[] = [];

      // Analyze heading hierarchy
      const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
      const levelMap = new Map<number, string[]>();
      headings.forEach((h) => {
        const level = parseInt(h.tagName.charAt(1));
        if (!levelMap.has(level)) levelMap.set(level, []);
        levelMap.get(level)?.push(h.textContent?.trim().substring(0, 50) || '');
      });

      const headingLevels = Array.from(levelMap.entries()).map(([level, texts]) => ({
        level,
        count: texts.length,
        text: texts,
      }));

      // Check for skipped heading levels
      const sortedLevels = Array.from(levelMap.keys()).sort();
      let hierarchyValid = true;
      for (let i = 1; i < sortedLevels.length; i++) {
        if (sortedLevels[i] - sortedLevels[i - 1] > 1) {
          hierarchyValid = false;
          issues.push(`Heading hierarchy skips from h${sortedLevels[i - 1]} to h${sortedLevels[i]}`);
        }
      }

      const h1Count = levelMap.get(1)?.length || 0;
      if (h1Count === 0) {
        issues.push('Page has no h1 heading');
        hierarchyValid = false;
      }
      if (h1Count > 1) {
        issues.push(`Page has ${h1Count} h1 headings (recommended: exactly 1)`);
      }

      // Count distinct font sizes
      const allElements = document.querySelectorAll('body *');
      const fontSizes = new Set<string>();
      allElements.forEach((el) => {
        const style = window.getComputedStyle(el);
        if (style.display !== 'none' && el.textContent?.trim()) {
          fontSizes.add(style.fontSize);
        }
      });
      const fontSizeVariations = fontSizes.size;
      if (fontSizeVariations > 8) {
        issues.push(`${fontSizeVariations} distinct font sizes (recommended: 4-6)`);
      }

      // Calculate whitespace ratio
      const bodyRect = document.body.getBoundingClientRect();
      const totalArea = bodyRect.width * Math.min(bodyRect.height, window.innerHeight);
      let contentArea = 0;
      const contentEls = document.querySelectorAll(
        'p, h1, h2, h3, h4, h5, h6, img, button, input, select, textarea, table, ul, ol'
      );
      contentEls.forEach((el) => {
        const rect = el.getBoundingClientRect();
        if (rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0) {
          contentArea += rect.width * rect.height;
        }
      });
      const whitespaceRatio = totalArea > 0 ? 1 - contentArea / totalArea : 0;
      if (whitespaceRatio < 0.2) {
        issues.push(`Only ${(whitespaceRatio * 100).toFixed(0)}% whitespace (recommended: 30-50%)`);
      }

      // Check primary CTA clarity
      const primaryButtons = document.querySelectorAll(
        'button[type="submit"], .btn-primary, [data-testid*="primary"]'
      );
      let primaryActionClear = false;
      if (primaryButtons.length === 1) {
        const style = window.getComputedStyle(primaryButtons[0]);
        const bg = style.backgroundColor;
        primaryActionClear =
          bg !== 'rgba(0, 0, 0, 0)' && bg !== 'rgb(255, 255, 255)' && bg !== 'transparent';
      }
      if (primaryButtons.length > 1) {
        issues.push(`${primaryButtons.length} primary CTAs compete for attention`);
      }

      return {
        headingLevels,
        headingHierarchyValid: hierarchyValid,
        fontSizeVariations,
        whitespaceRatio,
        primaryActionClear,
        issues,
      };
    });

    let distribution: HierarchyMetrics['visualWeightDistribution'];
    if (data.headingHierarchyValid && data.whitespaceRatio > 0.3 && data.fontSizeVariations <= 6) {
      distribution = 'clear-hierarchy';
    } else if (data.fontSizeVariations <= 3) {
      distribution = 'flat';
    } else if (data.whitespaceRatio < 0.2) {
      distribution = 'top-heavy';
    } else {
      distribution = 'balanced';
    }

    let score = 50;
    if (data.headingHierarchyValid) score += 15;
    if (data.primaryActionClear) score += 10;
    if (data.whitespaceRatio >= 0.3 && data.whitespaceRatio <= 0.5) score += 10;
    if (data.fontSizeVariations >= 4 && data.fontSizeVariations <= 6) score += 10;
    if (data.fontSizeVariations > 8) score -= 10;
    if (data.whitespaceRatio < 0.2) score -= 15;
    if (!data.headingHierarchyValid) score -= 10;
    score = Math.max(0, Math.min(100, score));

    return {
      ...data,
      visualWeightDistribution: distribution,
      hierarchyScore: score,
    };
  }
}

Step 4: Build a Consistency Checker

Consistency across pages reduces the cognitive cost of learning the interface.

// helpers/consistency-checker.ts
import { Page } from '@playwright/test';

export interface ConsistencyIssue {
  category: 'naming' | 'layout' | 'interaction' | 'visual' | 'navigation';
  description: string;
  severity: 'high' | 'medium' | 'low';
  pages: string[];
  recommendation: string;
}

export interface ConsistencyMetrics {
  issues: ConsistencyIssue[];
  consistencyScore: number;
}

export class ConsistencyChecker {
  private snapshots: Array<{
    url: string;
    buttonLabels: string[];
    navStructure: string[];
    headingPattern: string[];
    layoutPattern: string;
  }> = [];

  async captureSnapshot(page: Page): Promise<void> {
    const snapshot = await page.evaluate(() => {
      const buttons = document.querySelectorAll('button, [role="button"]');
      const buttonLabels = Array.from(buttons)
        .map((b) => b.textContent?.trim())
        .filter(Boolean) as string[];

      const navLinks = document.querySelectorAll('nav a, [role="navigation"] a');
      const navStructure = Array.from(navLinks)
        .map((a) => a.textContent?.trim())
        .filter(Boolean) as string[];

      const headings = document.querySelectorAll('h1, h2, h3');
      const headingPattern = Array.from(headings).map(
        (h) => `${h.tagName}:${h.textContent?.trim().substring(0, 30)}`
      );

      const mainContent = document.querySelector('main, [role="main"]');
      let layoutPattern = 'unknown';
      if (mainContent) {
        const style = window.getComputedStyle(mainContent);
        if (style.display === 'grid') layoutPattern = 'grid';
        else if (style.display === 'flex') layoutPattern = 'flex';
        else layoutPattern = 'block';
      }

      return { buttonLabels, navStructure, headingPattern, layoutPattern };
    });

    this.snapshots.push({ url: page.url(), ...snapshot });
  }

  analyze(): ConsistencyMetrics {
    const issues: ConsistencyIssue[] = [];

    if (this.snapshots.length < 2) {
      return { issues, consistencyScore: 100 };
    }

    // Check navigation consistency
    const navStructures = this.snapshots.map((s) => JSON.stringify(s.navStructure));
    if (new Set(navStructures).size > 1) {
      issues.push({
        category: 'navigation',
        description: 'Navigation structure differs across pages',
        severity: 'high',
        pages: this.snapshots.map((s) => s.url),
        recommendation: 'Use a consistent navigation component on all pages',
      });
    }

    // Check button label consistency for common actions
    const allLabels = this.snapshots.flatMap((s) => s.buttonLabels);
    const saveVariants = allLabels.filter((l) =>
      /^(save|submit|confirm|apply|done|ok|update)$/i.test(l)
    );
    const uniqueSaveLabels = new Set(saveVariants.map((l) => l.toLowerCase()));
    if (uniqueSaveLabels.size > 2) {
      issues.push({
        category: 'naming',
        description: `Multiple labels for save action: ${Array.from(uniqueSaveLabels).join(', ')}`,
        severity: 'medium',
        pages: this.snapshots.map((s) => s.url),
        recommendation: 'Standardize on a single label for the primary save action',
      });
    }

    const cancelVariants = allLabels.filter((l) =>
      /^(cancel|close|dismiss|back|discard)$/i.test(l)
    );
    const uniqueCancelLabels = new Set(cancelVariants.map((l) => l.toLowerCase()));
    if (uniqueCancelLabels.size > 2) {
      issues.push({
        category: 'naming',
        description: `Multiple labels for cancel action: ${Array.from(uniqueCancelLabels).join(', ')}`,
        severity: 'medium',
        pages: this.snapshots.map((s) => s.url),
        recommendation: 'Standardize on a single label for the cancel action',
      });
    }

    // Check layout consistency
    const layouts = this.snapshots.map((s) => s.layoutPattern);
    if (new Set(layouts).size > 2) {
      issues.push({
        category: 'layout',
        description: `${new Set(layouts).size} different layout patterns across pages`,
        severity: 'medium',
        pages: this.snapshots.map((s) => s.url),
        recommendation: 'Use a consistent layout system for similar page types',
      });
    }

    let score = 100;
    for (const issue of issues) {
      if (issue.severity === 'high') score -= 20;
      else if (issue.severity === 'medium') score -= 10;
      else score -= 5;
    }

    return { issues, consistencyScore: Math.max(0, score) };
  }
}

Step 5: Build a Task Path Tracer

Measuring how many steps common tasks require reveals unnecessary workflow complexity.

// helpers/task-path-tracer.ts
import { Page } from '@playwright/test';

export interface TaskStep {
  action: string;
  url: string;
  elementInteracted: string;
  timestamp: number;
  cognitiveEffort: 'low' | 'medium' | 'high';
}

export interface TaskPath {
  taskName: string;
  steps: TaskStep[];
  totalSteps: number;
  totalTimeMs: number;
  pagesVisited: number;
  backtrackCount: number;
  cognitiveScore: number;
}

export class TaskPathTracer {
  private steps: TaskStep[] = [];
  private startTime: number = 0;
  private visitedUrls: Set<string> = new Set();
  private urlSequence: string[] = [];

  startTask(): void {
    this.steps = [];
    this.startTime = Date.now();
    this.visitedUrls = new Set();
    this.urlSequence = [];
  }

  recordStep(
    action: string,
    page: Page,
    elementDescription: string,
    effort: TaskStep['cognitiveEffort'] = 'low'
  ): void {
    const url = page.url();
    this.visitedUrls.add(url);
    this.urlSequence.push(url);

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
224
Forks
27
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
cognitive-load-analyzer
Source
github.com/pramoddutta/qaskills