Claude Skill

frontend

Frontend: UI design, distinctive visual styles, HTML artifacts, Three.js 3D.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download notque-vexjoy-agent-skills_frontend_frontend-8ad6845.zip · 296 KB
Part of notque/vexjoy-agent — 69 skills

Install

skills CLI npx skills add https://github.com/notque/vexjoy-agent/tree/main/skills/frontend/frontend
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install notque-vexjoy-agent@llmmart
Git git clone https://github.com/notque/vexjoy-agent.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole notque/vexjoy-agent collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Frontend Skill

Four modes: Design (UX copy, design systems, critique, accessibility, handoff, research), Distinctive (context-driven aesthetic exploration with anti-cliche validation), HTML-Artifact (self-contained HTML generation with 8 shapes), and Three.js (3D web apps in imperative, R3F, or WebGPU paradigms).

Mode Selection

Classify the request into one mode before loading references.

Mode Signals What to Load
DESIGN UX copy, design system, design critique, accessibility, WCAG, design handoff, user research Design-refs per sub-mode table below
DISTINCTIVE Frontend design, typography, visual identity, anti-cliche, text animation, card effects references/distinctive-frontend-design-refs/ per phase
HTML-ARTIFACT HTML artifact, make HTML, as HTML, rich visualization, interactive document, pptx, deck references/html-artifact-refs/ per shape
THREEJS Three.js, 3D scene, WebGL, WebGPU, react three fiber, R3F, GLTF references/threejs-builder-refs/ per paradigm

DESIGN Mode

Design methodology: UX copy, design systems, critique, accessibility review, developer handoff, user research synthesis. Always load references/design-refs/llm-design-failure-modes.md alongside mode-specific reference.

Sub-mode Detection

Sub-mode Signals Load
UX-COPY write copy, button text, error message, empty state, tooltip references/design-refs/ux-copy.md
DESIGN-SYSTEM design tokens, component library, audit components, theme references/design-refs/design-systems.md
CRITIQUE review design, critique mockup, design feedback, usability references/design-refs/design-critique.md
ACCESSIBILITY WCAG, accessibility audit, color contrast, keyboard nav, a11y references/design-refs/accessibility-review.md
HANDOFF developer handoff, spec sheet, implementation spec, responsive references/design-refs/design-handoff.md
RESEARCH synthesize research, interview analysis, usability findings (inline below)

UX-COPY

  1. Gather context: component type, user emotional state, brand voice, constraints, existing terminology.
  2. Generate copy: primary recommendation with rationale, 2-3 alternatives, localization notes.
  3. Validate: terminology consistency, action labels match outcomes, error messages follow What/Why/Fix, character limits met.

Gate: Copy for all requested components. Each piece has rationale. Alternatives differentiated.

DESIGN-SYSTEM

Three operations: Audit (naming consistency, token coverage, hardcoded values, state completeness), Document (props/variants/states/a11y/usage spec), Extend (new component using existing tokens). Use design token architecture: color, typography, spacing, borders, shadows, motion.

Validate: consistent naming, all values reference tokens, all states defined (default/hover/active/disabled/loading/error), ARIA documented.

CRITIQUE

Four-step method: Describe (elements, layout -- no judgment), Analyze (hierarchy, contrast, alignment), Interpret (emotional tone, brand alignment), Evaluate (recommendations). Apply Nielsen's 10 heuristics to the specific design. Match feedback depth to stage: exploration (concept direction), refinement (hierarchy, patterns), final (contrast, spacing, a11y).

ACCESSIBILITY

Audit by WCAG principle: Perceivable (alt text 1.1.1, contrast 4.5:1 text / 3:1 UI), Operable (keyboard 2.1.1, focus order 2.4.3, touch 44x44px), Understandable (predictable 3.2.1, error ID 3.3.1), Robust (name/role/value 4.1.2). Report: severity matrix, each finding with WCAG criterion + remediation, contrast table, keyboard map.

HANDOFF

Gather: design source, tech stack, tokens, breakpoints. Generate spec covering: layout, tokens, components, states, interactions, content limits, edge cases (empty/loading/error/overflow/i18n), accessibility, animation. Validate: all states documented, token references used, edge cases present.

RESEARCH

Accept inputs (transcripts, surveys, support tickets). Extract observations and quotes -- behavioral data outweighs stated preferences. Synthesize via affinity mapping and theme development. Priority matrix (impact x frequency). Output: executive summary, themes with evidence, insights-to-opportunities table, user segments, recommendations.

Output Conventions

Markdown with tables. Severity: Critical (blocks users), Major (degrades), Minor (polish). Every recommendation names element + issue + concrete fix. Include what works alongside what needs improvement.


DISTINCTIVE Mode

Systematic aesthetic exploration producing validated design specifications. Every choice flows from project context, not defaults. Seven phases with gates.

Phase 1: Context Discovery

  1. Gather: purpose, surface type (landing vs app/dashboard), audience, emotion, cultural context, constraints, tech stack, real content, previous projects.
  2. Pick one macrostructure from references/distinctive-frontend-design-refs/macrostructure-catalog.md by heading anchor. Load only the chosen entry.
  3. Define 3-5 aesthetic directions using references/distinctive-frontend-design-refs/color-inspirations.json and references/distinctive-frontend-design-refs/font-catalog.json. See references/distinctive-frontend-design-refs/phase-details.md for examples.
  4. Write narrative brief: visual thesis, content plan, interaction thesis.

Gate: Macrostructure chosen, aesthetic direction justified, narrative brief written.

Phase 2: Typography

Load references/distinctive-frontend-design-refs/font-catalog.json. Banned fonts: Inter, Roboto, Arial, Helvetica, system fonts, Space Grotesk. Select pairing per references/distinctive-frontend-design-refs/phase-details.md. Two typefaces max. Validate against banned list.

Phase 3: Color Palette

Research inspiration via references/distinctive-frontend-design-refs/color-inspirations.json. Build with strict 60/30/10 dominance (dominant, secondary, accent). One accent color only. Check against cliche list in references/distinctive-frontend-design-refs/preferred-patterns.json.

Phase 4: Animation Strategy

2-to-3 rule: ship 2-3 intentional motions per page. Fill three slots (entrance, scroll, interaction). Load references/distinctive-frontend-design-refs/animation-patterns.md for patterns. Define easing and timing per references/distinctive-frontend-design-refs/phase-details.md.

Phase 5: Hero & Background

First viewport reads as one composition. See references/distinctive-frontend-design-refs/app-vs-landing-rules.md for surface-specific rules. Choose technique from references/distinctive-frontend-design-refs/background-techniques.md. Minimum 2 layers. Check contrast against WCAG AA.

Phase 6: Validation

Run: python3 ${CLAUDE_SKILL_DIR}/scripts-distinctive-frontend-design/validate_design.py --fonts "Display,Body" --palette palette.json --project NAME --macrostructure macro:ID --animation --background --emitted-css generated.html

Score must reach 80 (Grade B+). If below, iterate on failed checks.

Phase 7: Specification Output

Emit design stamp comment. Generate CSS custom properties (tokens). Create base styles. Document specification. Provide framework-specific starter code if requested (see references/distinctive-frontend-design-refs/implementation-examples.md).


HTML-ARTIFACT Mode

Generate single self-contained .html files. All CSS in <style>, all JS in <script>. No CDN links, no frameworks, no external deps. Max 500KB.

Phase 0: Check Saved Template

Run: python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/fill-template.py --list

If a saved template matches, clone it and fill slots only. Skip to Phase 4 VALIDATE. Do not restyle the template.

Phase 1: Detect Shape

Run: python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/detect-shape.py --request "{request}"

Shape Signals Output
spec plan, compare, brainstorm Side-by-side grids, pro/con, SVG diagrams
code-review review PR, explain diff Diff rendering, severity colors, annotations
prototype prototype, tune, try options Sliders, CSS var live update, sandbox
report report, summarize, status TL;DR box, collapsibles, timeline, metrics
editor reorder, triage, edit config Drag-drop, kanban, toggles, export
data-viz visualize, chart, dashboard SVG charts, canvas, tooltips, filters
diagram diagram, flowchart, architecture Inline SVG, annotated flowcharts
deck slides, presentation, pitch Arrow-key nav, 16:9, progress bar

Low confidence falls back to report. Hybrid shapes: primary controls layout, secondary provides embedded components.

Phase 2: Assemble + Load Context

Run: python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/assemble-template.py --shape {shape} --title "{title}" --components {components}

Always load references/html-artifact-refs/design-system.md and references/html-artifact-refs/interaction-patterns.md. Load shape-specific reference from references/html-artifact-refs/shape-{name}.md.

Phase 3: Generate

Dispatch html-builder agent (see agents/html-builder.md) with pre-assembled template. Vanilla JS only. Semantic HTML. SVG inline.

Phase 4: Validate

Run: python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/validate-artifact.py {html_file}

Checks: valid HTML structure, no external deps, has <title>, charset, viewport, under 500KB, no broken internal refs, CSS slop scan. Fix failures and re-run (max 3 attempts).

Phase 5: Deliver

Print absolute file path, 1-line summary, offer browser open. Check $DISPLAY/$SSH_TTY before offering open on Linux.

Phase 6-7: Export (optional)

PDF: python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/to-pdf.py --input <html> --output <pdf> --json. PPTX (deck shape only): python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/pptx-bridge/run-unified.py --input <html> --format pptx --out <pptx> --no-render.


THREEJS Mode

Build Three.js web applications in four phases: Design, Build, Animate, Polish. Three paradigms detected from context.

Phase 1: Design

Detect paradigm first:

Signal Paradigm Load
@react-three/fiber, r3f, drei, useFrame React Three Fiber references/threejs-builder-refs/react-three-fiber.md
WebGPURenderer, TSL, compute shader WebGPU references/threejs-builder-refs/webgpu.md
Standalone HTML, new THREE.Scene(), vanilla Imperative references/threejs-builder-refs/advanced-topics.md
EventBus, GameState, player controller Game (alongside paradigm) references/threejs-builder-refs/game-architecture.md + game-patterns.md
GLTF/GLB, .glb, skeletal rigs GLTF (alongside paradigm) references/threejs-builder-refs/gltf-loading.md

If ambiguous, ask -- imperative and R3F patterns conflict. Identify core visual element, select components per references/threejs-builder-refs/build-recipes.md, document visual style.

Phase 2: Build

Follow paradigm-specific patterns from loaded reference. Imperative defaults: single HTML, CONFIG object, three-point lighting, renderer.setAnimationLoop(). See references/threejs-builder-refs/build-recipes.md for boilerplate, scene infrastructure, constraints.

Phase 3: Animate

R3F uses useFrame (never requestAnimationFrame). Imperative uses setAnimationLoop. No geometry/material allocation in animation loop. Wire interaction handlers per scene plan.

Phase 4: Polish

Remove debug helpers. Handle window resize. Verify visible lighting. Match visual style. Run 4 verification steps: responsive, visual quality, output test, cleanup. See references/threejs-builder-refs/build-recipes.md.


Deep References

Load on demand when the task needs detailed patterns, examples, or specifications:

Design Refs

File Content
references/design-refs/ux-copy.md Component-specific UX copy patterns
references/design-refs/design-systems.md Design token architecture, component specs
references/design-refs/design-critique.md Structured critique method, heuristics
references/design-refs/accessibility-review.md WCAG criteria, component a11y patterns
references/design-refs/design-handoff.md Spec categories, artifact templates
references/design-refs/llm-design-failure-modes.md 8 LLM failure modes with defenses

Distinctive Frontend Design Refs

File Content
references/distinctive-frontend-design-refs/font-catalog.json Curated fonts by aesthetic category
references/distinctive-frontend-design-refs/color-inspirations.json Cultural/contextual palette sources
references/distinctive-frontend-design-refs/animation-patterns.md Animation choreography with CSS/React
references/distinctive-frontend-design-refs/background-techniques.md Atmospheric background methods
references/distinctive-frontend-design-refs/implementation-examples.md CSS tokens, framework templates
references/distinctive-frontend-design-refs/macrostructure-catalog.md Named macro:* page structures
references/distinctive-frontend-design-refs/phase-details.md Selection processes, validation, timing
references/distinctive-frontend-design-refs/css-audit-patterns.md Detection commands for CSS slop
references/distinctive-frontend-design-refs/performance-budgets.md Render costs, layout thrashing
references/distinctive-frontend-design-refs/game-ui-polish.md Game-native UI polish rules
references/distinctive-frontend-design-refs/card-shader-patterns.md Fragment shader GLSL
references/distinctive-frontend-design-refs/shader-integration-react.md React 19 WebGL hook + context pool
references/distinctive-frontend-design-refs/balatro-shader-breakdown.md Holographic foil shader
references/distinctive-frontend-design-refs/roll-text.md Roll/slot text patterns
references/distinctive-frontend-design-refs/text-animation-patterns.md Reveal, typewriter, crossfade
references/distinctive-frontend-design-refs/oklch-color-harmony.md OKLCH color harmony
references/distinctive-frontend-design-refs/honest-placeholders.md Placeholder content rules

HTML Artifact Refs

File Content
references/html-artifact-refs/design-system.md Theme tokens, a11y checklist, SVG rules
references/html-artifact-refs/diagram-layering.md SVG layer order, dark-theme colors
references/html-artifact-refs/infographic-layouts.md 21 layout types, 22 visual styles
references/html-artifact-refs/pdf-export.md Page-size table, troubleshooting
references/html-artifact-refs/pptx-export.md Layout types, THEME dict, CLI ref
references/html-artifact-refs/shape-diagram-illustration.md SVG construction, diagram types

Three.js Refs

File Content
references/threejs-builder-refs/build-recipes.md Boilerplate, scene setup, error handling
references/threejs-builder-refs/react-three-fiber.md R3F patterns, Drei, Zustand
references/threejs-builder-refs/webgpu.md WebGPURenderer, TSL, compute shaders
references/threejs-builder-refs/advanced-topics.md GLTF, post-processing, shaders, physics
references/threejs-builder-refs/visual-polish.md Materials, lighting, HDR, shadows
references/threejs-builder-refs/gltf-loading.md Coordinate contract, caching, auto-center
references/threejs-builder-refs/game-patterns.md Animation FSM, camera movement, input
references/threejs-builder-refs/game-architecture.md EventBus, GameState, pre-ship checklist
references/threejs-builder-refs/shader-patterns.md ShaderMaterial, vertex displacement, effects
references/threejs-builder-refs/performance-patterns.md InstancedMesh, batching, LOD, dispose
references/threejs-builder-refs/advanced-animation.md AnimationMixer, IK, spring physics, GSAP

Scripts

  • Distinctive design: scripts-distinctive-frontend-design/validate_design.py, scripts-distinctive-frontend-design/css_slop_rules.py
  • HTML artifact: scripts-html-artifact/detect-shape.py, scripts-html-artifact/assemble-template.py, scripts-html-artifact/validate-artifact.py, scripts-html-artifact/fill-template.py, scripts-html-artifact/to-pdf.py, scripts-html-artifact/pptx-bridge/
  • HTML artifact templates: templates/, agents: agents/html-builder.md
Files (vexjoy-agent)
  • agents
    • html-builder.md 5.3 KB
      # html-builder
      
      You generate self-contained HTML artifacts. Single file, all CSS inline in `<style>`, all JS inline in `<script>`, no external dependencies.
      
      ## Inputs
      
      You receive from the orchestrator:
      - **shape**: One of `spec`, `code-review`, `prototype`, `report`, `editor`, `data-viz`, `diagram`, `deck`
      - **user_request**: The original request text
      - **pre-assembled template**: HTML skeleton from `assemble-template.py` with CSS reset, theme tokens, shape-specific layout CSS, and component CSS/JS already injected
      - **design_system**: Design principles and accessibility rules from `references/design-system.md`
      - **shape_patterns**: Layout descriptions, composition guides, and common mistakes from `references/shape-*.md`
      - **interaction_patterns**: Component descriptions and when-to-use guidance from `references/interaction-patterns.md`
      
      ## How Template Assembly Works
      
      Before you generate, the orchestrator runs:
      
      ```
      python3 skills/meta/html-artifact/scripts/assemble-template.py \
        --shape <shape> --title "<title>" --components <component1,component2>
      ```
      
      This outputs an HTML file with:
      1. CSS reset (from `templates/base-reset.css`)
      2. Full theme tokens (from `templates/themes/<theme>.css`)
      3. Shape-specific layout CSS (from `templates/shapes/<shape>.css`)
      4. Component CSS + JS (from `templates/components/<name>.{css,js}`)
      
      **You fill in the content structure.** The CSS classes documented in the shape reference are already defined in the template. Use them directly.
      
      ## Generation Rules
      
      ### Structure
      
      Start from the pre-assembled template. Fill in:
      - `<!-- CONTENT: header -->` with title, metadata, nav
      - `<!-- CONTENT -->` with primary content using shape-specific CSS classes
      - `<!-- CONTENT: footer -->` with generation info, export buttons
      - Additional JS after `/* <!-- SCRIPTS --> */` for shape-specific interactivity
      
      ### Shape-Specific Rules
      
      Reference files carry the full layout descriptions. These are the non-negotiable constraints per shape.
      
      | Shape | Must Include | Key Constraint |
      |---|---|---|
      | spec | N-column comparison grid, pro/con per option, metadata badges, recommendation section | Grid must scale from 2-5 columns; collapse to stacked on mobile |
      | code-review | Actual diffs with line numbers, severity-colored annotations, risk map overview, file jump links | Diff line numbers must be selectable; severity uses token-based colors |
      | prototype | Interactive controls (sliders, selectors), live preview area, export/copy button | Controls must update preview in real-time via CSS custom properties or DOM manipulation |
      | report | TL;DR box at top, metric callouts for numbers, collapsible sections for detail | TL;DR must be visible without scrolling; collapsibles default to collapsed |
      | editor | Drag-and-drop or form-based editing, state persistence in memory, export buttons | Must have at least 2 export formats; state survives re-ordering |
      | data-viz | SVG charts (not canvas unless >1000 data points), tooltips on data points, filter controls, legend | SVG preferred for accessibility; canvas only when dataset size demands it |
      | diagram | Inline SVG with labeled nodes, interactive hover/click, legend, copy SVG button | SVGs must use CSS custom properties; no external image refs |
      | deck | Slide container with arrow-key nav, slide counter, at least 2 slide types, progress bar | 16:9 aspect ratio; touch swipe support; print styles render all slides |
      
      ### Quality Rules
      
      1. **Design tokens** -- Use the CSS custom properties already in the template. Never hardcode colors, spacing, or font sizes.
      2. **Responsive** -- Works from 375px to 1440px+. The shape CSS handles breakpoints; add shape-specific responsive rules as needed.
      3. **Accessible** -- Keyboard navigation for all interactive elements. ARIA labels on controls. Focus indicators visible.
      4. **Title** -- `<title>` describes the content specifically, not generically.
      5. **File size** -- Under 500KB total.
      6. **Clean source** -- Semantic HTML elements, named JS functions with comments, section separators.
      7. **Reduced motion** -- The base reset handles this globally.
      
      ### Delivering the File
      
      1. Write the `.html` file to disk using the Write tool
      2. Default location: current working directory or project root
      3. Filename: kebab-case describing the content
      4. After writing: report the absolute file path
      
      ## Patterns to Replace
      
      | Common Mistake | Preferred Approach |
      |---|---|
      | CDN links | CSS is already in the template; add shape-specific styles inline |
      | Framework imports (React, Vue) | Vanilla JS -- single file, no build step |
      | Generate markdown then wrap in `<pre>` | Generate HTML natively using shape-specific CSS classes |
      | Hardcoded px values | Use `--sp-*` tokens and `--type-*` scale (already in template) |
      | Color by name (`red`, `#ff0000`) | Use `--color-*` semantic tokens (already in template) |
      | Redefine CSS that's in the template | The template has reset, theme, shape, and component CSS. Only add new styles. |
      
      ## Generation Process
      
      1. Start from the pre-assembled template (CSS/JS already injected)
      2. Plan the HTML structure: which sections, what interactive elements
      3. Write the semantic HTML body using CSS classes from the shape reference
      4. Write additional JS for shape-specific interactivity (component JS is already included)
      5. Self-review: check for external deps, hardcoded values, missing ARIA, missing export buttons
      6. Write the file to disk
      
  • assets
    • base-template.html 495 B · in bundle
  • references
    • design-refs
      • accessibility-review.md 13 KB
        # Accessibility Review Reference
        
        WCAG 2.1 AA compliance patterns organized by component type. Concrete criteria, ratios, and ARIA patterns that change audit behavior.
        
        ---
        
        ## WCAG 2.1 AA Quick Reference
        
        ### Perceivable
        
        | Criterion | Requirement | Test |
        |-----------|-------------|------|
        | 1.1.1 Non-text content | All meaningful images have alt text. Decorative images have `alt=""`. | Check every `<img>`, `<svg>`, icon for alt text or aria-label. |
        | 1.3.1 Info and relationships | Structure conveyed semantically (headings, lists, tables, landmarks). | Disable CSS — is content still structured? |
        | 1.3.4 Orientation | Content works in portrait and landscape. | Rotate device/viewport both ways. |
        | 1.4.1 Use of color | Color is not the only means of conveying information. | View in grayscale — is all info still clear? |
        | 1.4.3 Contrast (minimum) | Normal text: 4.5:1. Large text (18pt / 14pt bold): 3:1. | Measure every text/background pairing. |
        | 1.4.4 Resize text | Content readable at 200% zoom without horizontal scrolling. | Zoom to 200%, check for overflow and overlap. |
        | 1.4.11 Non-text contrast | UI components and graphical objects: 3:1 against adjacent colors. | Check button borders, form borders, icons, chart elements. |
        | 1.4.12 Text spacing | No loss of content when: line height 1.5x, paragraph spacing 2x, letter spacing 0.12em, word spacing 0.16em. | Apply spacing overrides, check for clipping. |
        | 1.4.13 Content on hover/focus | Tooltips/popovers: dismissible, hoverable, persistent until dismissed. | Hover over tooltip, move to its content — does it stay? Press Escape — does it close? |
        
        ### Operable
        
        | Criterion | Requirement | Test |
        |-----------|-------------|------|
        | 2.1.1 Keyboard | All functionality accessible via keyboard. | Tab through entire page. All controls reachable and operable. |
        | 2.1.2 No keyboard trap | Focus can always move away from any component. | Tab into every component, verify you can Tab/Escape out. |
        | 2.4.1 Bypass blocks | Skip-to-content link available. | First Tab stop should be "Skip to main content." |
        | 2.4.3 Focus order | Focus order matches visual layout and logical reading sequence. | Tab through page — does order make sense? |
        | 2.4.6 Headings and labels | Headings and labels describe topic or purpose. | Read headings alone — do they tell the page story? |
        | 2.4.7 Focus visible | Keyboard focus indicator is visible on all interactive elements. | Tab through — is focus ring visible at every stop? |
        | 2.5.5 Target size | Touch targets at least 44x44 CSS pixels. | Measure interactive elements. |
        
        ### Understandable
        
        | Criterion | Requirement | Test |
        |-----------|-------------|------|
        | 3.1.1 Language of page | `lang` attribute on `<html>`. | Check page source. |
        | 3.2.1 On focus | No context change on focus alone. | Tab to each element — does anything unexpected happen? |
        | 3.2.2 On input | No context change on input without warning. | Changing a dropdown should not navigate away without notice. |
        | 3.3.1 Error identification | Errors described in text (not color alone). | Trigger validation — are errors described? |
        | 3.3.2 Labels or instructions | Every input has a visible label. | Check that all fields have associated `<label>`. |
        | 3.3.3 Error suggestion | Suggestions provided for correctable errors. | Enter wrong format — does the error suggest the right one? |
        
        ### Robust
        
        | Criterion | Requirement | Test |
        |-----------|-------------|------|
        | 4.1.1 Parsing | Valid HTML (no duplicate IDs, proper nesting). | Run HTML validator. |
        | 4.1.2 Name, role, value | All UI components expose name, role, and value to assistive tech. | Inspect with screen reader — announced correctly? |
        
        ---
        
        ## Color Contrast Requirements
        
        ### Minimum Ratios
        
        | Element Type | Ratio | WCAG Criterion |
        |-------------|-------|---------------|
        | Normal text (< 18pt / < 14pt bold) | 4.5:1 | 1.4.3 |
        | Large text (>= 18pt / >= 14pt bold) | 3:1 | 1.4.3 |
        | UI components (borders, icons, focus rings) | 3:1 | 1.4.11 |
        | Decorative/disabled elements | No requirement | N/A |
        | Placeholder text | 4.5:1 (treat as informational text) | 1.4.3 |
        
        ### Common Pairings That Fail
        
        | Foreground | Background | Ratio | Verdict |
        |-----------|------------|-------|---------|
        | `#999999` | `#FFFFFF` | 2.85:1 | Fails normal text |
        | `#767676` | `#FFFFFF` | 4.54:1 | Passes (barely) |
        | `#FFFFFF` | `#3B82F6` (blue-500) | 3.51:1 | Passes large text only |
        | `#FFFFFF` | `#10B981` (green-500) | 2.47:1 | Fails all text |
        | `#FFFFFF` | `#EF4444` (red-500) | 3.05:1 | Passes large text only |
        | `#374151` | `#FFFFFF` | 10.14:1 | Passes all |
        | `#6B7280` | `#FFFFFF` | 4.63:1 | Passes (barely) |
        
        **Key insight**: Many popular design system colors fail WCAG when used as text backgrounds with white text. Verify every pairing. The 600-700 range of most palettes is safer for white text.
        
        ### Safe Text/Background Combinations
        
        | Use Case | Background | Text | Ratio |
        |----------|-----------|------|-------|
        | Primary body text | `#FFFFFF` | `#1F2937` (gray-800) | 14.72:1 |
        | Secondary body text | `#FFFFFF` | `#4B5563` (gray-600) | 7.45:1 |
        | Muted/tertiary text | `#FFFFFF` | `#6B7280` (gray-500) | 4.63:1 |
        | Dark surface body text | `#111827` (gray-900) | `#F9FAFB` (gray-50) | 18.06:1 |
        | Dark surface secondary | `#111827` | `#D1D5DB` (gray-300) | 10.27:1 |
        
        ---
        
        ## Keyboard Navigation Patterns
        
        ### Focus Management
        
        | Pattern | Keyboard Behavior | Focus Rule |
        |---------|-------------------|------------|
        | Page load | Focus on skip-to-content link (or first heading) | Do not auto-focus deep into the page |
        | Modal open | Focus moves to modal (first focusable element or title) | Trap focus inside modal |
        | Modal close | Focus returns to the element that triggered the modal | Preserve context |
        | Dropdown open | Focus on first option | Arrow keys navigate options |
        | Dropdown close | Focus returns to trigger button | Escape closes |
        | Tab panel switch | Focus on selected tab | Arrow keys switch tabs, Tab moves to panel content |
        | Toast/notification | Do not steal focus | Announce via live region |
        | Delete action | Focus moves to nearest remaining item | Do not leave focus on empty space |
        
        ### Standard Key Bindings
        
        | Key | Expected Behavior |
        |-----|------------------|
        | Tab | Move to next focusable element |
        | Shift+Tab | Move to previous focusable element |
        | Enter | Activate button, follow link, submit form |
        | Space | Toggle checkbox, activate button, scroll page |
        | Escape | Close modal, close dropdown, cancel action |
        | Arrow keys | Navigate within a component (tabs, menus, radio groups, sliders) |
        | Home/End | Move to first/last item in a list |
        
        ---
        
        ## ARIA Landmark Patterns
        
        ### Page Structure
        
        ```html
        <header role="banner">           → Site header, logo, global nav
        <nav role="navigation">          → Primary navigation
        <main role="main">               → Primary content (one per page)
        <aside role="complementary">     → Sidebar, related content
        <footer role="contentinfo">      → Site footer
        <section role="region">          → Named content section (requires aria-label)
        <form role="form">               → Named form (requires aria-label)
        <div role="search">              → Search functionality
        ```
        
        **Rule**: Every piece of content lives within a landmark. Screen reader users navigate by landmarks the way sighted users scan visually.
        
        ### Component ARIA Patterns
        
        | Component | Role | Key Attributes | Announcements |
        |-----------|------|---------------|---------------|
        | Button | `button` | `aria-pressed` (toggle), `aria-expanded` (menu trigger) | Label + state |
        | Link | `link` | `aria-current="page"` (current page in nav) | Label + "link" |
        | Dialog/Modal | `dialog` | `aria-modal="true"`, `aria-labelledby` | Title on open |
        | Alert | `alert` | (implicit live region) | Content read immediately |
        | Status | `status` | (polite live region) | Content read at next pause |
        | Tab | `tab` within `tablist` | `aria-selected`, `aria-controls` | Label + "tab" + "selected" |
        | Menu | `menu` with `menuitem` | `aria-expanded` on trigger | Item label + position |
        | Combobox | `combobox` | `aria-expanded`, `aria-activedescendant`, `aria-autocomplete` | Input + results count |
        | Switch | `switch` | `aria-checked` | Label + "switch" + "on/off" |
        | Progress | `progressbar` | `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-label` | Label + percentage |
        | Tooltip | `tooltip` | `aria-describedby` on trigger | Content read when trigger focused |
        | Accordion | `button` with `aria-expanded` controlling `region` | `aria-expanded="true/false"` | Label + "expanded/collapsed" |
        
        ---
        
        ## Common WCAG Violations by Component
        
        ### Forms
        
        | Violation | Impact | Fix |
        |-----------|--------|-----|
        | Input without `<label>` | Screen reader cannot identify the field | Associate `<label for="id">` with every input |
        | Placeholder as only label | Disappears on input, low contrast | Use visible label above field. Placeholder is supplemental. |
        | Error shown by color only | Color-blind users miss errors | Add error icon + text message alongside color change |
        | Required field indicator missing | Users guess which fields are required | Use `aria-required="true"` and visible "(required)" text or asterisk with legend |
        | Group without fieldset | Related radios/checkboxes lack context | Wrap in `<fieldset>` with `<legend>` |
        
        ### Navigation
        
        | Violation | Impact | Fix |
        |-----------|--------|-----|
        | No skip link | Keyboard users tab through entire nav every page | Add "Skip to main content" as first focusable element |
        | Current page not indicated | Screen reader users cannot tell where they are | Add `aria-current="page"` to current nav item |
        | Dropdown opens on hover only | Keyboard users cannot access submenu | Open on Enter/Space, navigate with Arrow keys |
        | Mobile hamburger menu not labeled | Screen reader says "button" with no context | Add `aria-label="Main menu"` and `aria-expanded` |
        
        ### Modals/Dialogs
        
        | Violation | Impact | Fix |
        |-----------|--------|-----|
        | Focus not trapped | Tab exits modal to background content | Trap focus within modal while open |
        | No Escape to close | Keyboard users stuck without clicking X | Add Escape key handler |
        | Focus not returned on close | User loses their place in the page | Return focus to the trigger element |
        | Background scrollable | Users interact with hidden content | Set `aria-hidden="true"` on background, prevent scroll |
        | Missing title | Screen reader announces modal with no context | Use `aria-labelledby` pointing to heading |
        
        ### Data Tables
        
        | Violation | Impact | Fix |
        |-----------|--------|-----|
        | No `<th>` elements | Screen reader cannot associate data with headers | Use `<th scope="col">` for column headers, `<th scope="row">` for row headers |
        | Complex table without `headers` | Multi-level headers lose association | Use `id` on `<th>` and `headers` attribute on `<td>` |
        | Sortable column not announced | Screen reader users cannot tell sort state | Add `aria-sort="ascending/descending/none"` to `<th>` |
        | Missing caption | Table purpose unclear | Add `<caption>` or `aria-label` on `<table>` |
        
        ### Media
        
        | Violation | Impact | Fix |
        |-----------|--------|-----|
        | Auto-playing audio/video | Disorienting, especially for screen reader users | Require user action to play. Provide pause/stop/mute controls. |
        | No captions on video | Deaf/hard-of-hearing users excluded | Provide synchronized captions (not auto-generated alone) |
        | No alt text on informational images | Screen reader users miss content | Write descriptive alt text: what the image shows and why it matters |
        | Decorative image announced | Screen reader noise | Use `alt=""` and `role="presentation"` |
        
        ---
        
        ## Screen Reader Testing Checklist
        
        | Check | Method |
        |-------|--------|
        | Page title descriptive | Screen reader announces page title on load |
        | Headings create outline | Navigate by heading (H key in NVDA/VoiceOver) — does the outline make sense? |
        | Links make sense out of context | List all links — do labels like "Click here" or "Read more" appear? Use descriptive labels instead. |
        | Forms announce labels | Focus each field — does the screen reader announce the label? |
        | Images described | Navigate images — are informational images described? Are decorative images silent? |
        | Dynamic content announced | Trigger a notification/status change — is it announced via live region? |
        | Modal focus managed | Open/close modal — does focus behave correctly? |
        | Error messages announced | Trigger validation — are errors announced and associated with fields? |
        
        ---
        
        ## Focus Visible Patterns
        
        | Pattern | CSS Approach | Notes |
        |---------|-------------|-------|
        | Default browser | `outline: auto` | Varies by browser. Often insufficient contrast. |
        | Custom ring | `outline: 2px solid [focus-color]; outline-offset: 2px;` | Use a high-contrast color. 2px offset prevents clipping. |
        | Combined | `outline` + `box-shadow` for double ring | Works when single outline might blend with element border |
        | Dark mode | Use lighter focus color in dark mode | Ensure 3:1 contrast against the dark surface |
        
        **Rule**: The focus indicator must have at least 3:1 contrast against adjacent colors and be at least 2px in the shortest dimension. This is testable — measure it.
        
      • design-critique.md 10.4 KB
        # Design Critique Reference
        
        Structured methodology for evaluating designs. Concrete frameworks that change model behavior — not "give constructive feedback" generics.
        
        ---
        
        ## Four-Step Critique Method
        
        Apply in order. Each step builds on the previous.
        
        ### Step 1: Describe (What Is Present)
        
        Observe and name without judgment. This forces careful looking before evaluating.
        
        | Observe | Questions |
        |---------|-----------|
        | Elements | What components, images, text blocks, and controls are present? |
        | Layout | How is content organized? Grid, hierarchy, grouping? |
        | Visual relationships | What's near what? What aligns? What stands alone? |
        | Color usage | What palette? How many colors? Where are they used? |
        | Typography | How many type sizes? Weights? What creates the reading order? |
        | Spacing | Tight or loose? Consistent or varied? Where does white space live? |
        
        **Output**: Neutral inventory. "The page has a two-column layout with a sidebar navigation on the left and a content area on the right. Three heading levels are visible."
        
        ### Step 2: Analyze (How Principles Apply)
        
        Evaluate the design against fundamental principles:
        
        | Principle | What to Check | Strong Signal | Weak Signal |
        |-----------|--------------|---------------|-------------|
        | **Contrast** | Do important elements stand out? | CTA visually distinct from surrounding elements | Primary and secondary actions look the same |
        | **Hierarchy** | Is there a clear reading order? | Eye follows: heading -> key content -> CTA | Multiple elements compete for attention |
        | **Alignment** | Are elements on a consistent grid? | Clean vertical and horizontal alignment | Elements placed "close enough" without snapping |
        | **Proximity** | Are related items grouped? | Labels near their fields, actions near their context | Related elements scattered across the screen |
        | **Repetition** | Are patterns consistent? | Same card style used for all similar content | Each card has slightly different padding/layout |
        | **Balance** | Does the layout feel stable? | Visual weight distributed intentionally | One side heavy with content, other empty |
        
        ### Step 3: Interpret (What the Design Communicates)
        
        | Dimension | Questions |
        |-----------|-----------|
        | Emotional tone | Does it feel professional, playful, urgent, calm? Is that appropriate? |
        | Brand alignment | Does it look like it belongs to this product/brand? |
        | User expectations | Would users recognize this as [type of screen]? Does it match their mental model? |
        | Information priority | What does the design say is most important? Is the design right? |
        | Trust signals | Does it feel trustworthy? Reliable? Appropriate for the data sensitivity? |
        
        ### Step 4: Evaluate (What Works, What to Improve)
        
        Structure every finding as: **Element + Issue + Impact + Recommendation**.
        
        | Element | Weak Feedback | Strong Feedback |
        |---------|--------------|----------------|
        | CTA button | "The button doesn't stand out" | "The primary CTA ('Start trial') uses the same visual weight as navigation links. Users may miss it. Increase contrast: use a filled button with `color-brand-primary` background." |
        | Form layout | "The form is confusing" | "Card number, expiry, and CVV appear in a single column at full width. Group them in a row to match the physical card layout and reduce vertical scrolling." |
        | Error state | "Error handling is bad" | "Form validation shows all errors at the top of the form. Users must scroll up to see them, then scroll back down to fix them. Show each error inline below its field." |
        
        ---
        
        ## Nielsen's 10 Heuristics — Applied
        
        Each heuristic with concrete application to real design patterns. Apply to the specific design; skip heuristics that do not apply.
        
        ### 1. Visibility of System Status
        
        The system keeps users informed about what is happening.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | File upload | Progress bar with percentage and estimated time | Spinner with no indication of progress or duration |
        | Form submission | Button shows loading state, then success confirmation | Page refreshes with no feedback |
        | Background process | Status indicator: "Syncing 3 of 12 files..." | Nothing visible until complete |
        | Search | Results count and load time shown | Results appear with no context |
        
        ### 2. Match Between System and Real World
        
        Use language and concepts familiar to users.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Terminology | "Shopping cart" (matches mental model) | "Procurement queue" (internal jargon) |
        | Organization | Calendar view for scheduling | Timestamp list for scheduling |
        | Icons | Trash can for delete | Abstract geometric shape for delete |
        
        ### 3. User Control and Freedom
        
        Users make mistakes. Provide exits, undo, and recovery.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Destructive action | "Undo" toast for 10 seconds after deletion | Immediate permanent deletion |
        | Navigation | Clear back button, breadcrumbs | Deep modal with no escape route |
        | Multi-step flow | "Save draft" available at every step | Losing all progress on back navigation |
        
        ### 4. Consistency and Standards
        
        Follow platform conventions and internal patterns.
        
        | Check | Questions |
        |-------|-----------|
        | Platform | Does it follow iOS/Android/Web conventions for this control type? |
        | Internal | Does it match other screens in the same product? |
        | Industry | Does it match user expectations from similar products? |
        | Visual | Same spacing, colors, typography for same element types? |
        
        ### 5. Error Prevention
        
        Design to prevent errors before they happen.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Input formatting | Auto-format phone number as user types | Reject input after submission for wrong format |
        | Destructive action | Require typing project name to confirm deletion | Single "Delete" button with no confirmation |
        | Data loss | Auto-save with version history | No save reminder before navigating away |
        
        ### 6. Recognition Over Recall
        
        Make options visible. Reduce memory load.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Navigation | Visible sidebar with all sections | Remembering keyboard shortcuts for navigation |
        | Search | Recent searches and suggestions shown | Empty search box with no hints |
        | Forms | Dropdown with options visible | Free-text field requiring exact format |
        
        ### 7. Flexibility and Efficiency of Use
        
        Serve both novices and experts.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Power users | Keyboard shortcuts + visual UI both available | Keyboard only or mouse only |
        | Customization | Configurable dashboard with sensible defaults | Fixed layout for all users |
        | Shortcuts | Recent/favorite items accessible | Start from scratch every time |
        
        ### 8. Aesthetic and Minimalist Design
        
        Every element earns its space. Visual noise competes with content.
        
        | Check | Questions |
        |-------|-----------|
        | Content priority | Is the most important content most prominent? |
        | Visual noise | Are there decorative elements that add no information? |
        | Density | Is there enough white space for comfortable scanning? |
        | Distraction | Do secondary elements pull attention from primary tasks? |
        
        ### 9. Help Users Recognize, Diagnose, and Recover from Errors
        
        Error messages are plain language, indicate the problem, and suggest a solution.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Form error | "Password must be at least 8 characters" shown at the field | "Error" shown at top of page |
        | System error | "We couldn't save your changes. Your work is safe — try again in a moment." | "500 Internal Server Error" |
        | Auth error | "Wrong password. Reset your password?" with link | "Authentication failed" |
        
        ### 10. Help and Documentation
        
        Provide contextual help where users need it.
        
        | Pattern | Strong | Weak |
        |---------|--------|------|
        | Complex feature | Inline tooltip explaining the option | Separate help page requiring navigation away |
        | First use | Contextual walkthrough of key features | 20-step feature tour on first login |
        | Edge case | "Learn more" link next to advanced option | No explanation of advanced options |
        
        ---
        
        ## Cognitive Walkthrough Methodology
        
        Simulate a new user attempting a specific task. For each step:
        
        | Question | What It Reveals |
        |----------|----------------|
        | Will the user try to achieve the right effect? | Is the goal clear? |
        | Will the user see the correct action is available? | Is the affordance visible? |
        | Will the user associate the action with the desired effect? | Is the label/icon clear? |
        | Will the user see progress after taking the action? | Is feedback provided? |
        
        **Walkthrough output**: Step-by-step table with each action, success/failure prediction, and specific issues found.
        
        ---
        
        ## Critique Framing by Audience
        
        | Audience | Frame | Focus | Language |
        |----------|-------|-------|----------|
        | Designer (peer) | Collaborative exploration | Design decisions, alternatives, rationale | "Have you considered..." / "What if..." |
        | Designer (junior) | Educational, encouraging | Principles applied, growth areas, what works | "This works because..." / "Try this approach..." |
        | Stakeholder | Impact-focused | User impact, business goals, competitive context | "Users will..." / "This supports [goal] by..." |
        | Developer | Actionable, specific | Implementation implications, edge cases, specs | "This needs [specific state]" / "Token: [name]" |
        | Cross-functional review | Balanced, structured | Findings organized by severity and category | Numbered findings, severity levels, priority order |
        
        ---
        
        ## Critique Failure Modes
        
        | Failure Mode | Do instead |
        |-------------|-----------|
        | Vague praise: "Looks good" | Specific observation: "The card grid creates a consistent scanning pattern and the hover states clearly indicate interactivity" |
        | Vague criticism: "This is confusing" | Specific finding: "The filter controls and sort controls use the same visual style, making it unclear which is which. Differentiate with distinct grouping or labels." |
        | Unsolicited redesign: "You should redo the whole layout" | Targeted recommendation: "The sidebar navigation competes with the main content. Consider reducing sidebar width or collapsing it by default." |
        | Subjective preference: "I don't like the color" | Principled feedback: "The blue links on the blue-tinted background have a contrast ratio of 2.8:1, below the 4.5:1 WCAG AA requirement." |
        | Missing context: Critiquing a prototype for pixel-perfection | Stage-appropriate feedback: "Since this is an early prototype, focusing on the flow: the 3-step checkout reduces friction compared to the current 5-step flow." |
        
      • design-handoff.md 9.9 KB
        # Design Handoff Reference
        
        Spec documentation patterns for developer handoff. Every section here represents a category developers need to build from a design without guessing.
        
        ---
        
        ## Handoff Artifact Checklist
        
        Use this checklist to verify completeness before delivering a spec.
        
        | Category | Artifact | Status |
        |----------|----------|--------|
        | **Layout** | Grid system documented | |
        | | Breakpoints defined | |
        | | Responsive behavior per breakpoint | |
        | | Content area max-width | |
        | **Tokens** | Color tokens mapped to usage | |
        | | Typography tokens mapped to elements | |
        | | Spacing tokens mapped to gaps/padding | |
        | | Elevation tokens mapped to surfaces | |
        | | Motion tokens mapped to transitions | |
        | **Components** | Component name and variant for each element | |
        | | Props/configuration for each instance | |
        | | Custom overrides documented | |
        | **States** | Default state for all interactive elements | |
        | | Hover state | |
        | | Focus state (keyboard) | |
        | | Active/pressed state | |
        | | Disabled state | |
        | | Loading state | |
        | | Error state | |
        | | Selected state (where applicable) | |
        | | Empty state | |
        | **Interaction** | Click/tap behavior | |
        | | Hover behavior (desktop) | |
        | | Transitions and animations | |
        | | Gesture support (mobile) | |
        | | Drag behavior (if applicable) | |
        | **Content** | Character limits per field | |
        | | Truncation rules (ellipsis, line clamp, fade) | |
        | | Empty state content and layout | |
        | | Loading state treatment (skeleton, spinner, placeholder) | |
        | | Error state content and layout | |
        | **Edge Cases** | Minimum content (single item, short text) | |
        | | Maximum content (overflow, long text, many items) | |
        | | International text (30-40% expansion) | |
        | | Slow/offline connection behavior | |
        | | Missing/null data handling | |
        | | Permission variations (what changes by role) | |
        | **Accessibility** | Focus order documented | |
        | | ARIA labels and roles specified | |
        | | Keyboard interactions defined | |
        | | Screen reader announcements noted | |
        | | Color contrast verified for all pairings | |
        
        ---
        
        ## Spec Documentation Patterns
        
        ### Layout Specification
        
        | Property | What to Specify | Example |
        |----------|----------------|---------|
        | Grid | Column count, gutter width, margins | 12-col grid, 24px gutter, 32px margins |
        | Max width | Content area maximum | max-width: 1200px, centered |
        | Sidebar | Width (fixed or percentage), collapse behavior | 280px fixed, collapses to icon-only at <1024px |
        | Stacking order | z-index layers for overlapping elements | nav: 100, dropdown: 200, modal: 300, toast: 400 |
        | Overflow | Scroll behavior per container | Content area scrolls independently, sidebar fixed |
        
        ### Responsive Behavior Specification
        
        | Breakpoint | Name | Layout Changes |
        |-----------|------|---------------|
        | >= 1280px | Desktop Large | Full layout, sidebar expanded |
        | 1024-1279px | Desktop | Full layout, sidebar collapsed |
        | 768-1023px | Tablet | Single column, bottom navigation |
        | < 768px | Mobile | Stacked layout, hamburger menu, full-width cards |
        
        For each breakpoint, document:
        - What elements reflow, hide, or change behavior
        - Touch target size adjustments (44px minimum on touch devices)
        - Typography scale changes (if any)
        - Spacing adjustments (tighter on mobile)
        - Navigation pattern changes
        
        ### Token-to-Value Mapping
        
        | Token | Value | Where Used |
        |-------|-------|-----------|
        | `color-primary` | `#3B82F6` | CTA buttons, active tab, links |
        | `color-primary-hover` | `#2563EB` | CTA hover state |
        | `color-surface-primary` | `#FFFFFF` | Main background |
        | `color-surface-secondary` | `#F9FAFB` | Card backgrounds, alternating rows |
        | `color-border-default` | `#E5E7EB` | Card borders, dividers |
        | `color-text-primary` | `#111827` | Headings, body text |
        | `color-text-secondary` | `#6B7280` | Metadata, helper text |
        | `font-heading-lg` | `24px/1.25 Inter 600` | Page titles |
        | `font-body` | `16px/1.5 Inter 400` | Body text |
        | `font-caption` | `12px/1.5 Inter 400` | Timestamps, badges |
        | `spacing-section` | `32px` | Between major sections |
        | `spacing-card-padding` | `16px` | Inside cards |
        | `radius-card` | `8px` | Card corners |
        | `radius-button` | `6px` | Button corners |
        
        **Rule**: Every visual value in the spec references a token. If a value has no token, flag it as "needs token" — this prevents design system drift.
        
        ---
        
        ## Interaction Documentation
        
        ### State Transitions
        
        Document each interactive element as a state machine:
        
        | Element | Trigger | From State | To State | Transition | Notes |
        |---------|---------|-----------|----------|-----------|-------|
        | CTA Button | Mouse enter | Default | Hover | `background-color 150ms ease` | Darken 10% |
        | CTA Button | Mouse down | Hover | Active | `transform 100ms ease` | Scale to 0.98 |
        | CTA Button | Click | Active | Loading | `opacity 150ms ease` | Show spinner, disable |
        | CTA Button | API success | Loading | Default | Instant | Show success toast |
        | CTA Button | API error | Loading | Default | Instant | Show error message |
        | Sidebar | Toggle click | Expanded | Collapsed | `width 250ms ease-in-out` | Icons remain visible |
        | Modal | Trigger click | Closed | Open | `opacity 250ms ease-out` + backdrop | Focus first element |
        | Modal | Escape/backdrop | Open | Closed | `opacity 200ms ease-in` | Return focus to trigger |
        | Accordion | Header click | Collapsed | Expanded | `max-height 250ms ease-out` | Scroll into view if needed |
        
        ### Gesture Documentation (Mobile)
        
        | Gesture | Element | Action | Threshold | Feedback |
        |---------|---------|--------|-----------|----------|
        | Swipe left | List item | Reveal delete action | 80px | Red background slides in |
        | Swipe right | List item | Reveal archive action | 80px | Green background slides in |
        | Pull down | List view | Refresh content | 60px + release | Spinner at top |
        | Long press | Card | Enter selection mode | 500ms | Haptic + visual selection indicator |
        | Pinch | Image | Zoom in/out | Two fingers | 1x to 3x range, spring back at limits |
        
        ---
        
        ## Edge Case Documentation
        
        ### Content Extremes
        
        | Scenario | How to Handle | Example |
        |----------|---------------|---------|
        | Empty text field | Show placeholder, not broken layout | "No description provided" in muted text |
        | Single character name | Layout holds with minimal content | "A" in avatar, card width unchanged |
        | 200-character name | Truncate with ellipsis | "Alexandra Constantino..." at max-width |
        | 0 items in list | Show empty state with CTA | Illustration + "No items yet" + [Create first item] |
        | 1 item in list | Layout works with single item | Same card layout, no "showing 1 of 1" |
        | 10,000 items | Virtualized list or pagination | Show first 50, load more on scroll |
        | Mixed content lengths | Consistent card height or flexible grid | Cards align to grid regardless of content |
        
        ### Data States
        
        | State | Visual Treatment | Content |
        |-------|-----------------|---------|
        | Loading (initial) | Skeleton placeholders matching content shape | No text, pulsing gray rectangles |
        | Loading (more) | Spinner at bottom of list | Existing content visible |
        | Loading (action) | Button spinner, disabled state | "Saving..." text optional |
        | Error (page) | Full-page error with retry | "Something went wrong. [Try again]" |
        | Error (inline) | Inline error below the failing element | Specific error message + fix suggestion |
        | Error (toast) | Toast notification | Brief error + action if applicable |
        | Offline | Banner at top of page | "You're offline. Changes will sync when you reconnect." |
        | Stale data | Subtle indicator | "Last updated 5 min ago. [Refresh]" |
        
        ### Internationalization
        
        | Factor | Spec Requirement |
        |--------|-----------------|
        | Text expansion | All text containers handle 40% longer strings |
        | Number formatting | Use locale-aware formatting (`1,234.56` vs `1.234,56`) |
        | Date formatting | Use locale-aware dates (use date formatting libraries instead of hardcoded month names) |
        | RTL support | Layout mirrors for RTL locales (flex-direction, text-align, icon placement) |
        | Currency | Symbol position varies by locale (`$100` vs `100 $`) |
        | Pluralization | Support plural rules beyond singular/plural (some languages have 6 forms) |
        
        ---
        
        ## Developer Q&A Template
        
        Include with every handoff spec. Pre-answer the questions developers always ask.
        
        | Question | Answer |
        |----------|--------|
        | What component library/framework? | [React / Vue / Angular / SwiftUI / etc.] |
        | Which design tokens file? | [Path or URL to tokens] |
        | Where do I find the icons? | [Icon library + specific icon names used] |
        | What happens when [element] overflows? | [Truncation rule, scroll behavior, or wrap behavior] |
        | What are the loading states? | [Per-element loading treatment] |
        | What happens on error? | [Error display strategy per context] |
        | What animations are needed? | [Token references, durations, easings] |
        | Is there a mobile-specific behavior? | [Per-breakpoint differences] |
        | What's the focus order? | [Tab order specification] |
        | What ARIA attributes are needed? | [Per-component ARIA spec] |
        | Where does the data come from? | [API endpoint or data source for each dynamic element] |
        | What are the permission levels? | [What changes based on user role] |
        | What are the feature flags? | [Any conditional rendering based on flags] |
        
        ---
        
        ## Handoff Quality Checks
        
        Before delivering, verify:
        
        | Check | Method |
        |-------|--------|
        | Every interactive element has all states | Cross-reference against state checklist |
        | Token references used throughout | Search for raw hex/px values — flag any found |
        | Responsive behavior explicit | Each breakpoint has documented changes |
        | Edge cases documented | Empty, loading, error, overflow, missing data all addressed |
        | Accessibility requirements stated | Focus order, ARIA, keyboard, screen reader all specified |
        | Developer questions pre-answered | Q&A template filled out |
        | Content specs complete | Character limits, truncation rules, placeholder text defined |
        | Animation specs include token references | Duration and easing use token names |
        
      • design-systems.md 9.3 KB
        # Design Systems Reference
        
        Domain knowledge for design token architecture, component API design, and system governance. Concrete patterns that change model behavior.
        
        ---
        
        ## Design Token Architecture
        
        ### Token Layers
        
        Tokens organize into three layers. Each layer serves a different audience.
        
        | Layer | Purpose | Example | Consumer |
        |-------|---------|---------|----------|
        | **Global** | Raw values | `blue-500: #3B82F6` | System maintainers only |
        | **Semantic** | Meaning-mapped | `color-primary: {blue-500}` | Component authors |
        | **Component** | Context-specific | `button-bg-primary: {color-primary}` | Implementation detail |
        
        **Key rule**: Components reference semantic tokens. Semantic tokens reference global tokens. Components do not reference global tokens directly. This indirection enables theming.
        
        ### Color Token System
        
        | Category | Tokens | Usage |
        |----------|--------|-------|
        | **Brand** | `color-brand-primary`, `color-brand-secondary`, `color-brand-accent` | Brand identity, primary CTAs, key UI accents |
        | **Semantic** | `color-success`, `color-warning`, `color-error`, `color-info` | Status communication, form validation, alerts |
        | **Neutral** | `color-neutral-50` through `color-neutral-900` | Text, backgrounds, borders, dividers |
        | **Surface** | `color-surface-primary`, `color-surface-secondary`, `color-surface-elevated` | Background layers, cards, modals |
        | **Interactive** | `color-interactive-default`, `color-interactive-hover`, `color-interactive-active`, `color-interactive-disabled` | Buttons, links, form controls |
        | **Text** | `color-text-primary`, `color-text-secondary`, `color-text-disabled`, `color-text-inverse` | Typography hierarchy |
        
        **Contrast rule**: Every text/background pairing must meet WCAG 2.1 AA. Document the contrast ratio for each pairing in the token spec.
        
        ### Typography Token System
        
        | Token | Purpose | Example Value |
        |-------|---------|---------------|
        | `font-family-sans` | UI text | Inter, system-ui, sans-serif |
        | `font-family-mono` | Code, data | JetBrains Mono, monospace |
        | `font-size-xs` | Captions, badges | 12px / 0.75rem |
        | `font-size-sm` | Helper text, metadata | 14px / 0.875rem |
        | `font-size-md` | Body text | 16px / 1rem |
        | `font-size-lg` | Section headings | 18px / 1.125rem |
        | `font-size-xl` | Page headings | 24px / 1.5rem |
        | `font-size-2xl` | Hero text | 30px / 1.875rem |
        | `font-size-3xl` | Display | 36px / 2.25rem |
        | `font-weight-regular` | Body text | 400 |
        | `font-weight-medium` | Emphasis, labels | 500 |
        | `font-weight-semibold` | Headings, buttons | 600 |
        | `font-weight-bold` | Strong emphasis | 700 |
        | `line-height-tight` | Headings | 1.25 |
        | `line-height-normal` | Body text | 1.5 |
        | `line-height-relaxed` | Long-form content | 1.75 |
        
        **Type scale**: Use a consistent ratio (1.25 major third or 1.333 perfect fourth). Every size derives from base * ratio^n. This creates visual harmony and prevents arbitrary sizing.
        
        ### Spacing Token System
        
        | Token | Value | Usage |
        |-------|-------|-------|
        | `space-0` | 0 | Reset |
        | `space-1` | 4px | Tight grouping (icon + label) |
        | `space-2` | 8px | Related elements within a group |
        | `space-3` | 12px | Between form fields |
        | `space-4` | 16px | Standard component padding |
        | `space-5` | 20px | Section content padding |
        | `space-6` | 24px | Between related sections |
        | `space-8` | 32px | Between distinct sections |
        | `space-10` | 40px | Major section separation |
        | `space-12` | 48px | Page-level spacing |
        | `space-16` | 64px | Layout columns, major gaps |
        
        **Base unit**: 4px. All spacing values are multiples of 4. This creates a consistent rhythm and prevents "just add 3px" drift.
        
        ### Elevation Token System
        
        | Token | Shadow | Usage |
        |-------|--------|-------|
        | `elevation-0` | none | Flat elements, inline content |
        | `elevation-1` | `0 1px 2px rgba(0,0,0,0.05)` | Cards, raised surfaces |
        | `elevation-2` | `0 4px 6px rgba(0,0,0,0.07)` | Dropdowns, popovers |
        | `elevation-3` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dialogs |
        | `elevation-4` | `0 20px 25px rgba(0,0,0,0.15)` | Notifications, toasts |
        
        **Rule**: Higher elevation = closer to user = more important. Elevation communicates layer hierarchy. Two elements at the same elevation should not overlap.
        
        ### Motion Token System
        
        | Token | Value | Usage |
        |-------|-------|-------|
        | `duration-instant` | 100ms | Micro-interactions (hover, focus ring) |
        | `duration-fast` | 150ms | Toggles, color changes |
        | `duration-normal` | 250ms | Modals, panels, expand/collapse |
        | `duration-slow` | 400ms | Page transitions, complex animations |
        | `easing-default` | `ease-in-out` | Most transitions |
        | `easing-enter` | `ease-out` | Elements appearing (modal enters) |
        | `easing-exit` | `ease-in` | Elements leaving (modal exits) |
        | `easing-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Playful, bouncy interactions |
        
        ---
        
        ## Component API Design Patterns
        
        ### Naming Conventions
        
        | Entity | Convention | Examples |
        |--------|-----------|----------|
        | Components | PascalCase, noun-based | `Button`, `TextField`, `DataTable` |
        | Variants | Descriptive adjective or purpose | `primary`, `secondary`, `ghost`, `destructive` |
        | Sizes | T-shirt sizing | `sm`, `md`, `lg` (avoid `small`, `medium`, `large` — more characters, same info) |
        | States | Present participle or adjective | `disabled`, `loading`, `selected`, `expanded` |
        | Slots | Noun describing the content | `icon`, `label`, `description`, `badge` |
        | Events | `on` + PascalCase verb | `onClick`, `onChange`, `onDismiss` |
        | Booleans | `is` or `has` prefix | `isDisabled`, `hasError`, `isOpen` |
        
        **Consistency rule**: Pick one convention and apply it everywhere. A component that uses `variant` while another uses `type` for the same concept creates confusion.
        
        ### Variant Strategies
        
        | Strategy | When to Use | Example |
        |----------|-------------|---------|
        | **Prop-based** | Few, orthogonal variations | `<Button variant="primary" size="md">` |
        | **Compound** | Complex components with sub-parts | `<Select><Select.Option>` |
        | **Composition** | Maximum flexibility needed | `<Card><CardHeader><CardBody>` |
        | **Polymorphic** | Component renders different elements | `<Button as="a" href="...">` |
        
        ### State Management
        
        Every interactive component defines these states:
        
        | State | Visual Treatment | Behavior |
        |-------|-----------------|----------|
        | Default | Base appearance | Interactive |
        | Hover | Subtle highlight (background or border shift) | Shows interactivity |
        | Focus | Visible focus ring (2px offset, high contrast) | Keyboard accessible |
        | Active/Pressed | Darker/depressed appearance | Confirms activation |
        | Disabled | Reduced opacity (0.5), muted colors | Non-interactive, cursor: not-allowed |
        | Loading | Spinner or skeleton, text preserved for layout | Non-interactive during load |
        | Error | Error color border/outline, error icon | Shows validation failure |
        | Selected | Distinct background or check indicator | Shows current selection |
        
        ---
        
        ## Composition Patterns
        
        ### Slot-Based Composition
        
        Components expose named slots for content injection:
        
        ```
        <Card>
          <Card.Header>       → title area
          <Card.Media>         → image/video area
          <Card.Body>          → main content
          <Card.Actions>       → button area
        </Card>
        ```
        
        **Rule**: Slots define where content goes. The component controls layout. The consumer controls content.
        
        ### Render Prop Pattern
        
        For components that need to expose internal state:
        
        ```
        <Combobox>
          {({ isOpen, selectedItem }) => (
            <Combobox.Input />
            <Combobox.Options />
          )}
        </Combobox>
        ```
        
        ### Provider Pattern
        
        For cross-cutting concerns (theme, locale, feature flags):
        
        ```
        <ThemeProvider theme={darkTheme}>
          <App />    → all descendants access theme tokens
        </ThemeProvider>
        ```
        
        ---
        
        ## Theme Architecture
        
        | Concern | Implementation | Example |
        |---------|---------------|---------|
        | Light/dark mode | Swap semantic token values | `color-surface-primary: white` (light) / `#1a1a1a` (dark) |
        | Brand theming | Override brand tokens | `color-brand-primary: {partner-blue}` |
        | Density | Adjust spacing and sizing tokens | `space-4: 12px` (compact) / `16px` (default) / `20px` (comfortable) |
        | Color scheme | Semantic tokens map to different global palettes | `color-primary: {indigo-600}` (default) / `{teal-600}` (alt) |
        
        **Theme structure**: Themes override semantic tokens only. Global tokens remain stable. Components do not need to know which theme is active.
        
        ---
        
        ## Documentation Standards
        
        Every component's documentation includes:
        
        | Section | Contents |
        |---------|----------|
        | Description | What it is, when to use it, when to use something else |
        | Props table | Name, type, default, description for every prop |
        | Variants gallery | Visual example of every variant |
        | States reference | Visual example of every state |
        | Accessibility | ARIA role, keyboard behavior, screen reader announcements |
        | Usage guidelines | Do's (with rationale) and alternatives (with context) |
        | Code examples | Minimum viable, common patterns, edge cases |
        | Related components | What's similar and when to pick which |
        
        **Do/Instead pattern for documentation**:
        
        | Do | Instead of |
        |------|-----------|
        | Use `Button variant="destructive"` for delete actions | Using `variant="primary"` with a red color override |
        | Compose `Card` with `Card.Header` for titled content | Passing `title` as a string prop (limits formatting) |
        | Use `TextField` with `type="email"` for email inputs | Building a custom input with regex validation |
        
      • llm-design-failure-modes.md 10.3 KB
        # LLM Design Failure Modes
        
        Specific ways LLMs fail at design tasks. Each failure mode includes detection signals and concrete defenses. Load this reference for every design mode.
        
        > **Shared base**: Universal LLM failure modes (hallucination, overconfidence, generic output, arithmetic errors, stale knowledge) are documented in `skills/shared-patterns/llm-domain-failure-modes-base.md`. This file covers design-specific failures only.
        
        ---
        
        ## 1. Generic Copy Without Product Context
        
        **What happens**: LLM generates professionally-written copy that could belong to any product. "Get started today!" "Something went wrong." "Welcome aboard!"
        
        **Detection signals**:
        - Copy works equally well for a banking app and a gaming platform
        - No product-specific terminology
        - Tone does not match the product's existing voice
        - Generic verbs: "Submit", "Continue", "OK"
        
        **Defense**:
        - Ask for the product's existing terminology and voice before writing
        - Match button labels to the specific outcome: "Create invoice" not "Submit"
        - Reference the user's actual task: "No invoices yet" not "Nothing here yet"
        - Check: would a competitor's product use this exact same copy? If yes, it's too generic. Make it specific.
        
        ---
        
        ## 2. Inaccessible Color Suggestions
        
        **What happens**: LLM recommends color combinations that look appealing but fail WCAG contrast requirements. Common with white text on medium-brightness backgrounds.
        
        **Detection signals**:
        - White text on any color lighter than the 600 shade (e.g., blue-500, green-500)
        - Gray text lighter than #767676 on white
        - Colored text on colored backgrounds without ratio check
        - Status colors (green for success, red for error) used as text color on white
        
        **Defense**:
        - Calculate contrast ratio for every text/background pairing recommended
        - Minimum 4.5:1 for normal text, 3:1 for large text (18pt+), 3:1 for UI components
        - Common safe pairings: gray-800 on white (14.72:1), gray-600 on white (7.45:1)
        - When suggesting a color palette, include the contrast ratio for each pairing
        - Always pair color-based status indicators with icons and text labels
        
        ---
        
        ## 3. Platform-Blind Critique
        
        **What happens**: LLM applies desktop web conventions to mobile designs, or iOS patterns to Android, or web patterns to native apps.
        
        **Detection signals**:
        - Suggesting hover states for mobile interfaces
        - Recommending hamburger menu for an iOS app (use tab bar instead)
        - Applying web-style dropdowns to native mobile (use bottom sheets)
        - Suggesting right-click context menus for touch interfaces
        - Recommending text sizes below 16px for mobile body text
        
        **Defense**:
        - Identify the target platform before critiquing
        - Apply platform-specific conventions:
        
        | Platform | Key Conventions |
        |----------|----------------|
        | iOS | Tab bar (bottom), navigation bar (top), system sheets, swipe gestures, SF Symbols, 44pt touch targets |
        | Android | Bottom navigation, FAB, Material Design components, back button, 48dp touch targets |
        | Web Desktop | Sidebar nav, hover states, right-click menus, keyboard shortcuts, cursor states |
        | Web Mobile | Bottom navigation, thumb-zone awareness, touch targets 44px+, no hover dependency |
        
        - When platform is ambiguous, ask. "Is this for web or native mobile?"
        
        ---
        
        ## 4. Missing Edge Cases in Specs
        
        **What happens**: LLM generates clean specs for the happy path — a well-formed form, a list with 5-10 items, a short username. Real products encounter extremes.
        
        **Detection signals**:
        - No empty state documented
        - No loading state specified
        - No error state designed
        - No mention of content overflow
        - No slow/offline connection handling
        - "The user enters their name" but no spec for a 200-character name
        
        **Defense**:
        Use this checklist for every spec:
        
        | Category | Edge Cases to Cover |
        |----------|-------------------|
        | Content length | Empty (0), one, minimum, typical, maximum, overflow |
        | Data states | Loading (initial), loading (more), loading (action), success, error (inline), error (page), offline, stale |
        | User input | Valid, invalid format, empty required, max length, special characters, paste, autofill |
        | List/collection | 0 items, 1 item, typical count, maximum count, filtered to 0 |
        | Permissions | Admin, editor, viewer, no access, expired access |
        | Timing | Instant response, slow response (2-5s), timeout (30s+), rate-limited |
        | i18n | Text 40% longer, RTL layout, locale-specific formatting |
        
        ---
        
        ## 5. Surface-Level Critique
        
        **What happens**: LLM provides vague feedback that sounds positive but gives no actionable guidance. "The layout is clean." "Good use of whitespace." "The hierarchy is clear."
        
        **Detection signals**:
        - Feedback uses subjective adjectives without specifics: "clean", "modern", "intuitive"
        - No specific elements named
        - No before/after comparison
        - No design principle cited as basis for feedback
        - Critique could apply to any design
        
        **Defense**:
        - Every finding names: the specific element, the specific issue, the specific impact, and a specific recommendation
        - Use the format: "[Element] has [issue] which causes [impact]. Improve by [recommendation]."
        - "Clean" is not feedback. "The 32px section spacing creates consistent vertical rhythm between content blocks" is feedback.
        - Always include what works well and explain WHY it works (which principle it applies correctly)
        
        ---
        
        ## 6. Fabricated Research Findings
        
        **What happens**: LLM invents plausible user quotes, statistics, persona details, or research findings that sound authoritative but have no source.
        
        **Detection signals**:
        - Specific statistics without source: "73% of users prefer..."
        - User quotes that sound polished rather than natural
        - Persona details too detailed to derive from the input data
        - Confident claims about user behavior with no evidence trail
        - "Research shows..." without citing which research
        
        **Defense**:
        - Every finding traces to specific user-provided data. If no data was provided, state the limitation.
        - Use participant type attribution: "3 of 5 enterprise admins mentioned..." — not invented names
        - Flag confidence levels: HIGH (multiple sources, behavioral data), MEDIUM (single source, self-reported), LOW (inferred, limited data)
        - Mark assumptions explicitly: "Assumption: users complete this flow in a single session. Validate with session data."
        - When asked about user behavior with no data, say so. "I don't have behavioral data on this. Here's what the design implies about expected behavior."
        
        ---
        
        ## 7. Token-Value Confusion
        
        **What happens**: LLM specs use raw values (`#3B82F6`, `16px`, `0.5rem`) instead of design token references (`color-primary`, `spacing-4`, `space-2`).
        
        **Detection signals**:
        - Hex codes in the spec where token names should be
        - Pixel values without corresponding token names
        - Font specifications using raw values instead of type scale tokens
        - Inconsistent values that should reference the same token
        
        **Defense**:
        - Ask for the design system's token naming convention before writing specs
        - Every value in a spec should be a token reference: `color-primary` not `#3B82F6`
        - When the token system is unknown, use conventional names and flag them: "Assuming token naming convention. Verify against your design system."
        - Flag any raw value with: "Needs token mapping — verify [value] maps to [suggested token name]"
        - If a value does not fit any existing token, call it out: "No existing token matches this value. Consider creating `spacing-section-header: 28px` or adjusting to nearest token `spacing-8: 32px`."
        
        ---
        
        ## 8. Framework Dumping
        
        **What happens**: LLM lists all 10 Nielsen heuristics, all WCAG criteria, or all design principles as a checklist without applying them to the specific design.
        
        **Detection signals**:
        - All 10 heuristics listed regardless of relevance
        - WCAG criteria listed without checking specific elements
        - Design principles stated abstractly: "Contrast is important for hierarchy"
        - No specific design elements referenced in the analysis
        - Equal depth on all points instead of focusing on actual issues
        
        **Defense**:
        - Apply each criterion to the actual design. Skip criteria that do not apply.
        - Focus depth on actual findings. A design with great keyboard support and poor contrast deserves a paragraph on contrast and a sentence on keyboard.
        - Cite the specific element that passes or fails each criterion
        - "WCAG 1.4.3 Contrast: The body text (#6B7280 on #FFFFFF) passes at 4.63:1." — not "Contrast should be at least 4.5:1 for normal text."
        
        ---
        
        ## 9. Ignoring Design System Constraints
        
        **What happens**: LLM suggests components or patterns that conflict with the product's existing design system, introducing visual inconsistency.
        
        **Detection signals**:
        - Recommending a component variant that does not exist in the system
        - Suggesting custom styling instead of using existing component props
        - Proposing a new pattern when an existing one would work
        - Mixing conventions from different design systems (Material + Apple HIG)
        
        **Defense**:
        - Ask what design system the product uses before making recommendations
        - Recommend existing components and variants first. Propose new ones only when existing components cannot solve the problem.
        - When suggesting a new pattern, show its relationship to existing patterns: "This extends the existing Card component with a new `featured` variant"
        - Flag when a recommendation might conflict with existing patterns: "Check if your design system already has a notification component before building this custom one"
        
        ---
        
        ## 10. Treating Mobile as Smaller Desktop
        
        **What happens**: LLM applies desktop interaction patterns to mobile, resulting in designs that ignore how mobile users actually hold and interact with their devices.
        
        **Detection signals**:
        - Small touch targets (below 44px)
        - Hover-dependent interactions
        - Dense information layouts without mobile-specific reorganization
        - No consideration of thumb zone (reachability)
        - Desktop-style dropdowns instead of mobile-native bottom sheets
        
        **Defense**:
        - Thumb zone awareness: primary actions in the bottom half of the screen (easy reach), secondary in the top half
        - Touch targets: minimum 44x44 CSS pixels, with 8px spacing between targets
        - Interaction patterns: bottom sheets over dropdowns, swipe gestures for common actions, pull-to-refresh
        - Content density: reduce visible items, use progressive disclosure, larger type sizes (minimum 16px body)
        - Text input: minimize typing, use pickers/selectors where possible, auto-capitalize, appropriate keyboard type (`type="email"`, `type="tel"`)
        
      • ux-copy.md 9.5 KB
        # UX Copy Reference
        
        Domain knowledge for writing interface copy by component type. Every pattern here changes model behavior — generic writing advice lives elsewhere.
        
        ---
        
        ## Core Principles
        
        1. **Clear** — Say exactly what you mean. Eliminate ambiguity before brevity.
        2. **Concise** — Use the fewest words that convey full meaning. Cut, then check if meaning survived.
        3. **Consistent** — Same terms for the same things everywhere. Build a terminology table per product.
        4. **Useful** — Every word helps the user accomplish their goal. Decorative text earns its pixels or gets cut.
        5. **Human** — Write like a helpful person. Match emotional register to user state.
        
        ---
        
        ## Copy Patterns by Component Type
        
        ### Buttons and CTAs
        
        | Pattern | Example | Why It Works |
        |---------|---------|-------------|
        | Start with a verb | "Save changes", "Download report" | Action-oriented, sets expectation |
        | Match label to outcome | "Create account" over "Submit" | User knows what happens next |
        | Specific over generic | "Add to cart" over "Continue" | Removes ambiguity |
        | Primary = one clear CTA per screen | "Start free trial" (primary), "Learn more" (secondary) | Hierarchy guides attention |
        
        **Character guidance**: Button text 1-4 words. Mobile: 1-3 words. If a button needs explanation, add helper text below.
        
        **Pairs that work**:
        
        | Action | Confirmation | Cancel |
        |--------|-------------|--------|
        | Delete | "Delete project" | "Keep project" |
        | Discard | "Discard changes" | "Keep editing" |
        | Send | "Send message" | "Back to draft" |
        | Remove | "Remove member" | "Cancel" |
        | Publish | "Publish now" | "Save as draft" |
        
        Use the specific action for both confirm and cancel. "OK / Cancel" forces the user to re-read the dialog.
        
        ### Error Messages
        
        **Structure**: What happened + Why it happened + How to fix it.
        
        | Context | Example | Failure Mode |
        |---------|---------|-------------|
        | Form validation | "Email address needs an @ symbol" | "Invalid email" |
        | Payment failure | "Payment declined. Your bank declined this charge. Try a different card or contact your bank." | "Transaction failed" |
        | Permission | "You need editor access to change this. Ask the project owner to update your role." | "Permission denied" |
        | Rate limit | "Too many requests. Wait 30 seconds, then try again." | "Error 429" |
        | Network | "Can't reach the server. Check your connection and try again." | "Network error" |
        | File upload | "This file is 25MB. Upload files under 10MB, or compress this one first." | "File too large" |
        
        **Tone calibration**: Error messages meet users at their most frustrated. Be helpful and direct. Skip apologies ("Oops!") unless the error is genuinely the product's fault. When it is, acknowledge briefly: "Something went wrong on our end. We're looking into it."
        
        **Technical errors**: Translate codes for users. Show the technical detail in a collapsible section for power users or support context. "Something went wrong (Error 500)" with expandable details.
        
        ### Empty States
        
        **Structure**: What this area is + Why it's empty + Clear next action.
        
        | Context | Example |
        |---------|---------|
        | First use | "No projects yet. Create your first project to start collaborating with your team." [Create project] |
        | Search no results | "No results for 'widget'. Try different keywords or check your filters." |
        | Filtered empty | "No items match these filters. Clear filters to see all items." [Clear filters] |
        | Deleted content | "This project was deleted. You can restore it from the trash within 30 days." [Go to trash] |
        | Permissions | "You don't have access to any projects yet. Ask your team admin to add you." |
        
        **Key rule**: Empty states are onboarding opportunities. Show the user what's possible, not just what's missing. Use illustration or example content when appropriate.
        
        ### Confirmation Dialogs
        
        | Element | Pattern | Example |
        |---------|---------|---------|
        | Title | State the action clearly | "Delete 3 files?" |
        | Body | Describe consequences | "These files will be permanently removed. This action can't be undone." |
        | Confirm button | Label with the action | "Delete files" |
        | Cancel button | Label with the alternative | "Keep files" |
        
        **Destructive actions**: Use the destructive action as the verb ("Delete", "Remove", "Revoke"). Add a consequence statement. Require explicit confirmation for irreversible actions (type the name, check a box).
        
        **Non-destructive confirmations**: For reversible actions, keep it lightweight. "Move to archive? You can restore anytime." [Archive] [Cancel]
        
        ### Tooltips
        
        | Guideline | Example |
        |-----------|---------|
        | Concise, specific help | "Keyboard shortcut: Cmd+K" |
        | Explain hidden functionality | "Drag to reorder items" |
        | Provide context for icons | "Share with team" |
        | Add detail to truncated content | Full text on hover |
        
        **Character limit**: 150 characters max. If you need more, use an inline help section or link to docs. Tooltips that require reading are tooltips that fail.
        
        Tooltips explain what isn't obvious. If the UI element is self-explanatory, skip the tooltip.
        
        ### Notifications
        
        | Type | Tone | Duration | Example |
        |------|------|----------|---------|
        | Success | Confirmatory, brief | 3-5 seconds, auto-dismiss | "Changes saved" |
        | Info | Neutral, helpful | 5-8 seconds or persistent | "New version available. Refresh to update." |
        | Warning | Clear, actionable | Persistent until addressed | "Your trial ends in 3 days. Upgrade to keep your data." |
        | Error | Empathetic, directive | Persistent until resolved | "Upload failed. Check your connection and try again." |
        
        **Notification copy rules**:
        - Lead with the outcome, not the process. "File uploaded" over "Upload complete."
        - Include an action when relevant. "Comment added. [View comment]"
        - Respect attention. Only notify for things the user needs to know.
        
        ### Onboarding
        
        | Step | Pattern | Example |
        |------|---------|---------|
        | Welcome | Set expectations, not features | "Set up your workspace in 3 steps" |
        | Progress | Show where they are | "Step 2 of 3: Invite your team" |
        | Value moment | Connect setup to benefit | "Add your first project to start tracking progress" |
        | Completion | Celebrate, direct to core flow | "You're all set. Here's your dashboard." |
        
        **Progressive disclosure**: Introduce one concept at a time. Let the user succeed at something simple before introducing complexity. Setup wizards with 3-5 steps outperform feature tours.
        
        ---
        
        ## Voice and Tone Framework
        
        Voice is constant (who you are). Tone adapts to context (how you say it).
        
        ### Tone by User State
        
        | User State | Emotional Register | Tone Approach | Example |
        |-----------|-------------------|---------------|---------|
        | Accomplishing a goal | Focused, confident | Concise, get out of the way | "Saved" |
        | Learning | Curious, uncertain | Supportive, educational | "Drag items to reorder. Changes save automatically." |
        | Encountering an error | Frustrated, anxious | Empathetic, direct, solution-first | "Payment failed. Try a different card." |
        | Making a decision | Thoughtful, weighing options | Informative, neutral | "Free plan: 3 projects. Pro plan: unlimited projects." |
        | Waiting | Impatient, uncertain | Reassuring, set expectations | "Processing your export. This usually takes 1-2 minutes." |
        | Succeeding | Satisfied, relieved | Celebratory but proportional | "Project published!" (not "Woohoo! Amazing job!") |
        
        ### Content Hierarchy
        
        1. **Title/heading**: What is this? (noun or verb phrase)
        2. **Primary copy**: What should the user do? (action-oriented)
        3. **Supporting copy**: Why? Additional context. (explanatory)
        4. **Helper text**: How? Technical details. (instructional)
        
        Scan the hierarchy: users read headings and bold text first, body text second, helper text only when stuck.
        
        ---
        
        ## Character Limits by Context
        
        | Element | Recommended Max | Hard Max | Notes |
        |---------|----------------|----------|-------|
        | Button text | 20 chars | 30 chars | 1-4 words, verb-first |
        | Page title | 40 chars | 60 chars | Front-load key words |
        | Tooltip | 80 chars | 150 chars | One concept only |
        | Error message | 120 chars | 200 chars | What + why + fix |
        | Notification | 80 chars | 120 chars | Outcome + optional action |
        | Empty state heading | 40 chars | 60 chars | What's missing |
        | Empty state body | 100 chars | 150 chars | Why empty + next step |
        | Modal title | 40 chars | 50 chars | Action or question |
        | Input label | 25 chars | 40 chars | Noun phrase |
        | Placeholder text | 30 chars | 50 chars | Example or format hint |
        
        ---
        
        ## Localization Considerations
        
        | Factor | Guidance |
        |--------|----------|
        | Text expansion | Translated text runs 30-40% longer than English. Design for expansion. |
        | String concatenation | Use complete sentences with placeholders, not concatenated fragments. "You have {count} items" not "You have " + count + " items" |
        | Pluralization | Different languages have different plural rules (not just singular/plural). Use ICU MessageFormat or equivalent. |
        | Date/time | Use locale-aware formatting. "May 5, 2026" vs "5 May 2026" vs "2026/5/5" |
        | Cultural idioms | "Heads up" and "FYI" translate poorly. Use direct language: "Note:" or "Important:" |
        | Right-to-left | UI layout mirrors for RTL languages. Icon placement, text alignment, and progress direction all reverse. |
        | Color meaning | Red = danger in Western cultures, prosperity in Chinese culture. Rely on icons and text alongside color. |
        | Gendered language | Some languages require gendered forms. Design copy to minimize gender-specific constructions where possible. |
        
        **Localization-safe pattern**: Write copy that is literal, complete, and free of idioms. "Save your work before closing" translates cleanly. "Don't lose your stuff!" does not.
        
    • distinctive-frontend-design-refs
      • animation-patterns.md 10.5 KB
        # Animation Patterns for High-Impact Moments
        
        ## Philosophy
        
        One well-orchestrated sequence > scattered micro-interactions
        
        Focus animation effort on moments that matter:
        - Initial page load (first impression)
        - Major state transitions (empty → filled, loading → success)
        - Feature showcases (pricing reveal, testimonial carousel)
        - User achievements (form submission, milestone reached)
        
        Reserve motion for moments that carry meaning. Not every hover state needs motion.
        
        ---
        
        ## Pattern 1: Orchestrated Page Load
        
        ### Hero Section Staggered Reveal
        
        **Use case**: Landing pages, portfolio homepages, product launches
        
        **Strategy**: Stagger element entrances with consistent timing intervals
        
        ```css
        /* CSS Implementation */
        .hero-title {
          animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1);
          animation-delay: 0.2s;
          animation-fill-mode: both;
        }
        
        .hero-subtitle {
          animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1);
          animation-delay: 0.4s;
          animation-fill-mode: both;
        }
        
        .hero-cta {
          animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1);
          animation-delay: 0.6s;
          animation-fill-mode: both;
        }
        
        .hero-visual {
          animation: fade-in 1s cubic-bezier(0.22, 1, 0.36, 1);
          animation-delay: 0.8s;
          animation-fill-mode: both;
        }
        
        @keyframes slide-up {
          from {
            opacity: 0;
            transform: translateY(30px);
          }
          to {
            opacity: 1;
            transform: translateY(0);
          }
        }
        
        @keyframes fade-in {
          from {
            opacity: 0;
          }
          to {
            opacity: 1;
          }
        }
        ```
        
        **React with Framer Motion**:
        
        ```jsx
        import { motion } from "framer-motion";
        
        const containerVariants = {
          hidden: { opacity: 0 },
          visible: {
            opacity: 1,
            transition: {
              staggerChildren: 0.2,
              delayChildren: 0.2
            }
          }
        };
        
        const itemVariants = {
          hidden: { opacity: 0, y: 30 },
          visible: {
            opacity: 1,
            y: 0,
            transition: {
              type: "spring",
              damping: 25,
              stiffness: 120
            }
          }
        };
        
        export function Hero() {
          return (
            <motion.section
              variants={containerVariants}
              initial="hidden"
              animate="visible"
              className="hero"
            >
              <motion.h1 variants={itemVariants}>Your Title</motion.h1>
              <motion.p variants={itemVariants}>Your subtitle</motion.p>
              <motion.button variants={itemVariants}>CTA</motion.button>
              <motion.div variants={itemVariants}>Visual element</motion.div>
            </motion.section>
          );
        }
        ```
        
        **Timing**:
        - Total sequence: 1.4s (0.2s start delay + 0.6s stagger + 0.8s animation)
        - Stagger interval: 200ms between elements
        - Animation duration: 800ms per element
        
        ---
        
        ## Pattern 2: State Transition Choreography
        
        ### Empty State → Filled Content
        
        **Use case**: Dashboards, data tables, search results
        
        **Strategy**: Smooth transition from empty state to populated content
        
        ```jsx
        import { motion, AnimatePresence } from "framer-motion";
        
        const listVariants = {
          hidden: { opacity: 0 },
          visible: {
            opacity: 1,
            transition: {
              staggerChildren: 0.1
            }
          }
        };
        
        const itemVariants = {
          hidden: { opacity: 0, x: -20 },
          visible: {
            opacity: 1,
            x: 0,
            transition: {
              type: "spring",
              stiffness: 300,
              damping: 24
            }
          },
          exit: {
            opacity: 0,
            x: 20,
            transition: { duration: 0.2 }
          }
        };
        
        export function DataList({ items, isEmpty }) {
          if (isEmpty) {
            return (
              <motion.div
                initial={{ opacity: 0, scale: 0.95 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.95 }}
                className="empty-state"
              >
                <p>No items yet. Add your first item!</p>
              </motion.div>
            );
          }
        
          return (
            <motion.ul
              variants={listVariants}
              initial="hidden"
              animate="visible"
              className="data-list"
            >
              <AnimatePresence>
                {items.map((item) => (
                  <motion.li
                    key={item.id}
                    variants={itemVariants}
                    layout
                  >
                    {item.content}
                  </motion.li>
                ))}
              </AnimatePresence>
            </motion.ul>
          );
        }
        ```
        
        ---
        
        ## Pattern 3: Loading → Success Celebration
        
        **Use case**: Form submissions, file uploads, payment processing
        
        **Strategy**: Build anticipation with loading, release with success animation
        
        ```jsx
        import { motion } from "framer-motion";
        
        const loadingVariants = {
          initial: { rotate: 0 },
          animate: {
            rotate: 360,
            transition: {
              duration: 1,
              repeat: Infinity,
              ease: "linear"
            }
          }
        };
        
        const successVariants = {
          hidden: { scale: 0, opacity: 0 },
          visible: {
            scale: 1,
            opacity: 1,
            transition: {
              type: "spring",
              stiffness: 200,
              damping: 15
            }
          }
        };
        
        export function SubmissionState({ status }) {
          if (status === "loading") {
            return (
              <motion.div
                variants={loadingVariants}
                initial="initial"
                animate="animate"
                className="loading-spinner"
              >
                ⟳
              </motion.div>
            );
          }
        
          if (status === "success") {
            return (
              <motion.div
                variants={successVariants}
                initial="hidden"
                animate="visible"
                className="success-checkmark"
              >
                ✓
              </motion.div>
            );
          }
        
          return null;
        }
        ```
        
        **CSS-only alternative**:
        
        ```css
        /* Loading state */
        .loading-spinner {
          animation: spin 1s linear infinite;
        }
        
        @keyframes spin {
          from { transform: rotate(0deg); }
          to { transform: rotate(360deg); }
        }
        
        /* Success state */
        .success-checkmark {
          animation: success-pop 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
        }
        
        @keyframes success-pop {
          0% {
            transform: scale(0);
            opacity: 0;
          }
          50% {
            transform: scale(1.2);
          }
          100% {
            transform: scale(1);
            opacity: 1;
          }
        }
        ```
        
        ---
        
        ## Pattern 4: Scroll-Triggered Reveals
        
        **Use case**: Long-form content, storytelling pages, feature showcases
        
        **Strategy**: Elements animate into view as user scrolls
        
        ```jsx
        import { motion } from "framer-motion";
        import { useInView } from "framer-motion";
        import { useRef } from "react";
        
        export function ScrollReveal({ children }) {
          const ref = useRef(null);
          const isInView = useInView(ref, { once: true, margin: "-100px" });
        
          return (
            <motion.div
              ref={ref}
              initial={{ opacity: 0, y: 50 }}
              animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 50 }}
              transition={{
                duration: 0.8,
                ease: [0.22, 1, 0.36, 1]
              }}
            >
              {children}
            </motion.div>
          );
        }
        ```
        
        **CSS-only with Intersection Observer**:
        
        ```javascript
        // JavaScript to add class when in view
        const observer = new IntersectionObserver(
          (entries) => {
            entries.forEach((entry) => {
              if (entry.isIntersecting) {
                entry.target.classList.add("revealed");
              }
            });
          },
          { threshold: 0.1, rootMargin: "-100px" }
        );
        
        document.querySelectorAll(".reveal-on-scroll").forEach((el) => {
          observer.observe(el);
        });
        ```
        
        ```css
        .reveal-on-scroll {
          opacity: 0;
          transform: translateY(50px);
          transition: opacity 0.8s cubic-bezier(0.22, 1, 0.36, 1),
                      transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
        }
        
        .reveal-on-scroll.revealed {
          opacity: 1;
          transform: translateY(0);
        }
        ```
        
        ---
        
        ## Pattern 5: Interactive Hover Effects
        
        **Use case**: Buttons, cards, navigation items (use sparingly!)
        
        **Strategy**: Subtle, purposeful motion that enhances without distracting
        
        ```css
        /* Lift effect for cards */
        .card {
          transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
                      box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        }
        
        .card:hover {
          transform: translateY(-4px);
          box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
        }
        
        /* Scale effect for buttons */
        .button {
          transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
        }
        
        .button:hover {
          transform: scale(1.05);
        }
        
        .button:active {
          transform: scale(0.98);
        }
        
        /* Underline animation for links */
        .nav-link {
          position: relative;
        }
        
        .nav-link::after {
          content: '';
          position: absolute;
          bottom: -2px;
          left: 0;
          width: 0;
          height: 2px;
          background: var(--accent);
          transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
        }
        
        .nav-link:hover::after {
          width: 100%;
        }
        ```
        
        ---
        
        ## Easing Curves Reference
        
        Choose easing based on animation purpose:
        
        ```css
        /* Smooth deceleration (entrances) */
        --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
        
        /* Smooth acceleration (exits) */
        --ease-in: cubic-bezier(0.4, 0, 1, 1);
        
        /* Standard easing (general purpose) */
        --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
        
        /* Elastic bounce (playful) */
        --ease-elastic: cubic-bezier(0.68, -0.55, 0.265, 1.55);
        
        /* Sharp snap (instant feedback) */
        --ease-sharp: cubic-bezier(0.4, 0, 0.6, 1);
        ```
        
        **When to use**:
        - **Ease-out**: Element entering screen (slide-in, fade-in)
        - **Ease-in**: Element exiting screen (slide-out, fade-out)
        - **Ease-in-out**: Element moving within screen (position change)
        - **Ease-elastic**: Success states, playful interactions
        - **Ease-sharp**: Quick feedback (button press, toggle)
        
        ---
        
        ## Duration Guidelines
        
        ```css
        /* Micro-interactions (hover, focus) */
        --duration-fast: 150ms;
        
        /* Standard transitions (most UI changes) */
        --duration-normal: 300ms;
        
        /* Slower transitions (page sections, major changes) */
        --duration-slow: 500ms;
        
        /* Orchestrated sequences (staggered reveals) */
        --duration-sequence: 800ms;
        ```
        
        **Rules of thumb**:
        - Faster for frequent interactions (hover: 150-250ms)
        - Medium for important feedback (button click: 300ms)
        - Slower for high-impact moments (hero load: 500-800ms)
        - Never exceed 1000ms for UI animations
        
        ---
        
        ## Performance Considerations
        
        **Prefer animating these properties** (GPU-accelerated):
        - `transform` (translate, scale, rotate)
        - `opacity`
        - `filter` (use sparingly)
        
        **Prefer animating**:
        - `width`, `height` (causes reflow)
        - `top`, `left`, `right`, `bottom` (use `transform` instead)
        - `margin`, `padding` (causes reflow)
        
        **Best practices**:
        ```css
        /* Good: GPU-accelerated */
        .element {
          transform: translateY(20px);
          opacity: 0;
        }
        
        /* Bad: Causes layout thrashing */
        .element {
          top: 20px;
          display: block;
        }
        ```
        
        **Reduce motion for accessibility**:
        ```css
        @media (prefers-reduced-motion: reduce) {
          * {
            animation-duration: 0.01ms !important;
            animation-iteration-count: 1 !important;
            transition-duration: 0.01ms !important;
          }
        }
        ```
        
        ---
        
        ## Animation Patterns to Detect and Fix
        
        **Signals to avoid**:
        - Animate every element on every page
        - Use slow animations (>1000ms) for UI
        - Ignore `prefers-reduced-motion`
        - Animate layout properties (width, height, top, left)
        - Add hover animations to touch devices
        - Use animations that block user interaction
        
        **Preferred action**:
        - Focus on 1-2 high-impact moments per page
        - Keep UI animations quick (150-500ms)
        - Respect accessibility preferences
        - Use `transform` and `opacity` only
        - Disable hover effects on touch with `@media (hover: hover)`
        - Allow users to interrupt/skip animations
        
      • background-techniques.md 10.2 KB
        # Background & Atmosphere Techniques
        
        ## Philosophy
        
        Backgrounds create mood and depth. Avoid flat solid colors.
        
        Every background should contribute to the overall aesthetic story:
        - **Layered gradients** create atmospheric depth
        - **Geometric patterns** add technical precision
        - **Textures** provide organic warmth
        - **Contextual effects** immerse in theme
        
        ---
        
        ## Technique 1: Layered Radial Gradients
        
        ### Atmospheric Glow
        
        **Use case**: Landing pages, hero sections, feature showcases
        
        **Effect**: Soft colored glows that create depth and focus attention
        
        ```css
        /* Example: Warm glow from top-right */
        .section {
          background:
            radial-gradient(
              ellipse 80% 50% at 80% 20%,
              rgba(255, 222, 0, 0.12),
              transparent 60%
            ),
            radial-gradient(
              ellipse 60% 40% at 20% 80%,
              rgba(58, 58, 58, 0.08),
              transparent 60%
            ),
            #E8E6E3;
          min-height: 100vh;
        }
        ```
        
        **Variations**:
        
        ```css
        /* Dual spotlights (opposite corners) */
        background:
          radial-gradient(circle at top left, rgba(255, 100, 100, 0.15), transparent 40%),
          radial-gradient(circle at bottom right, rgba(100, 100, 255, 0.15), transparent 40%),
          var(--surface);
        
        /* Central vignette effect */
        background:
          radial-gradient(ellipse at center, var(--surface), rgba(0, 0, 0, 0.3) 120%);
        
        /* Aurora effect (multiple overlapping glows) */
        background:
          radial-gradient(ellipse at 20% 30%, rgba(255, 100, 200, 0.1), transparent 50%),
          radial-gradient(ellipse at 80% 50%, rgba(100, 200, 255, 0.1), transparent 50%),
          radial-gradient(ellipse at 50% 80%, rgba(200, 255, 100, 0.1), transparent 50%),
          var(--surface);
        ```
        
        ---
        
        ## Technique 2: Geometric Patterns
        
        ### Grid Lines (Technical Precision)
        
        **Use case**: Developer tools, technical documentation, SaaS products
        
        **Effect**: Subtle grid that suggests precision and structure
        
        ```css
        /* Vertical line grid */
        .background-grid {
          background-image:
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 99px,
              rgba(58, 58, 58, 0.03) 99px,
              rgba(58, 58, 58, 0.03) 100px
            );
          background-color: var(--surface);
        }
        
        /* Graph paper grid */
        .background-graph {
          background-image:
            repeating-linear-gradient(
              0deg,
              transparent,
              transparent 49px,
              rgba(58, 58, 58, 0.02) 49px,
              rgba(58, 58, 58, 0.02) 50px
            ),
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 49px,
              rgba(58, 58, 58, 0.02) 49px,
              rgba(58, 58, 58, 0.02) 50px
            );
          background-color: var(--surface);
        }
        
        /* Diagonal stripes */
        .background-stripes {
          background-image:
            repeating-linear-gradient(
              45deg,
              transparent,
              transparent 35px,
              rgba(58, 58, 58, 0.03) 35px,
              rgba(58, 58, 58, 0.03) 70px
            );
          background-color: var(--surface);
        }
        ```
        
        ### Dots Pattern
        
        ```css
        .background-dots {
          background-image:
            radial-gradient(circle, rgba(58, 58, 58, 0.05) 1px, transparent 1px);
          background-size: 20px 20px;
          background-color: var(--surface);
        }
        
        /* Larger dots with spacing */
        .background-dots-large {
          background-image:
            radial-gradient(circle, rgba(58, 58, 58, 0.08) 2px, transparent 2px);
          background-size: 40px 40px;
          background-color: var(--surface);
        }
        ```
        
        ---
        
        ## Technique 3: Noise Textures
        
        ### Subtle Grain (Organic Feel)
        
        **Use case**: Portfolios, creative showcases, artisan brands
        
        **Effect**: Adds tactile quality, prevents sterile digital feel
        
        ```css
        /* Using pseudo-element for noise overlay */
        .section-with-noise {
          position: relative;
          background: var(--surface);
        }
        
        .section-with-noise::after {
          content: '';
          position: absolute;
          inset: 0;
          background-image: url('data:image/svg+xml,<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><filter id="noiseFilter"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/></filter><rect width="100%" height="100%" filter="url(%23noiseFilter)"/></svg>');
          opacity: 0.4;
          mix-blend-mode: multiply;
          pointer-events: none;
        }
        ```
        
        **Alternative: CSS-only noise effect**:
        
        ```css
        .noise-background {
          background-image:
            repeating-linear-gradient(90deg, transparent 0, rgba(0,0,0,.03) 1px, transparent 2px),
            repeating-linear-gradient(180deg, transparent 0, rgba(0,0,0,.03) 1px, transparent 2px);
          background-size: 1px 1px;
          background-color: var(--surface);
        }
        ```
        
        ---
        
        ## Technique 4: Contextual Effects
        
        ### Code Editor Theme (Dark Background)
        
        ```css
        .code-theme-background {
          background:
            /* Subtle scanline effect */
            repeating-linear-gradient(
              0deg,
              transparent,
              transparent 2px,
              rgba(255, 255, 255, 0.02) 2px,
              rgba(255, 255, 255, 0.02) 4px
            ),
            /* IDE-style grid */
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 79px,
              rgba(255, 255, 255, 0.01) 79px,
              rgba(255, 255, 255, 0.01) 80px
            ),
            /* Base color */
            #1E1E2E;
        }
        ```
        
        ### Portfolio Spotlight Effect
        
        ```css
        /* Spotlight follows cursor (requires JavaScript) */
        .portfolio-background {
          background: radial-gradient(
            circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
            rgba(255, 255, 255, 0.1) 0%,
            transparent 20%
          ),
          #1A1A1A;
        }
        ```
        
        ```javascript
        // JavaScript to track mouse position
        document.addEventListener('mousemove', (e) => {
          const x = (e.clientX / window.innerWidth) * 100;
          const y = (e.clientY / window.innerHeight) * 100;
          document.documentElement.style.setProperty('--mouse-x', `${x}%`);
          document.documentElement.style.setProperty('--mouse-y', `${y}%`);
        });
        ```
        
        ### Documentation Texture
        
        ```css
        /* Subtle paper texture for docs */
        .docs-background {
          background:
            /* Paper texture via noise */
            url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAIklEQVQIW2NkQAKrVq36zwjjgzhhYWGMYAEYB8RmROaABADeOQ8CXl/xfgAAAABJRU5ErkJggg==')
            repeat,
            /* Slight gradient for depth */
            linear-gradient(180deg, #FAFAF8 0%, #F5F5F2 100%);
        }
        ```
        
        ---
        
        ## Technique 5: Multi-Layer Composition
        
        ### Complex Atmospheric Depth
        
        Combine multiple techniques for rich backgrounds:
        
        ```css
        .rich-background {
          position: relative;
          background-color: var(--surface);
        }
        
        .rich-background::before {
          content: '';
          position: absolute;
          inset: 0;
          background:
            /* Radial glow accent */
            radial-gradient(
              ellipse 60% 40% at 70% 30%,
              rgba(255, 222, 0, 0.08),
              transparent 60%
            ),
            /* Geometric pattern */
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 99px,
              rgba(58, 58, 58, 0.02) 99px,
              rgba(58, 58, 58, 0.02) 100px
            );
          pointer-events: none;
        }
        
        .rich-background::after {
          content: '';
          position: absolute;
          inset: 0;
          /* Noise texture overlay */
          background-image: url('data:image/svg+xml,...');
          opacity: 0.3;
          mix-blend-mode: multiply;
          pointer-events: none;
        }
        ```
        
        ---
        
        ## Technique 6: Animated Backgrounds
        
        ### Subtle Movement (Use Sparingly!)
        
        ```css
        /* Slowly shifting gradient */
        @keyframes gradient-shift {
          0%, 100% {
            background-position: 0% 50%;
          }
          50% {
            background-position: 100% 50%;
          }
        }
        
        .animated-gradient {
          background:
            linear-gradient(
              270deg,
              rgba(255, 100, 100, 0.1),
              rgba(100, 100, 255, 0.1),
              rgba(100, 255, 100, 0.1)
            );
          background-size: 200% 200%;
          animation: gradient-shift 15s ease infinite;
        }
        ```
        
        **Warning**: Animated backgrounds can be distracting. Use only when:
        - Animation is very subtle (slow, low opacity)
        - It enhances the theme (e.g., flowing data for analytics app)
        - User can disable it via `prefers-reduced-motion`
        
        ```css
        @media (prefers-reduced-motion: reduce) {
          .animated-gradient {
            animation: none;
          }
        }
        ```
        
        ---
        
        ## Dark Mode Considerations
        
        When implementing dark backgrounds:
        
        ```css
        /* Light mode */
        :root {
          --bg-surface: #E8E6E3;
          --bg-glow: rgba(255, 222, 0, 0.08);
          --bg-pattern: rgba(58, 58, 58, 0.03);
        }
        
        /* Dark mode */
        @media (prefers-color-scheme: dark) {
          :root {
            --bg-surface: #1E1E2E;
            --bg-glow: rgba(255, 222, 0, 0.05);
            --bg-pattern: rgba(255, 255, 255, 0.02);
          }
        }
        
        body {
          background:
            radial-gradient(
              ellipse at top,
              var(--bg-glow),
              transparent 50%
            ),
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 99px,
              var(--bg-pattern) 99px,
              var(--bg-pattern) 100px
            ),
            var(--bg-surface);
        }
        ```
        
        ---
        
        ## Performance Tips
        
        **Optimize background rendering**:
        
        1. **Use CSS gradients over images** when possible (smaller file size, scalable)
        2. **Limit layer count** (3-4 layers max for good performance)
        3. **Avoid animating backgrounds** on scroll (causes jank)
        4. **Use `will-change` sparingly**:
           ```css
           .animated-bg {
             will-change: background-position;
           }
           ```
        5. **Compress SVG noise patterns** for smaller data URLs
        6. **Consider `background-attachment: fixed`** carefully (can cause scrolling performance issues)
        
        ---
        
        ## Aesthetic-Specific Recommendations
        
        ### Neo-Brutalist
        ```css
        background:
          repeating-linear-gradient(90deg, transparent, transparent 99px, rgba(58, 58, 58, 0.04) 99px, rgba(58, 58, 58, 0.04) 100px),
          #E8E6E3;
        ```
        
        ### Sophisticated Editorial
        ```css
        background:
          url('subtle-paper-texture.png'),
          linear-gradient(180deg, #FAFAF8 0%, #F5F5F2 100%);
        ```
        
        ### Technical/Developer
        ```css
        background:
          repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(255, 255, 255, 0.02) 19px, rgba(255, 255, 255, 0.02) 20px),
          repeating-linear-gradient(90deg, transparent, transparent 19px, rgba(255, 255, 255, 0.02) 19px, rgba(255, 255, 255, 0.02) 20px),
          #1E1E2E;
        ```
        
        ### Warm Artisan
        ```css
        background:
          radial-gradient(ellipse at 30% 40%, rgba(212, 165, 116, 0.1), transparent 50%),
          url('organic-texture.svg'),
          #F5EFE7;
        ```
        
        ### Retro Synthwave
        ```css
        background:
          radial-gradient(ellipse at top, rgba(189, 147, 249, 0.2), transparent 50%),
          radial-gradient(ellipse at bottom, rgba(255, 121, 198, 0.2), transparent 50%),
          #1A1B26;
        ```
        
        ---
        
        ## Testing Backgrounds
        
        **Checklist**:
        - [ ] Readable text contrast (WCAG AA minimum)
        - [ ] Not distracting from main content
        - [ ] Performs smoothly on low-end devices
        - [ ] Looks good at different viewport sizes
        - [ ] Respects `prefers-reduced-motion` if animated
        - [ ] Works in both light and dark modes (if applicable)
        - [ ] Enhances aesthetic without overwhelming
        
        **Tools**:
        - Chrome DevTools > Rendering > Paint flashing
        - WebPageTest for performance
        - Contrast checker for text readability
        
      • balatro-shader-breakdown.md 14.9 KB
        # Balatro Holographic Shader Breakdown
        
        Deep technical breakdown of the Balatro-style holographic foil card effect, adapted for wrestling deckbuilder card dimensions (400×560) and rarity-based color palettes. All GLSL targets `#version 300 es` (WebGL2).
        
        The core Balatro technique: animated UV distortion layered with multi-octave noise, fed into a rainbow hue rotation function, with a Fresnel-like edge brightening that responds to mouse position. The result looks like a physical holographic foil card catching light.
        
        ---
        
        ## Technique Overview
        
        Five layers compose the holographic effect:
        
        1. **UV distortion field** — Slow-moving noise shifts the base UV coordinates, creating the "swimming" quality of real holo foil
        2. **Rainbow hue rotation** — Distorted UV position maps to hue, creating the color-sweep bands
        3. **Shimmer band** — A fast diagonal highlight band (the "catch" of light)
        4. **Fresnel-like edge response** — Edges brighten as mouse moves to simulate viewing angle
        5. **Mouse tilt influence** — Mouse position offsets the hue slightly, simulating physical card tilt
        
        Each layer is blended together with the card's final color output using `mix-blend-mode: screen` at the HTML level — the shader outputs over a transparent background, so dark regions disappear and bright holographic regions add to the card.
        
        ---
        
        ## Complete Legendary Holographic Shader
        
        This is the full copy-pasteable shader for the `legendary` tier. Import the noise functions from `card-shader-patterns.md` or inline them.
        
        ```glsl
        #version 300 es
        precision highp float;
        
        in vec2 v_uv;
        out vec4 fragColor;
        
        // --- Uniforms ---
        uniform float u_time;        // seconds, wraps at 1000.0
        uniform float u_rarity;      // 1.0 for legendary
        uniform float u_hover;       // 0.0–1.0 lerped
        uniform vec2  u_mouse;       // card-space [0,1], (0.5,0.5) at rest
        uniform vec2  u_resolution;  // canvas pixel size
        uniform float u_upgraded;    // 0.0 or 1.0
        
        // --- Noise functions (paste from card-shader-patterns.md or include via import) ---
        
        // Hash for value noise
        float hash(vec2 p) {
          p = fract(p * vec2(127.1, 311.7));
          p += dot(p, p + 19.19);
          return fract(p.x * p.y);
        }
        
        // 2D value noise
        float valueNoise(vec2 p) {
          vec2 i = floor(p);
          vec2 f = fract(p);
          vec2 u = f * f * (3.0 - 2.0 * f);
          return mix(
            mix(hash(i),            hash(i + vec2(1.0, 0.0)), u.x),
            mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
            u.y
          );
        }
        
        // Simplex permutation helper
        vec3 permute(vec3 x) {
          return mod(((x * 34.0) + 1.0) * x, 289.0);
        }
        
        // 2D simplex noise [-1,1]
        float snoise(vec2 v) {
          const vec4 C = vec4(0.211324865405187, 0.366025403784439,
                             -0.577350269189626, 0.024390243902439);
          vec2 i  = floor(v + dot(v, C.yy));
          vec2 x0 = v - i + dot(i, C.xx);
          vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
          vec4 x12 = x0.xyxy + C.xxzz;
          x12.xy -= i1;
          i = mod(i, 289.0);
          vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
                         + i.x + vec3(0.0, i1.x, 1.0));
          vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
          m = m * m * m * m;
          vec3 x = 2.0 * fract(p * C.www) - 1.0;
          vec3 h = abs(x) - 0.5;
          vec3 a0 = x - floor(x + 0.5);
          m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
          vec3 g;
          g.x  = a0.x  * x0.x   + h.x  * x0.y;
          g.yz = a0.yz * x12.xz + h.yz * x12.yw;
          return 130.0 * dot(m, g);
        }
        
        // fBm: 5 octaves for organic foil quality
        float fbm5(vec2 p) {
          float v = 0.0, a = 0.5, f = 1.0;
          for (int i = 0; i < 5; i++) {
            v += a * snoise(p * f);
            f *= 2.0; a *= 0.5;
          }
          return v;
        }
        
        // --- HSV to RGB ---
        vec3 hsv2rgb(float h, float s, float v) {
          vec3 rgb = clamp(abs(fract(h + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0) - 1.0, 0.0, 1.0);
          return v * mix(vec3(1.0), rgb, s);
        }
        
        // --- Main ---
        void main() {
          vec2 uv = v_uv;
        
          // Aspect-correct UV for 400x560 card
          // Geometric operations (circles, isotropic noise) need this
          vec2 aspectUV = vec2(uv.x, uv.y * (400.0 / 560.0));
        
          // -------------------------------------------------------
          // Layer 1: UV Distortion Field
          // Slow-moving noise that shifts UV coords, creating the
          // "swimming" quality of real holographic foil
          // -------------------------------------------------------
          float distortSpeed = 0.12;  // Very slow — physical foil doesn't swim fast
          vec2 distortSeed = aspectUV * 2.5 + vec2(u_time * distortSpeed, u_time * distortSpeed * 0.7);
          float distortX = fbm5(distortSeed) * 0.18;
          float distortY = fbm5(distortSeed + vec2(3.7, 1.3)) * 0.18;
          vec2 distortedUV = uv + vec2(distortX, distortY);
        
          // -------------------------------------------------------
          // Layer 2: Mouse tilt influence
          // Mouse offset shifts the hue — simulates physical card tilt
          // At rest: mouse = (0.5, 0.5), no shift
          // Max tilt: ±0.15 hue units at card edge
          // -------------------------------------------------------
          vec2 mouseOffset = (u_mouse - 0.5) * 0.15;  // Center mouse at 0 offset
          // More influence when hovering (u_hover lerped in)
          vec2 tiltInfluence = mouseOffset * u_hover;
        
          // -------------------------------------------------------
          // Layer 3: Rainbow hue from distorted UV + tilt
          // Diagonal axis across card maps to full rainbow cycle
          // -------------------------------------------------------
          float diagAxis = (distortedUV.x + tiltInfluence.x) * 0.55
                         + (distortedUV.y + tiltInfluence.y) * 0.45;
        
          // Slow hue drift over time + mouse-driven shift
          float hueShift = u_time * 0.08;
          float hue = fract(diagAxis * 1.2 + hueShift);
        
          // Saturation: high but not 1.0 — fully saturated looks flat
          float sat = 0.75 + u_hover * 0.15;
        
          // Value: bright overall, enhanced on hover
          float val = 0.6 + u_hover * 0.25;
        
          vec3 rainbowColor = hsv2rgb(hue, sat, val);
        
          // -------------------------------------------------------
          // Layer 4: Fast shimmer band
          // Sharp diagonal highlight that sweeps quickly on hover
          // At rest: slow drift; on hover: 3x faster
          // -------------------------------------------------------
          float bandDiag = uv.x * 0.7 + uv.y * 0.3;
          float bandSpeed = 0.3 + u_hover * 0.9;
          float bandPhase = fract(bandDiag - u_time * bandSpeed);
          float band = smoothstep(0.0, 0.04, bandPhase) * smoothstep(0.14, 0.07, bandPhase);
        
          // Band is white-ish (adds brightness to the rainbow underneath)
          vec3 bandColor = vec3(0.9, 0.95, 1.0);
          float bandOpacity = band * (0.5 + u_hover * 0.4);
        
          // -------------------------------------------------------
          // Layer 5: Fresnel-like edge brightening
          // Edges respond to viewing angle (approximated by mouse position)
          // Without real geometry normals, we approximate with edge distance + mouse offset
          // -------------------------------------------------------
          vec2 edge = min(uv, 1.0 - uv);
          float edgeDist = min(edge.x, edge.y);
          float edgeGlow = 1.0 - smoothstep(0.0, 0.12, edgeDist);
        
          // Mouse position influences which edges brighten
          // When mouse is in top-left corner, top/left edges glow more
          float mouseAngle = atan(u_mouse.y - 0.5, u_mouse.x - 0.5);
          float edgeAngle = atan(uv.y - 0.5, uv.x - 0.5);
          float angleDiff = abs(cos(edgeAngle - mouseAngle));
          float fresnelResponse = edgeGlow * (0.3 + angleDiff * 0.5 * u_hover);
        
          vec3 edgeColor = mix(rainbowColor, vec3(1.0, 0.95, 0.9), 0.3);  // Warm edge highlight
          // Gold tint for legendary — matches the game's #FFB800 accent
          vec3 legendaryEdge = mix(edgeColor, vec3(1.0, 0.72, 0.0), 0.35 * u_rarity);
        
          // -------------------------------------------------------
          // Upgraded modifier: slight green shift on hue, stronger edge pulse
          // -------------------------------------------------------
          float upgradedHueShift = u_upgraded * 0.08;
          vec3 finalRainbow = hsv2rgb(fract(hue + upgradedHueShift), sat, val);
        
          // -------------------------------------------------------
          // Composite all layers
          // -------------------------------------------------------
          // Start with rainbow
          vec3 color = finalRainbow;
        
          // Add shimmer band on top (additive blend within shader)
          color = mix(color, color + bandColor, bandOpacity * 0.7);
        
          // Add edge brightening
          color = mix(color, legendaryEdge, fresnelResponse);
        
          // -------------------------------------------------------
          // Opacity calculation
          // Base opacity at rest: 0.45 (visible but not obscuring card art)
          // Peak opacity on hover: 0.70
          // The black background disappears via mix-blend-mode:screen in HTML
          // -------------------------------------------------------
          float baseOpacity = 0.45;
          float hoverBoost = 0.25;
          float opacity = baseOpacity + u_hover * hoverBoost;
        
          // Vignette: reduce effect near card center (keeps card art readable)
          // Card text is in the lower 45% — protect that region
          vec2 centered = uv - 0.5;
          float radialFade = 1.0 - smoothstep(0.15, 0.45, length(centered * vec2(1.0, 0.7)));
        
          // Text zone protection: lower portion of card
          float textProtect = smoothstep(0.45, 0.55, uv.y);  // fade out above y=0.5
          float finalVignette = radialFade * (0.4 + textProtect * 0.6);
        
          fragColor = vec4(color, opacity * finalVignette);
        }
        ```
        
        ---
        
        ## Layer-by-Layer Explanation
        
        ### UV Distortion Field
        
        Real holographic foil has micro-embossed patterns that cause adjacent areas to reflect different hues. The distortion field approximates this with slow fBm (fractional Brownian motion) noise that shifts UV coordinates. The key is **slow movement** — `distortSpeed = 0.12` means the pattern shifts visibly over about 8 seconds.
        
        If you increase this to 0.5 or above, the card looks like it's underwater. Below 0.05, the animation is imperceptible at rest.
        
        ### Mouse Tilt Influence
        
        The `u_mouse` uniform holds normalized card-space coordinates. At rest (cursor not on card), it should be set to `(0.5, 0.5)` — the center — so there's zero tilt offset. As the mouse moves toward a corner, the hue shifts by up to ±0.15 units.
        
        ```
        mouse at (0.0, 0.0) → tiltInfluence = (-0.075, -0.075) → hue shifted toward warmer
        mouse at (1.0, 1.0) → tiltInfluence = (0.075, 0.075) → hue shifted toward cooler
        ```
        
        The `* u_hover` multiplier means the tilt has no effect when `u_hover = 0.0` (card at rest, not being hovered). This prevents the effect from jumping when the cursor first enters the card.
        
        ### Rainbow Hue Rotation
        
        The diagonal axis `diagAxis = uv.x * 0.55 + uv.y * 0.45` maps position on the card to a position on a diagonal line. Multiplied by `1.2` and fed into `hsv2rgb`, this creates about 1.2 full rainbow cycles across the diagonal.
        
        Adjusting the `1.2` multiplier:
        - `0.5` — one half-cycle: card goes from red at one corner to cyan at the other
        - `1.2` — slightly more than one cycle: most natural, looks like real foil
        - `3.0` — three cycles: very dense, looks like cheap novelty foil
        
        ### Shimmer Band
        
        The `band` computation creates a thin bright stripe that sweeps diagonally. `bandPhase = fract(bandDiag - u_time * bandSpeed)` makes the phase value advance continuously, so the stripe sweeps from corner to corner repeatedly.
        
        `smoothstep(0.0, 0.04, bandPhase) * smoothstep(0.14, 0.07, bandPhase)` creates a peak at phase ≈ 0.07–0.10 with soft falloff. The `0.04` and `0.14` values control band width.
        
        At `u_hover = 0.0`: band speed is `0.3` (one sweep every ~3 seconds). At `u_hover = 1.0`: speed is `1.2` (four sweeps per second). This is what "catches the light" when you hover.
        
        ---
        
        ## Color Palette Customization
        
        The wrestling theme uses gold `#FFB800` as the primary accent. The legendary shader incorporates this in the edge glow:
        
        ```glsl
        // #FFB800 in linear RGB ≈ (1.0, 0.72, 0.0)
        vec3 legendaryEdge = mix(edgeColor, vec3(1.0, 0.72, 0.0), 0.35 * u_rarity);
        ```
        
        To customize per rarity tier, these are the target palettes:
        
        | Rarity | Primary Hue | Gold Accent | Saturation |
        |--------|-------------|-------------|------------|
        | uncommon | Silver/white (neutral) | None | 0.2 |
        | rare | Blue 220°–240° | None | 0.7 |
        | legendary | Full rainbow | Gold #FFB800 at edges | 0.75 |
        
        For the rare tier (not in this shader, in `RARE_FRAG`), replace the rainbow hue rotation with a fixed blue hue:
        
        ```glsl
        // Rare: blue hue range instead of full rainbow
        float hue = 0.60 + fbm5(distortedUV * 2.0 + u_time * 0.1) * 0.06;
        // 0.60 = 216°, range ±0.06 = 194°–238° (blue-violet band)
        ```
        
        ---
        
        ## Performance Characteristics
        
        - Single render pass (no ping-pong or multiple draw calls)
        - No texture lookups — fully procedural
        - fBm at 5 octaves: ~5 `snoise()` calls per pixel. At 200×280 canvas (lg card at 1x DPR): ~280,000 noise evaluations per frame at 30fps = 8.4M noise evaluations/second. This is comfortably within WebGL2 fragment shader limits on integrated graphics.
        - On mobile (low-end), reduce to 3 octaves: change `fbm5()` to loop `i < 3`. Quality is slightly lower but still recognizable as holographic foil.
        
        Estimated GPU cost: < 0.5ms per frame for a single legendary card at 200×280 pixels on mid-range integrated graphics (Intel Iris / Apple M-series).
        
        ---
        
        ## Adapting for Non-Legendary Rarities
        
        Strip layers in order from most to least expensive:
        
        **Rare** (remove rainbow, keep shimmer + distortion):
        ```glsl
        // Replace Layer 2+3 with fixed blue hue
        float hue = 0.62 + distortX * 0.04;  // Blue, slight variation
        // Keep Layer 4 (shimmer) and Layer 5 (edge)
        // Reduce opacity: base 0.30, hover 0.55
        ```
        
        **Uncommon** (shimmer only, no distortion, no edge):
        ```glsl
        // Skip fBm entirely — use simple diagonal band
        // Use neutral silver color vec3(0.8, 0.85, 1.0)
        // Opacity: base 0.12, hover 0.25
        // Remove edge glow and mouse tilt entirely
        ```
        
        ---
        
        ## Patterns to Detect and Fix
        
        **Animating hue at full time speed**: `hue = fract(uv.x + u_time)` cycles through the full rainbow every second. Real holo foil shifts hue based on viewing angle, not time — it doesn't pulse like a disco ball. Use mouse position as the primary hue driver; time should only add a slow ambient drift.
        
        **Skipping the distortion field for performance**: Without UV distortion, the rainbow bands are perfectly straight lines. Real holo foil has organic, slightly random banding. Even 2 octaves of noise (not 5) is enough to break the linearity.
        
        **Using `opacity = 1.0`**: The shader is composited via `mix-blend-mode: screen`. At full opacity, the dark regions of the shader block the card art. Keep base opacity ≤ 0.5 at rest, ≤ 0.75 on hover.
        
        **Hard-coding `u_rarity = 1.0` constants**: The shader should work across rarity values. Use `u_rarity` to scale the intensity of specific features (e.g., the gold edge accent is `0.35 * u_rarity` — at `u_rarity = 0.5` for rare, the gold is 50% as strong).
        
        **Running at 60fps**: The shimmer animation at 30fps is indistinguishable from 60fps to human perception. The 30fps throttle in `useCardShader` halves GPU load. Only change this if the animation looks choppy at 30fps (it should not).
        
        **Large canvas dimensions for small cards**: At `md` size (140px wide), a 140×178 canvas is 24,920 pixels. At `lg` (170px), it's 36,550 pixels. At 2x DPR, these double. The fragment shader runs per pixel — keep canvases sized to their CSS-rendered dimensions, not artificially large.
        
      • card-shader-patterns.md 10.9 KB
        # Card Shader Patterns
        
        WebGL2 fragment shader patterns for card game visual effects. All shaders target `#version 300 es` (WebGL2). No texture lookups — all effects are procedural, running on any WebGL2-capable device without asset loading.
        
        **Card aspect ratio**: 400×560 (5:7). UV coordinates are aspect-corrected where noted.
        
        ---
        
        ## Shader Architecture
        
        Every card shader uses the same vertex shader. The fragment shader varies by effect tier.
        
        ### Shared Vertex Shader
        
        ```glsl
        #version 300 es
        precision highp float;
        
        in vec2 a_position;  // clip-space quad: [-1,1] x [-1,1]
        out vec2 v_uv;       // normalized [0,1] UV for the fragment shader
        
        void main() {
          // Convert clip-space position to [0,1] UV
          v_uv = a_position * 0.5 + 0.5;
          gl_Position = vec4(a_position, 0.0, 1.0);
        }
        ```
        
        The vertex shader draws a full-screen quad covering the canvas. Two triangles, four vertices, no index buffer needed for a quad (use `TRIANGLE_STRIP`):
        
        ```typescript
        // Quad geometry — reuse across all card canvases
        const QUAD_VERTS = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);
        ```
        
        ### Uniform Interface (all shaders)
        
        ```glsl
        // Required uniforms — every shader must declare all of these
        uniform float u_time;       // elapsed seconds, JS wraps at 1000.0 to avoid float precision loss
        uniform float u_rarity;     // 0.0=common, 0.25=uncommon, 0.5=rare, 1.0=legendary
        uniform float u_hover;      // 0.0 to 1.0, lerped toward target each frame
        uniform vec2  u_mouse;      // card-space mouse [0,1], (0.5,0.5) when not hovering
        uniform vec2  u_resolution; // canvas pixel size (width, height)
        uniform float u_upgraded;   // 0.0 or 1.0
        ```
        
        ---
        
        ## Noise Functions
        
        All procedural effects use these noise primitives. Include whichever you need at the top of each fragment shader.
        
        ### Value Noise (fast, slightly blocky — good for energy pulses)
        
        ```glsl
        // Hash function — maps vec2 to pseudo-random float in [0,1]
        float hash(vec2 p) {
          p = fract(p * vec2(127.1, 311.7));
          p += dot(p, p + 19.19);
          return fract(p.x * p.y);
        }
        
        // 2D value noise with bilinear interpolation
        float valueNoise(vec2 p) {
          vec2 i = floor(p);
          vec2 f = fract(p);
          // Smooth interpolation (C2 continuity)
          vec2 u = f * f * (3.0 - 2.0 * f);
        
          return mix(
            mix(hash(i),           hash(i + vec2(1.0, 0.0)), u.x),
            mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
            u.y
          );
        }
        ```
        
        ### Simplex-Style Noise (smoother, organic — best for foil shimmer)
        
        ```glsl
        // Based on Ian McEwan / Stefan Gustavson's simplex noise, adapted for WebGL2
        vec3 permute(vec3 x) {
          return mod(((x * 34.0) + 1.0) * x, 289.0);
        }
        
        float snoise(vec2 v) {
          const vec4 C = vec4(
            0.211324865405187,   // (3.0-sqrt(3.0))/6.0
            0.366025403784439,   // 0.5*(sqrt(3.0)-1.0)
           -0.577350269189626,   // -1.0 + 2.0 * C.x
            0.024390243902439    // 1.0 / 41.0
          );
        
          vec2 i  = floor(v + dot(v, C.yy));
          vec2 x0 = v - i + dot(i, C.xx);
          vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
          vec4 x12 = x0.xyxy + C.xxzz;
          x12.xy -= i1;
          i = mod(i, 289.0);
        
          vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
                         + i.x + vec3(0.0, i1.x, 1.0));
        
          vec3 m = max(0.5 - vec3(
            dot(x0, x0),
            dot(x12.xy, x12.xy),
            dot(x12.zw, x12.zw)
          ), 0.0);
        
          m = m * m;
          m = m * m;
        
          vec3 x = 2.0 * fract(p * C.www) - 1.0;
          vec3 h = abs(x) - 0.5;
          vec3 ox = floor(x + 0.5);
          vec3 a0 = x - ox;
          m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
        
          vec3 g;
          g.x  = a0.x  * x0.x   + h.x  * x0.y;
          g.yz = a0.yz * x12.xz + h.yz * x12.yw;
        
          return 130.0 * dot(m, g);
        }
        ```
        
        ### Fractional Brownian Motion (layered noise for organic foil)
        
        ```glsl
        // fBm: sum multiple noise octaves for more organic motion
        // Use 3 octaves for shimmer (fast), 5 for legendary foil (higher quality)
        float fbm(vec2 p, int octaves) {
          float value = 0.0;
          float amplitude = 0.5;
          float frequency = 1.0;
          float lacunarity = 2.0;
          float gain = 0.5;
        
          for (int i = 0; i < 8; i++) {
            if (i >= octaves) break;
            value += amplitude * snoise(p * frequency);
            frequency *= lacunarity;
            amplitude *= gain;
          }
          return value;
        }
        ```
        
        ---
        
        ## Effect 1: Metallic Shimmer (uncommon)
        
        A single bright band that sweeps diagonally across the card surface. Subtle — opacity capped at 0.3.
        
        ```glsl
        #version 300 es
        precision highp float;
        
        in vec2 v_uv;
        out vec4 fragColor;
        
        uniform float u_time;
        uniform float u_rarity;   // Expected: 0.25 for uncommon
        uniform float u_hover;
        uniform vec2  u_resolution;
        
        void main() {
          vec2 uv = v_uv;
        
          // Diagonal shimmer band: project UV onto diagonal axis
          float diagAxis = uv.x * 0.6 + uv.y * 0.4;
        
          // Band position oscillates with time, hover accelerates it
          float speed = 0.4 + u_hover * 0.6;
          float bandPos = fract(diagAxis - u_time * speed);
        
          // Soft band: sharp center, falloff on edges
          float band = smoothstep(0.0, 0.08, bandPos) * smoothstep(0.22, 0.12, bandPos);
        
          // Silver/white color for metallic feel
          vec3 shimmerColor = vec3(0.85, 0.90, 1.0);
        
          // Opacity: base 0.15 idle, up to 0.30 on hover
          float opacity = (0.15 + u_hover * 0.15) * band;
        
          // Vignette: fade toward card edges so effect stays centered
          vec2 centered = uv - 0.5;
          float vignette = 1.0 - smoothstep(0.3, 0.5, length(centered));
        
          fragColor = vec4(shimmerColor, opacity * vignette);
        }
        ```
        
        ---
        
        ## Effect 2: Rare Shimmer + Blue Hue Shift
        
        Shimmer band plus a blue-purple hue overlay and edge glow. Intensity doubles on hover.
        
        ```glsl
        #version 300 es
        precision highp float;
        
        in vec2 v_uv;
        out vec4 fragColor;
        
        uniform float u_time;
        uniform float u_rarity;   // Expected: 0.5 for rare
        uniform float u_hover;
        uniform vec2  u_mouse;
        uniform vec2  u_resolution;
        uniform float u_upgraded;
        
        // [Include valueNoise and snoise from above]
        
        // HSV to RGB for color cycling
        vec3 hsv2rgb(vec3 c) {
          vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
          vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
          return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
        }
        
        void main() {
          vec2 uv = v_uv;
        
          // Aspect-correct UVs for 400x560 card (5:7 ratio)
          vec2 aspectUV = vec2(uv.x, uv.y * (560.0 / 400.0));
        
          // --- Shimmer band (same as uncommon, higher opacity) ---
          float diagAxis = uv.x * 0.6 + uv.y * 0.4;
          float speed = 0.5 + u_hover * 0.8;
          float bandPos = fract(diagAxis - u_time * speed);
          float band = smoothstep(0.0, 0.06, bandPos) * smoothstep(0.18, 0.10, bandPos);
        
          // --- Blue hue shift layer ---
          // Subtle noise distortion on the UV before hue lookup
          float noiseVal = valueNoise(aspectUV * 3.0 + vec2(u_time * 0.15, u_time * 0.1));
          float hue = 0.62 + noiseVal * 0.08;  // Blue range: 0.58–0.70
          float sat = 0.7 + u_hover * 0.2;
          float val = 0.5 + band * 0.4;
          vec3 hueColor = hsv2rgb(vec3(hue, sat, val));
        
          // --- Edge pulse ---
          // Glow along card edges, stronger on hover
          vec2 edge = min(uv, 1.0 - uv);
          float edgeDist = min(edge.x, edge.y);
          float edgePulse = 1.0 - smoothstep(0.0, 0.08, edgeDist);
          float pulseAnim = 0.5 + 0.5 * sin(u_time * 2.0 + uv.y * 4.0);
          vec3 edgeColor = vec3(0.3, 0.5, 1.0);  // Blue
          float edgeIntensity = edgePulse * pulseAnim * (0.3 + u_hover * 0.4);
        
          // --- Upgraded: slightly more intense, slight green tint on edge ---
          vec3 upgradeColor = mix(edgeColor, vec3(0.3, 1.0, 0.5), u_upgraded * 0.3);
        
          // --- Composite ---
          vec3 finalColor = mix(hueColor, upgradeColor, edgePulse * 0.4);
          float finalOpacity = (0.25 + u_hover * 0.25) * (band * 0.6 + edgeIntensity);
        
          // Vignette
          vec2 centered = uv - 0.5;
          float vignette = 1.0 - smoothstep(0.25, 0.5, length(centered));
        
          fragColor = vec4(finalColor, finalOpacity * vignette);
        }
        ```
        
        ---
        
        ## Effect 3: Energy Pulse (rarity-colored radial)
        
        Radial pulse from card center, color and intensity keyed to `u_rarity`. Used as an ambient idle effect, not dependent on hover.
        
        ```glsl
        #version 300 es
        precision highp float;
        
        in vec2 v_uv;
        out vec4 fragColor;
        
        uniform float u_time;
        uniform float u_rarity;
        uniform float u_hover;
        uniform vec2  u_resolution;
        
        void main() {
          vec2 uv = v_uv;
          vec2 centered = uv - 0.5;
        
          // Aspect-correct distance for non-square cards (400x560)
          centered.y *= (400.0 / 560.0);
          float dist = length(centered);
        
          // Pulse ring emanates from center
          float pulseSpeed = 0.8 + u_rarity * 0.4;
          float pulse = fract(dist * 2.5 - u_time * pulseSpeed);
          float ring = smoothstep(0.0, 0.1, pulse) * smoothstep(0.3, 0.15, pulse);
        
          // Falloff: pulses fade as they move outward
          float falloff = 1.0 - smoothstep(0.0, 0.55, dist);
        
          // Rarity color: common=silver, uncommon=blue, rare=gold, legendary=rainbow
          vec3 rarityColor;
          if (u_rarity < 0.3) {
            rarityColor = vec3(0.6, 0.6, 0.7);  // Silver (uncommon)
          } else if (u_rarity < 0.6) {
            rarityColor = vec3(1.0, 0.72, 0.0);  // Gold (rare) — matches #FFB800
          } else {
            // Legendary: animate through rainbow
            float hue = fract(u_time * 0.15 + dist * 0.5);
            rarityColor = vec3(
              0.5 + 0.5 * cos(6.28318 * (hue + 0.0)),
              0.5 + 0.5 * cos(6.28318 * (hue + 0.333)),
              0.5 + 0.5 * cos(6.28318 * (hue + 0.667))
            );
          }
        
          float intensity = ring * falloff * (0.4 + u_hover * 0.3);
          fragColor = vec4(rarityColor, intensity);
        }
        ```
        
        ---
        
        ## UV Coordinate Reference
        
        The card aspect ratio is 400×560 (width × height). When computing effects that need to appear geometrically correct (circles, uniform bands):
        
        ```glsl
        // Aspect correction: make UV space square for geometric effects
        // Multiply y by (width/height) to normalize
        vec2 aspectUV = vec2(v_uv.x, v_uv.y * (400.0 / 560.0));
        
        // For distance calculations from center:
        vec2 centered = v_uv - 0.5;
        centered.y *= (400.0 / 560.0);  // Correct for aspect
        float dist = length(centered);   // Now a circle, not an ellipse
        ```
        
        ---
        
        ## Patterns to Detect and Fix
        
        **Allocating arrays or objects in the fragment shader loop**: GLSL `for` loops with dynamic bounds or large local arrays cause driver stalls. Use a fixed iteration count (e.g., `for (int i = 0; i < 5; i++)`) and use `break` only when the loop count is truly constant.
        
        **Using `gl_FragCoord` for UV**: Derive UVs from `v_uv` (passed from vertex shader), not `gl_FragCoord / u_resolution`. The varyings are more stable and the math is cleaner.
        
        **Sampling `u_time` directly at full speed**: `u_time` increments at 1.0 per second by default. For slow organic movement, multiply by 0.3–0.6. For pulse timing, use `fract()` to loop cleanly instead of `mod()`.
        
        **Opacity above 0.75 for shimmer effects**: Effects above 0.75 opacity obscure the card art and text. Test with `mix-blend-mode: screen` — the effective visual opacity is lower than the alpha value suggests.
        
        **Using WebGL1 (`gl.getContext('webgl')`)**: Always request `webgl2`. WebGL1 lacks `#version 300 es`, `out vec4` fragment outputs, and the `in`/`out` varying syntax. Feature-detect and fall back to CSS if WebGL2 is unavailable.
        
        **Declaring unused uniforms**: GLSL compilers strip uniforms that are declared but never referenced. The JS side calling `gl.getUniformLocation()` on a stripped uniform returns `null`, causing a silent no-op. Either reference every uniform in shader code or remove unused declarations.
        
      • color-inspirations.json 9.4 KB
        {
          "version": "1.0.0",
          "last_updated": "2025-12-04",
          "categories": {
            "ide_themes": {
              "description": "Color schemes from beloved code editor themes",
              "palettes": [
                {
                  "name": "Dracula",
                  "inspiration": "Popular dark theme with purple accents",
                  "dominant": "#282A36",
                  "secondary": "#44475A",
                  "accent": "#BD93F9",
                  "functional": {
                    "success": "#50FA7B",
                    "warning": "#FFB86C",
                    "error": "#FF5555",
                    "info": "#8BE9FD"
                  },
                  "rationale": "High contrast, excellent for technical UIs"
                },
                {
                  "name": "Nord",
                  "inspiration": "Arctic, north-bluish color palette",
                  "dominant": "#2E3440",
                  "secondary": "#3B4252",
                  "accent": "#88C0D0",
                  "functional": {
                    "success": "#A3BE8C",
                    "warning": "#EBCB8B",
                    "error": "#BF616A",
                    "info": "#81A1C1"
                  },
                  "rationale": "Calm, sophisticated, Scandinavian aesthetics"
                },
                {
                  "name": "Gruvbox",
                  "inspiration": "Retro groove with warm tones",
                  "dominant": "#282828",
                  "secondary": "#3C3836",
                  "accent": "#FABD2F",
                  "functional": {
                    "success": "#B8BB26",
                    "warning": "#FE8019",
                    "error": "#FB4934",
                    "info": "#83A598"
                  },
                  "rationale": "Warm, vintage feel with excellent contrast"
                },
                {
                  "name": "Tokyo Night",
                  "inspiration": "Neon-lit Tokyo cityscape at night",
                  "dominant": "#1A1B26",
                  "secondary": "#24283B",
                  "accent": "#7AA2F7",
                  "functional": {
                    "success": "#9ECE6A",
                    "warning": "#E0AF68",
                    "error": "#F7768E",
                    "info": "#7DCFFF"
                  },
                  "rationale": "Modern cyberpunk aesthetic"
                },
                {
                  "name": "Catppuccin Mocha",
                  "inspiration": "Soothing pastel theme for night owls",
                  "dominant": "#1E1E2E",
                  "secondary": "#313244",
                  "accent": "#CBA6F7",
                  "functional": {
                    "success": "#A6E3A1",
                    "warning": "#F9E2AF",
                    "error": "#F38BA8",
                    "info": "#89DCEB"
                  },
                  "rationale": "Soft pastels with deep backgrounds"
                }
              ]
            },
            "cultural_aesthetics": {
              "description": "Color palettes inspired by cultural traditions",
              "palettes": [
                {
                  "name": "Japanese Indigo",
                  "inspiration": "Traditional Japanese dye techniques",
                  "dominant": "#EAE6DF",
                  "secondary": "#38444F",
                  "accent": "#003171",
                  "functional": {
                    "success": "#4A7C59",
                    "warning": "#D4A574",
                    "error": "#A64942",
                    "info": "#5B7C8D"
                  },
                  "rationale": "Minimalist, natural, sophisticated"
                },
                {
                  "name": "Scandinavian Woods",
                  "inspiration": "Nordic forests and natural materials",
                  "dominant": "#F4F1EA",
                  "secondary": "#8B7355",
                  "accent": "#C1502E",
                  "functional": {
                    "success": "#5F7161",
                    "warning": "#D4A574",
                    "error": "#A64942",
                    "info": "#6D7993"
                  },
                  "rationale": "Earthy, organic, calming"
                },
                {
                  "name": "Mediterranean Sun",
                  "inspiration": "Greek islands and coastal architecture",
                  "dominant": "#F8F6F0",
                  "secondary": "#0077BE",
                  "accent": "#E67E22",
                  "functional": {
                    "success": "#27AE60",
                    "warning": "#F39C12",
                    "error": "#C0392B",
                    "info": "#3498DB"
                  },
                  "rationale": "Bright, warm, inviting"
                },
                {
                  "name": "Desert Dusk",
                  "inspiration": "Southwestern US desert landscapes",
                  "dominant": "#E8DCC7",
                  "secondary": "#7C5D4F",
                  "accent": "#D1603D",
                  "functional": {
                    "success": "#8B956D",
                    "warning": "#E6AA68",
                    "error": "#B85042",
                    "info": "#547C84"
                  },
                  "rationale": "Warm earth tones, natural depth"
                }
              ]
            },
            "natural_phenomena": {
              "description": "Colors from nature and atmospheric effects",
              "palettes": [
                {
                  "name": "Arctic Twilight",
                  "inspiration": "Northern lights and polar night",
                  "dominant": "#1B2838",
                  "secondary": "#2D4654",
                  "accent": "#66D9EF",
                  "functional": {
                    "success": "#A6E3A1",
                    "warning": "#F9E2AF",
                    "error": "#F38BA8",
                    "info": "#89B4FA"
                  },
                  "rationale": "Cool, mysterious, ethereal"
                },
                {
                  "name": "Autumn Forest",
                  "inspiration": "Fall foliage and forest floor",
                  "dominant": "#F5EFE7",
                  "secondary": "#4A403A",
                  "accent": "#D4A574",
                  "functional": {
                    "success": "#6B8E23",
                    "warning": "#CD853F",
                    "error": "#A0522D",
                    "info": "#8B7355"
                  },
                  "rationale": "Rich, organic, comforting"
                },
                {
                  "name": "Deep Ocean",
                  "inspiration": "Underwater depths and bioluminescence",
                  "dominant": "#0B132B",
                  "secondary": "#1C2541",
                  "accent": "#00D9FF",
                  "functional": {
                    "success": "#6FFFE9",
                    "warning": "#FFB627",
                    "error": "#FF006E",
                    "info": "#5BC0EB"
                  },
                  "rationale": "Deep, mysterious, luminous accents"
                },
                {
                  "name": "Volcanic Ash",
                  "inspiration": "Iceland volcanic landscapes",
                  "dominant": "#E5E3DF",
                  "secondary": "#4A4A48",
                  "accent": "#FF4500",
                  "functional": {
                    "success": "#3D5A40",
                    "warning": "#FF8C42",
                    "error": "#C1121F",
                    "info": "#6C757D"
                  },
                  "rationale": "Stark contrasts, dramatic energy"
                }
              ]
            },
            "art_movements": {
              "description": "Palettes inspired by artistic periods and styles",
              "palettes": [
                {
                  "name": "Bauhaus Primary",
                  "inspiration": "Bauhaus school color theory",
                  "dominant": "#F4F1E8",
                  "secondary": "#1A1A1A",
                  "accent": "#E63946",
                  "functional": {
                    "success": "#06A77D",
                    "warning": "#F7B801",
                    "error": "#D62828",
                    "info": "#1D3557"
                  },
                  "rationale": "Bold primaries, geometric precision"
                },
                {
                  "name": "De Stijl",
                  "inspiration": "Dutch modernism and Mondrian",
                  "dominant": "#EFEFEF",
                  "secondary": "#000000",
                  "accent": "#DD0100",
                  "functional": {
                    "success": "#4CAF50",
                    "warning": "#FFC107",
                    "error": "#DD0100",
                    "info": "#0050B3"
                  },
                  "rationale": "Primary colors, stark geometry"
                },
                {
                  "name": "Art Deco Gold",
                  "inspiration": "1920s glamour and luxury",
                  "dominant": "#1B1B1B",
                  "secondary": "#2F2F2F",
                  "accent": "#D4AF37",
                  "functional": {
                    "success": "#3A7D44",
                    "warning": "#FF8C00",
                    "error": "#C41E3A",
                    "info": "#4682B4"
                  },
                  "rationale": "Luxurious, sophisticated, timeless"
                }
              ]
            },
            "industrial": {
              "description": "Utilitarian and industrial design influences",
              "palettes": [
                {
                  "name": "Concrete & Voltage",
                  "inspiration": "Brutalist architecture + high-voltage signage",
                  "dominant": "#E8E6E3",
                  "secondary": "#3A3A3A",
                  "accent": "#FFDE00",
                  "functional": {
                    "success": "#00C853",
                    "warning": "#FF6D00",
                    "error": "#D32F2F",
                    "info": "#0091EA"
                  },
                  "rationale": "Industrial materials with safety yellow energy"
                },
                {
                  "name": "Factory Floor",
                  "inspiration": "Manufacturing equipment and warning stripes",
                  "dominant": "#3D3D3D",
                  "secondary": "#5C5C5C",
                  "accent": "#FFB700",
                  "functional": {
                    "success": "#4CAF50",
                    "warning": "#FF9800",
                    "error": "#F44336",
                    "info": "#2196F3"
                  },
                  "rationale": "Heavy machinery aesthetic, functional color coding"
                },
                {
                  "name": "Railway Station",
                  "inspiration": "Victorian railway architecture",
                  "dominant": "#E4DED0",
                  "secondary": "#2C3E2E",
                  "accent": "#8B0000",
                  "functional": {
                    "success": "#2E7D32",
                    "warning": "#F57C00",
                    "error": "#C62828",
                    "info": "#1565C0"
                  },
                  "rationale": "Historic industrial with deep greens and reds"
                }
              ]
            }
          },
          "usage_guidelines": {
            "dominance_rule": "60% dominant, 30% secondary, 10% accent for balanced composition",
            "contrast_minimum": "WCAG AA requires 4.5:1 for normal text, 3:1 for large text",
            "accent_strategy": "Use accent sparingly for high-impact moments (CTAs, highlights, alerts)",
            "background_layering": "Layer gradients or patterns over dominant color for atmospheric depth",
            "dark_mode": "Flip dominance (dark becomes dominant) but keep accent for consistency"
          }
        }
        
      • css-audit-patterns.md 10.4 KB
        # CSS Audit Patterns Reference
        
        > **Scope**: grep/rg detection commands for banned fonts, hardcoded colors, failure mode CSS structures, and over-animation in frontend source files.
        > **Version range**: CSS3+, all frameworks (Tailwind, CSS Modules, plain CSS/SCSS)
        > **Generated**: 2026-04-16 — patterns apply to .css, .scss, .module.css, .tsx, .jsx, .html files
        
        ---
        
        ## Overview
        
        This file provides runnable detection commands to audit a frontend codebase for design failure modes. Run these after generating a design specification to verify the implementation matches the intent. The most common failure mode is banned fonts slipping in through fallback stacks or Google Fonts imports, and hardcoded hex values bypassing the CSS custom property system.
        
        ---
        
        ## Pattern Catalog
        <!-- no-pair-required: section-header-only; pairs live in each sub-section below -->
        
        ### Select Fonts from the Approved Catalog
        
        **Detection**:
        ```bash
        # Find banned font families in CSS/SCSS/style tags
        grep -rn 'font-family.*\(Inter\|Roboto\|Arial\|Helvetica\|Space Grotesk\)' --include="*.css" --include="*.scss" --include="*.module.css"
        grep -rn 'font-family.*\(Inter\|Roboto\|Arial\|Helvetica\|Space Grotesk\)' --include="*.tsx" --include="*.jsx" --include="*.html"
        
        # Find system font stacks (banned: -apple-system, BlinkMacSystemFont, 'Segoe UI')
        rg '-apple-system|BlinkMacSystemFont|Segoe UI' --type css
        rg '-apple-system|BlinkMacSystemFont|Segoe UI' -g "*.tsx" -g "*.jsx"
        
        # Find Google Fonts imports loading banned fonts
        grep -rn 'fonts.googleapis.com.*\(Inter\|Roboto\|Space.Grotesk\)' --include="*.html" --include="*.tsx" --include="*.jsx"
        grep -rn "next/font.*\(inter\|roboto\)" --include="*.tsx" --include="*.jsx" -i
        ```
        
        **Signal**:
        ```css
        body {
          font-family: Inter, -apple-system, sans-serif; /* banned: Inter + system stack */
        }
        h1 {
          font-family: 'Space Grotesk', Roboto, Arial; /* banned: all three */
        }
        ```
        
        **Why this matters**: These fonts are overused to the point of invisibility. They signal generic AI output, not contextual design. Every project that reaches for Inter as a default produces the same page. Using them makes the design indistinguishable from a template.
        
        **Preferred action**: Select from `references/font-catalog.json` under the matching aesthetic category. An editorial serif or a constructed grotesque chosen for the project context will distinguish the work from every other Inter page:
        ```css
        /* Use a pre-approved font from references/font-catalog.json */
        body {
          font-family: 'DM Sans', sans-serif;
        }
        h1 {
          font-family: 'Fraunces', Georgia, serif; /* editorial, distinctive */
        }
        ```
        
        ---
        
        ### Define All Colors as CSS Custom Properties
        
        **Detection**:
        ```bash
        # Find hardcoded hex values in CSS (skip lines that define custom properties)
        grep -rn '#[0-9a-fA-F]\{3,6\}' --include="*.css" --include="*.scss" | grep -v '^\s*--'
        
        # Find hardcoded rgb/rgba in style rules
        rg 'color:\s*(rgb|rgba|hsl|hsla)\(' --type css
        rg 'background(-color)?:\s*(rgb|rgba|hsl|hsla)\(' --type css
        
        # Find Tailwind arbitrary color values (should use CSS vars via theme config instead)
        rg 'text-\[#[0-9a-fA-F]+\]|bg-\[#[0-9a-fA-F]+\]|border-\[#[0-9a-fA-F]+\]' -g "*.tsx" -g "*.jsx"
        ```
        
        **Signal**:
        ```css
        .hero-title {
          color: #1a1a2e;           /* hardcoded — bypasses token system */
          background: #4a90e2;      /* hardcoded — impossible to audit ratio */
        }
        ```
        
        **Why this matters**: Hardcoded colors scattered across files break the 60/30/10 dominance ratio audit. You cannot verify palette coherence when the palette exists only in scattered hex literals. Palette changes require grep-and-replace across dozens of files instead of one `:root` edit.
        
        **Preferred action**: Define all palette values as CSS custom properties in a single `:root` block, then reference them everywhere via `var()`. Palette auditing becomes a one-file operation:
        ```css
        :root {
          --color-dominant: #1a1a2e;
          --color-accent: #4a90e2;
        }
        .hero-title {
          color: var(--color-dominant);
          background: var(--color-accent);
        }
        ```
        
        ---
        
        ### Limit Animations to Three Intentional Slots
        
        **Detection**:
        ```bash
        # Count animation declarations per CSS file (flag files with > 5)
        grep -c 'animation:' **/*.css **/*.scss 2>/dev/null
        
        # Find all animation declarations
        grep -rn '^\s*animation:' --include="*.css" --include="*.scss" --include="*.module.css"
        
        # Find Framer Motion animated elements (count total — over 6 per route is a signal)
        rg 'motion\.(div|section|h[1-6]|p|span|article|header|footer)' -g "*.tsx" -g "*.jsx" | wc -l
        
        # Find blanket transition (anti-pattern: animating everything with *)
        grep -rn '\*\s*{[^}]*transition' --include="*.css" --include="*.scss"
        grep -rn 'transition:\s*all' --include="*.css" --include="*.scss"
        ```
        
        **Signal**:
        ```css
        /* Animating everything — loses impact */
        .hero         { animation: fade-in 0.4s ease; }
        .hero-title   { animation: slide-up 0.5s ease 0.1s; }
        .hero-subtitle{ animation: slide-up 0.5s ease 0.2s; }
        .hero-cta     { animation: slide-up 0.5s ease 0.3s; }
        .nav          { animation: slide-down 0.4s ease; }
        .feature-card { animation: scale-in 0.5s ease; }
        .feature-icon { animation: rotate-in 0.6s ease; }
        /* 7 animations — three times over budget */
        ```
        
        **Why this matters**: When everything moves, nothing stands out. Motion creates hierarchy; animating every element collapses that hierarchy. Ten animations communicate noise, three intentional animations communicate craft.
        
        **Preferred action**: Fill exactly three motion slots (entrance, scroll-reveal, interaction) and animate nothing else. Silence is hierarchy:
        ```css
        /* 3 intentional slots: entrance, scroll-reveal (JS), interaction */
        .hero-title { animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .hero-cta   { animation: fade-in 0.6s ease 0.4s both; }
        /* Scroll reveal on one feature block via IntersectionObserver fills the third slot */
        /* Everything else: no animation */
        ```
        
        ---
        
        ### Layer Gradients for Atmospheric Depth
        
        **Detection**:
        ```bash
        # Find background: or background-color: with only a color (no gradient)
        grep -rn '^\s*background-color:' --include="*.css" --include="*.scss" --include="*.module.css"
        grep -rn '^\s*background:\s*#\|^\s*background:\s*rgb\|^\s*background:\s*var(' --include="*.css" --include="*.scss"
        
        # Verify hero/section elements have gradient layers (count should be > 0)
        grep -rn '\.hero\b\|\.landing\b\|\.page-hero\b' --include="*.css" -A 8 | grep -c 'gradient'
        ```
        
        **Signal**:
        ```css
        .hero {
          background-color: #0a0a1a; /* flat — fails the 2-layer Phase 5 gate */
        }
        .landing-section {
          background: #f5f5f0;       /* single value — no atmospheric depth */
        }
        ```
        
        **Why this matters**: A flat background creates no visual depth, no focal point, no mood. It is the visual equivalent of saying nothing. The Phase 5 gate requires at least two layers (base color plus gradient layer) because depth is what separates distinctive design from a stylesheet reset.
        
        **Preferred action**: Layer at least one radial gradient above the base color to create focal direction and atmospheric depth. Reference `background-techniques.md` for recipe options:
        ```css
        .hero {
          background:
            radial-gradient(ellipse 80% 60% at 20% 40%, rgba(99, 102, 241, 0.15), transparent 60%),
            radial-gradient(ellipse 60% 40% at 80% 80%, rgba(168, 85, 247, 0.10), transparent 50%),
            #0a0a1a; /* base + two glow layers = atmospheric depth */
        }
        ```
        
        ---
        
        ### Include a Named Fallback in Every Font Stack
        
        **Detection**:
        ```bash
        # Find font stacks where sans-serif is the only or first value
        grep -rn "font-family:\s*sans-serif\b" --include="*.css" --include="*.scss"
        rg "font-family:\s*sans-serif\b" --type css
        
        # Find missing fallback (only one font in stack, no comma)
        grep -rn "font-family:\s*'[^']*'" --include="*.css" | grep -v ','
        ```
        
        **Signal**:
        ```css
        body { font-family: sans-serif; }        /* OS default — different on every platform */
        h1   { font-family: 'Outfit'; }          /* no fallback — FOUT on slow connections */
        ```
        
        **Why this matters**: `sans-serif` alone renders as Helvetica (macOS), Arial (Windows), or Liberation Sans (Linux): three different pages. A missing fallback causes FOUT (flash of unstyled text) on slow connections when the web font has not loaded.
        
        **Preferred action**: Add one named system fallback between the web font and the generic family. The named fallback degrades gracefully while the web font loads:
        ```css
        body { font-family: 'DM Sans', 'Gill Sans', sans-serif; }
        h1   { font-family: 'Fraunces', Georgia, serif; }
        ```
        
        ---
        
        ## Error-Fix Mappings
        
        | Error (validate_design.py) | Root Cause | Fix |
        |----------------------------|------------|-----|
        | `"banned font detected"` | Font name matches banned list | Replace with catalog font; audit fallback stacks with grep above |
        | `"background: single layer"` | `.hero` has `background-color` only | Add `radial-gradient` layer above base color |
        | `"motion count exceeds 3"` | More than 3 CSS `animation:` per route | Remove ambient micro-interactions; keep entrance/scroll/interaction only |
        | `"hardcoded color values"` | Hex/rgb outside `:root` definitions | Move to `--color-*` custom property; reference via `var()` |
        | `"font-family: bare sans-serif"` | No named fallback before generic family | Add one named fallback font between web font and `sans-serif` |
        
        ---
        
        ## Detection Commands Reference
        
        ```bash
        # Banned fonts (CSS/SCSS)
        grep -rn 'font-family.*\(Inter\|Roboto\|Arial\|Helvetica\|Space Grotesk\)' --include="*.css" --include="*.scss"
        
        # System font stacks
        rg '-apple-system|BlinkMacSystemFont|Segoe UI' --type css
        
        # Hardcoded hex (not in custom property lines)
        grep -rn '#[0-9a-fA-F]\{3,6\}' --include="*.css" --include="*.scss" | grep -v '^\s*--'
        
        # Tailwind arbitrary colors
        rg 'text-\[#[0-9a-fA-F]+\]|bg-\[#[0-9a-fA-F]+\]' -g "*.tsx" -g "*.jsx"
        
        # Over-animation (all animation declarations)
        grep -rn '^\s*animation:' --include="*.css" --include="*.scss" --include="*.module.css"
        
        # Blanket transition (transition: all)
        grep -rn 'transition:\s*all' --include="*.css" --include="*.scss"
        
        # Single-layer backgrounds
        grep -rn '^\s*background:\s*#\|^\s*background:\s*rgb' --include="*.css" --include="*.scss"
        
        # Bare sans-serif fallback
        grep -rn "font-family:\s*sans-serif" --include="*.css" --include="*.scss"
        ```
        
        ---
        
        ## See Also
        
        - `preferred-patterns.json` — full banned font list, cliche color catalog, layout cliche list
        - `font-catalog.json` — pre-approved fonts by aesthetic category
        - `background-techniques.md` — layered gradient and atmospheric background recipes
        - `animation-patterns.md` — 2-to-3 rule implementation patterns with timing values
        
      • font-catalog.json 8.4 KB
        {
          "version": "1.0.0",
          "last_updated": "2025-12-04",
          "categories": {
            "neo_brutalist": {
              "description": "Bold, geometric, unapologetic. Technical precision with attitude.",
              "fonts": [
                {
                  "name": "Unbounded",
                  "type": "display",
                  "weights": [300, 400, 600, 800],
                  "source": "Google Fonts",
                  "rationale": "Variable geometric sans with distinctive rounded terminals. Strong presence without being corporate."
                },
                {
                  "name": "Bebas Neue",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Condensed all-caps. Industrial strength, brutalist aesthetic."
                },
                {
                  "name": "Archivo Black",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Heavy grotesque. Commands attention, brutalist energy."
                },
                {
                  "name": "Work Sans",
                  "type": "body",
                  "weights": [400, 500, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Geometric sans optimized for screens. Pairs well with bold display fonts."
                }
              ]
            },
            "sophisticated_serif": {
              "description": "Elegant, editorial, refined. Literary quality and timeless appeal.",
              "fonts": [
                {
                  "name": "Playfair Display",
                  "type": "display",
                  "weights": [400, 700, 900],
                  "source": "Google Fonts",
                  "rationale": "High-contrast transitional serif. Sophisticated without being stuffy."
                },
                {
                  "name": "Crimson Pro",
                  "type": "body",
                  "weights": [400, 500, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Book-style serif designed for text. Readable elegance."
                },
                {
                  "name": "Cormorant",
                  "type": "display",
                  "weights": [300, 400, 500, 700],
                  "source": "Google Fonts",
                  "rationale": "Delicate display serif with Garamond influence. Graceful personality."
                },
                {
                  "name": "Lora",
                  "type": "body",
                  "weights": [400, 500, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Contemporary serif with calligraphic roots. Warmth and character."
                },
                {
                  "name": "Spectral",
                  "type": "body",
                  "weights": [300, 400, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Serif optimized for digital reading. Clear hierarchy."
                }
              ]
            },
            "technical_mono": {
              "description": "Code-focused, precise, developer-friendly. Terminal aesthetics.",
              "fonts": [
                {
                  "name": "JetBrains Mono",
                  "type": "mono",
                  "weights": [400, 500, 700],
                  "source": "JetBrains",
                  "rationale": "Purpose-built for code. Excellent ligatures, increased letter height."
                },
                {
                  "name": "Fira Code",
                  "type": "mono",
                  "weights": [400, 500, 700],
                  "source": "Google Fonts",
                  "rationale": "Extension of Fira Mono with programming ligatures. Developer favorite."
                },
                {
                  "name": "IBM Plex Mono",
                  "type": "mono",
                  "weights": [400, 500, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Corporate heritage with humanist warmth. Distinctive personality."
                },
                {
                  "name": "Space Mono",
                  "type": "mono",
                  "weights": [400, 700],
                  "source": "Google Fonts",
                  "rationale": "Geometric monospace with retro-tech feel. Quirky but readable."
                }
              ]
            },
            "warm_humanist": {
              "description": "Approachable, organic, friendly. Human touch and crafted feel.",
              "fonts": [
                {
                  "name": "Quicksand",
                  "type": "display",
                  "weights": [400, 500, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Rounded geometric sans with friendly personality. Warm without being childish."
                },
                {
                  "name": "Josefin Sans",
                  "type": "display",
                  "weights": [400, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Geometric with vintage poster influence. Distinctive character."
                },
                {
                  "name": "Manrope",
                  "type": "body",
                  "weights": [400, 500, 600, 700, 800],
                  "source": "Google Fonts",
                  "rationale": "Modern geometric sans with open apertures. Friendly and functional."
                },
                {
                  "name": "Plus Jakarta Sans",
                  "type": "body",
                  "weights": [400, 500, 600, 700, 800],
                  "source": "Google Fonts",
                  "rationale": "Neo-grotesque with Indonesian design influence. Fresh perspective."
                }
              ]
            },
            "retro_display": {
              "description": "Nostalgic, distinctive, bold. Vintage energy and character.",
              "fonts": [
                {
                  "name": "Righteous",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Futuristic retro with 80s influence. Strong personality."
                },
                {
                  "name": "Bungee",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Modular signage typeface. Urban energy and attitude."
                },
                {
                  "name": "Audiowide",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Techno-futuristic with 80s sci-fi vibe. Synthetic but engaging."
                },
                {
                  "name": "Permanent Marker",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Handwritten marker aesthetic. Casual energy and immediacy."
                }
              ]
            },
            "editorial_condensed": {
              "description": "Compact, impactful, magazine-quality. Efficient use of space.",
              "fonts": [
                {
                  "name": "Barlow Condensed",
                  "type": "display",
                  "weights": [400, 600, 700, 900],
                  "source": "Google Fonts",
                  "rationale": "Tall grotesque with low contrast. Efficient and striking."
                },
                {
                  "name": "Oswald",
                  "type": "display",
                  "weights": [400, 600, 700],
                  "source": "Google Fonts",
                  "rationale": "Gothic condensed with newspaper heritage. Strong vertical emphasis."
                },
                {
                  "name": "Fjalla One",
                  "type": "display",
                  "weights": [400],
                  "source": "Google Fonts",
                  "rationale": "Nordic-inspired condensed sans. Sturdy and confident."
                }
              ]
            },
            "geometric_modern": {
              "description": "Clean, precise, contemporary. Mathematical harmony and balance.",
              "fonts": [
                {
                  "name": "Montserrat",
                  "type": "body",
                  "weights": [400, 500, 600, 700, 800],
                  "source": "Google Fonts",
                  "rationale": "Urban geometric inspired by Buenos Aires signage. Clean but characterful."
                },
                {
                  "name": "Poppins",
                  "type": "body",
                  "weights": [400, 500, 600, 700, 800],
                  "source": "Google Fonts",
                  "rationale": "Geometric sans with international flair. Rounded but not soft."
                },
                {
                  "name": "DM Sans",
                  "type": "body",
                  "weights": [400, 500, 700],
                  "source": "Google Fonts",
                  "rationale": "Low-contrast geometric optimized for UI. Clean efficiency."
                }
              ]
            }
          },
          "pairing_suggestions": [
            {
              "aesthetic": "Neo-Brutalist Technical",
              "display": "Unbounded",
              "body": "Work Sans",
              "rationale": "Geometric strength meets functional readability"
            },
            {
              "aesthetic": "Sophisticated Editorial",
              "display": "Playfair Display",
              "body": "Crimson Pro",
              "rationale": "High-contrast display with book-quality body"
            },
            {
              "aesthetic": "Developer Tool",
              "display": "Barlow Condensed",
              "body": "JetBrains Mono",
              "rationale": "Efficient condensed with code-optimized mono"
            },
            {
              "aesthetic": "Warm Artisan",
              "display": "Josefin Sans",
              "body": "Lora",
              "rationale": "Vintage display character with warm serif body"
            },
            {
              "aesthetic": "Retro Synthwave",
              "display": "Righteous",
              "body": "Space Mono",
              "rationale": "80s futurism with retro-tech monospace"
            }
          ]
        }
        
      • game-ui-polish.md 6.3 KB
        # Game UI Polish Reference
        
        Use this reference when the work mentions game UI, AAA game polish, Steam game polish, roguelike UI, Slay the Spire, deckbuilder UI, or when a user says an interface feels like a website instead of a game.
        
        ## Core Thesis
        
        Polished game UI does not come from more gradients, heavier chrome, or “premium” colors. It comes from a coherent interaction metaphor, disciplined hierarchy, and surfaces that feel authored as part of the game world. When a game UI feels wrong, first remove web-product idioms before adding decoration.
        
        For game surfaces, the goal is not “beautiful panel.” The goal is “a screen the game would have shipped.”
        
        ## First Diagnostic Question
        
        Ask: would a shipped game like Slay the Spire build this exact surface?
        
        If the honest answer is no, do not tune the palette. Identify the structural web idiom:
        
        - Nested rounded containers around every group.
        - Gradient panels used to imply polish.
        - Badges and pills explaining state.
        - Instructional text explaining obvious interactions.
        - Form-like labels and helper text everywhere.
        - Dashboard/card mosaics where every item is boxed.
        - Decorative glow, shine, blur, or texture competing with readability.
        - Multiple accent hues used because the screen lacks hierarchy.
        
        Fix those before color, typography, or asset generation.
        
        ## AAA / Steam Polish Heuristics
        
        High-polish game UI usually has these properties:
        
        - One composed screen, not a stack of web sections.
        - A small number of strong materials, reused consistently.
        - Flat or lightly painted surfaces with intentional edges.
        - Selection state shown by position, value, highlight strip, silhouette, or icon, not by a full bordered card treatment.
        - Text hierarchy that behaves like game signage: short headers, object names, numbers, terse mechanical labels.
        - Interaction objects carry the visual weight; containers stay quiet.
        - The background supports focus rather than proving visual effort.
        - Fewer simultaneous effects than a website mockup. Restraint reads expensive.
        
        ## Layout Rules
        
        Prefer:
        
        - One shell or board per screen.
        - Rows, columns, dividers, tabs, silhouettes, and object placement.
        - Spatial grouping instead of nested cards.
        - One persistent preview/summary region if the interaction needs it.
        - Selection marks: left strip, lift, glow, check, slot fill, or value change.
        
        Prefer:
        
        - A single screen shell instead of a modal layered with headers, panels, cards, and badges.
        - Framing only where the object itself needs emphasis.
        - Compact labels for small attributes instead of pill treatments.
        - Visual variety in borders and materials instead of repeated identical borders.
        - One background language per screen.
        - Sequence labels only when the game fiction genuinely calls for a staged flow.
        
        ## Color Rules
        
        Game UI can use any palette, but hierarchy must be obvious.
        
        - Choose material first, then color. Example: parchment, steel, canvas, leather, arcade plastic, CRT glass, blueprint paper.
        - Use gold only when the fiction genuinely makes it meaningful.
        - Use brown only with strong material contrast and deliberate art direction.
        - Reserve pink/red fades for cases where they support readability instead of generic drama.
        - Accent color should mark state or action, not decorate every border.
        - If color is doing too much, remove surfaces instead of inventing a better palette.
        
        ## Texture And Gradient Rules
        
        Gradients are not banned, but they must have a job.
        
        Acceptable jobs:
        
        - Lighting a scene.
        - Separating foreground from background.
        - Simulating a specific material.
        - Directing attention to the primary action.
        
        Bad jobs:
        
        - Making an empty rectangle feel premium.
        - Decorating every panel.
        - Hiding weak hierarchy.
        - Adding “AAA vibes” without a game-world reason.
        
        Default to flat material plus one restrained lighting layer. If every box has a gradient, the screen will feel like a website.
        
        ## Typography And Text Budget
        
        Game UI text should be terse.
        
        - One screen title, usually 1-3 words.
        - Option names and mechanical payoffs are allowed.
        - Helper sentences should be rare and only solve confusion discovered in play.
        - If the screen still works after removing a sentence, remove it.
        - Do not use badges to narrate state. Make the state visible.
        
        Common cuts:
        
        - “Click to...”
        - “Choose X to...”
        - “Step 1”
        - “Your name appears...”
        - “4 sprite looks”
        - Repeated subtitles under every item.
        
        ## Slay The Spire-Style Deckbuilder Guidance
        
        Slay the Spire-like polish comes from object clarity and low container count.
        
        Prefer:
        
        - Cards, relics, rewards, map nodes, and characters as the primary objects.
        - Dark scrim or simple board behind the objects.
        - Clear hover/selected states.
        - Minimal labels.
        - Layouts where the player understands the interaction from object placement.
        
        Prefer:
        
        - Reward layouts that center the card, relic, or map node as the object.
        - Clear object-first choices instead of explanatory modal sections.
        - Restraint in chrome so the art remains dominant.
        - Material treatments that fit the game world instead of decorative premium gloss.
        
        ## Corrective Workflow
        
        When a user says “this does not feel AAA/polished/game-like,” do this in order:
        
        1. Screenshot the current UI and name the structural failures.
        2. Remove nested boxes and decorative gradients before choosing new colors.
        3. Define the screen metaphor in one sentence: board, hand, poster, locker, map, shop shelf, ring apron, etc.
        4. Rebuild hierarchy with layout and state, not decoration.
        5. Reduce text to object names, mechanical labels, and one screen title.
        6. Reuse existing game assets as objects, not as stickers inside web cards.
        7. Validate with a screenshot and ask the Slay-the-Spire test again.
        
        Do not respond to “make it AAA” by making surfaces shinier. That is the failure mode.
        
        ## Road To AEW Lesson
        
        In Road to AEW, the useful improvement came only after removing:
        
        - Metallic gold “premium” treatment.
        - Pink/red gradient wash.
        - Rounded boxes around every option.
        - Nested modal > panel > card > badge structure.
        - Decorative badge labels.
        
        The better direction was:
        
        - One flat game board.
        - Dark material base.
        - Thin dividers.
        - Generated emblems as actual option objects.
        - Selection as a left accent strip and subtle fill.
        - Fewer gradients and fewer words.
        
        The remaining palette issue was brownness, not structure. This distinction matters: once the structure became game-native, color could be iterated separately.
        
      • honest-placeholders.md 3.2 KB
        # Honest Placeholders
        
        <!-- Loaded by distinctive-frontend-design when task involves placeholders, missing assets, placeholder images, or no imagery available -->
        
        Use striped placeholders instead of bad imagery. When final assets are not available, a striped background with a monospace label communicates "asset needed" without pretending to be the real thing. Bad stock photos or AI-generated illustrations look intentional and create false design decisions — a stakeholder sees them, assumes that is the direction, and the placeholder becomes permanent by inertia.
        
        ## The Striped Placeholder Pattern
        
        ```css
        .placeholder {
          background: repeating-linear-gradient(
            45deg,
            #e5e5e5,
            #e5e5e5 10px,
            #f0f0f0 10px,
            #f0f0f0 20px
          );
          display: flex;
          align-items: center;
          justify-content: center;
          font-family: 'Courier New', monospace;
          font-size: 12px;
          color: #666;
          min-height: 200px;
          border: 1px dashed #ccc;
        }
        ```
        
        ## Usage
        
        Label every placeholder with the asset name and expected dimensions:
        
        ```html
        <div class="placeholder">product hero (1200x800)</div>
        <div class="placeholder">team photo (800x600)</div>
        <div class="placeholder">feature screenshot (640x480)</div>
        ```
        
        The monospace font and dashed border make it visually obvious that this is not a design element. The label tells the asset producer exactly what is needed.
        
        ## Dark Mode Variant
        
        ```css
        [data-theme="dark"] .placeholder,
        .dark .placeholder {
          background: repeating-linear-gradient(
            45deg,
            #2a2a2a,
            #2a2a2a 10px,
            #333333 10px,
            #333333 20px
          );
          color: #999;
          border-color: #444;
        }
        ```
        
        ## Tailwind Utility Version
        
        When using Tailwind, create a utility class or use arbitrary values:
        
        ```html
        <!-- Tailwind placeholder with arbitrary gradient -->
        <div class="flex items-center justify-center min-h-[200px] font-mono text-sm text-gray-500 border border-dashed border-gray-300 bg-[repeating-linear-gradient(45deg,#e5e5e5,#e5e5e5_10px,#f0f0f0_10px,#f0f0f0_20px)]">
          product hero (1200x800)
        </div>
        ```
        
        Or add a custom utility in `tailwind.config.js`:
        
        ```js
        // tailwind.config.js
        module.exports = {
          theme: {
            extend: {},
          },
          plugins: [
            function({ addUtilities }) {
              addUtilities({
                '.bg-placeholder': {
                  background: 'repeating-linear-gradient(45deg, #e5e5e5, #e5e5e5 10px, #f0f0f0 10px, #f0f0f0 20px)',
                },
                '.bg-placeholder-dark': {
                  background: 'repeating-linear-gradient(45deg, #2a2a2a, #2a2a2a 10px, #333333 10px, #333333 20px)',
                },
              })
            },
          ],
        }
        ```
        
        ```html
        <div class="bg-placeholder dark:bg-placeholder-dark flex items-center justify-center min-h-[200px] font-mono text-sm text-gray-500 border border-dashed border-gray-300">
          product hero (1200x800)
        </div>
        ```
        
        ## When to Use
        
        - Hero sections before final photography is ready
        - Product screenshots before the product is built
        - Team photos before the photoshoot
        - Blog post featured images before the graphic designer delivers
        - Any image slot where using a stock photo would create a false design decision
        
        ## When Not to Use
        
        - The asset exists — use the real asset
        - A color block or gradient serves the design intent — use the intentional design element
        - The design calls for an illustration — commission the illustration or use a licensed set
        
      • implementation-examples.md 7.7 KB
        # Implementation Examples
        
        Reference implementations for distinctive frontend design specifications.
        
        ---
        
        ## CSS Design Tokens
        
        Complete CSS custom properties template derived from design specification output.
        
        ```css
        /* design-tokens.css */
        
        :root {
          /* Typography */
          --font-display: "Unbounded", sans-serif;
          --font-heading: "Crimson Pro", serif;
          --font-body: "Crimson Pro", serif;
          --font-mono: "JetBrains Mono", monospace;
        
          /* Font Sizes (fluid typography) */
          --text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
          --text-sm: clamp(0.875rem, 0.8rem + 0.375vw, 1rem);
          --text-base: clamp(1rem, 0.925rem + 0.375vw, 1.125rem);
          --text-lg: clamp(1.125rem, 1rem + 0.625vw, 1.5rem);
          --text-xl: clamp(1.5rem, 1.25rem + 1.25vw, 2.25rem);
          --text-2xl: clamp(2rem, 1.5rem + 2.5vw, 3.5rem);
          --text-3xl: clamp(2.5rem, 1.75rem + 3.75vw, 5rem);
        
          /* Font Weights */
          --weight-normal: 400;
          --weight-medium: 500;
          --weight-semibold: 600;
          --weight-bold: 700;
          --weight-extrabold: 800;
        
          /* Colors - Dominant */
          --surface: #E8E6E3;
          --surface-dark: #D1CFC8;
          --surface-darker: #B8B6B0;
        
          /* Colors - Secondary */
          --container: #3A3A3A;
          --container-light: #5C5C5C;
          --container-lighter: #7A7A7A;
        
          /* Colors - Accent */
          --accent: #FFDE00;
          --accent-hover: #FFE94D;
          --accent-dark: #CDB100;
        
          /* Colors - Functional */
          --success: #00C853;
          --warning: #FF6D00;
          --error: #D32F2F;
          --info: #0091EA;
        
          /* Spacing (geometric scale) */
          --space-xs: 0.25rem;
          --space-sm: 0.5rem;
          --space-md: 1rem;
          --space-lg: 1.5rem;
          --space-xl: 2rem;
          --space-2xl: 3rem;
          --space-3xl: 4.5rem;
        
          /* Border Radius */
          --radius-sm: 0.25rem;
          --radius-md: 0.5rem;
          --radius-lg: 1rem;
        
          /* Shadows */
          --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
          --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.1);
          --shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.15);
        
          /* Animation */
          --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
          --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
          --ease-in: cubic-bezier(0.4, 0, 1, 1);
          --ease-elastic: cubic-bezier(0.68, -0.55, 0.265, 1.55);
        
          --duration-fast: 150ms;
          --duration-normal: 300ms;
          --duration-slow: 500ms;
          --duration-stagger: 150ms;
        }
        ```
        
        ---
        
        ## Base Styles
        
        ```css
        /* base.css */
        
        * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        
        body {
          font-family: var(--font-body);
          font-size: var(--text-base);
          font-weight: var(--weight-normal);
          line-height: 1.6;
          color: var(--container);
        
          /* Atmospheric background */
          background:
            radial-gradient(
              ellipse 80% 50% at 50% 0%,
              rgba(255, 222, 0, 0.08),
              transparent
            ),
            repeating-linear-gradient(
              90deg,
              transparent,
              transparent 99px,
              rgba(58, 58, 58, 0.02) 99px,
              rgba(58, 58, 58, 0.02) 100px
            ),
            var(--surface);
          min-height: 100vh;
        }
        
        h1, h2, h3, h4, h5, h6 {
          font-family: var(--font-heading);
          font-weight: var(--weight-bold);
          line-height: 1.2;
          margin-bottom: var(--space-md);
        }
        
        h1 {
          font-family: var(--font-display);
          font-size: var(--text-3xl);
          font-weight: var(--weight-extrabold);
        }
        
        h2 { font-size: var(--text-2xl); }
        h3 { font-size: var(--text-xl); }
        
        code, pre {
          font-family: var(--font-mono);
          font-size: 0.9em;
        }
        ```
        
        ---
        
        ## React + Tailwind CSS
        
        ```javascript
        // tailwind.config.js
        module.exports = {
          theme: {
            extend: {
              fontFamily: {
                display: ['Unbounded', 'sans-serif'],
                heading: ['Crimson Pro', 'serif'],
                body: ['Crimson Pro', 'serif'],
                mono: ['JetBrains Mono', 'monospace'],
              },
              colors: {
                surface: {
                  DEFAULT: '#E8E6E3',
                  dark: '#D1CFC8',
                  darker: '#B8B6B0',
                },
                container: {
                  DEFAULT: '#3A3A3A',
                  light: '#5C5C5C',
                  lighter: '#7A7A7A',
                },
                accent: {
                  DEFAULT: '#FFDE00',
                  hover: '#FFE94D',
                  dark: '#CDB100',
                },
              },
              animation: {
                'slide-up': 'slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both',
              },
              keyframes: {
                'slide-up': {
                  'from': { opacity: '0', transform: 'translateY(30px)' },
                  'to': { opacity: '1', transform: 'translateY(0)' },
                },
              },
            },
          },
        };
        ```
        
        ---
        
        ## HTML Starter Template
        
        ```html
        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Project Name</title>
          <link rel="preconnect" href="https://fonts.googleapis.com">
          <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
          <link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@600;800&family=Crimson+Pro:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
          <link rel="stylesheet" href="design-tokens.css">
          <link rel="stylesheet" href="base.css">
          <link rel="stylesheet" href="styles.css">
        </head>
        <body>
          <section class="hero">
            <h1 class="hero-title">Your Distinctive Title</h1>
            <p class="hero-subtitle">A compelling subtitle that sets the tone</p>
            <button class="hero-cta">Get Started</button>
          </section>
        </body>
        </html>
        ```
        
        ---
        
        ## Typography Specification Template
        
        ```json
        {
          "display_font": {
            "family": "Unbounded",
            "weights": [600, 800],
            "usage": "Hero headings, page titles, brand moments",
            "rationale": "Geometric but distinctive, technical feel without being corporate"
          },
          "heading_font": {
            "family": "Crimson Pro",
            "weights": [500, 700],
            "usage": "Section headers, card titles",
            "rationale": "Serif contrast creates sophistication, prevents monotony"
          },
          "body_font": {
            "family": "Crimson Pro",
            "weights": [400, 600],
            "usage": "Body text, UI labels",
            "rationale": "Readable serif that feels crafted, not default"
          },
          "mono_font": {
            "family": "JetBrains Mono",
            "weights": [400, 500],
            "usage": "Code snippets, technical data",
            "rationale": "Purpose-built for code, distinctive ligatures"
          }
        }
        ```
        
        ---
        
        ## Color Palette Template
        
        ```json
        {
          "palette_name": "Concrete & Voltage",
          "inspiration": "Brutalist architecture + high-voltage warning signs",
          "dominant": {
            "surface": "#E8E6E3",
            "surface_dark": "#D1CFC8",
            "rationale": "Warm concrete gray, not sterile white"
          },
          "secondary": {
            "container": "#3A3A3A",
            "container_light": "#5C5C5C",
            "rationale": "Charcoal for depth, avoids pure black"
          },
          "accent": {
            "primary": "#FFDE00",
            "primary_hover": "#FFE94D",
            "rationale": "Industrial yellow, high-voltage energy"
          },
          "functional": {
            "success": "#00C853",
            "warning": "#FF6D00",
            "error": "#D32F2F",
            "info": "#0091EA"
          }
        }
        ```
        
        ---
        
        ## Aesthetic Direction Template
        
        ```json
        {
          "project_name": "example-app",
          "primary_direction": "Neo-Brutalist Technical",
          "secondary_direction": "Warm Artisan",
          "context_summary": "Developer tool targeting technical audience, needs to feel powerful yet approachable",
          "emotional_goals": ["confidence", "clarity", "craftsmanship"],
          "cultural_context": "Modern software development culture"
        }
        ```
        
        ---
        
        ## Design Specification Document Template
        
        ```markdown
        # Design Specification: [Project Name]
        
        ## Aesthetic Direction
        **Primary**: [Direction Name]
        **Inspiration**: [Source]
        **Emotional Goals**: [List]
        
        ## Typography
        - **Display**: [Font] (weights) - [usage]
        - **Headings**: [Font] (weights) - [usage]
        - **Body**: [Font] (weights) - [usage]
        - **Monospace**: [Font] (weights) - [usage]
        
        ## Color Palette
        **Name**: [Palette Name]
        - Dominant (60-70%): [colors + rationale]
        - Secondary (20-30%): [colors + rationale]
        - Accent (5-10%): [colors + rationale]
        - Functional: success, warning, error, info
        
        ## Animation Strategy
        [High-impact moments + timing]
        
        ## Backgrounds
        [Technique + layers]
        
        ## Validation Score: [X]/100 (Grade [X])
        ```
        
      • macrostructure-catalog.md 9.2 KB
        # Macrostructure Catalog
        
        Named page structures. Pick exactly one `macro:*` id in Phase 1, before aesthetic
        directions, and emit it as the `macrostructure` field in the spec. The macrostructure
        is the categorical shape of the page — the order and role of its sections — chosen
        before any color, type, or motion decision. Choosing structure first prevents every
        page from collapsing into the same hero-then-three-cards template.
        
        These are OUR names, deliberately distinct from any external catalog and from the
        component terms in `vocabulary.md` (Hero, Full-bleed, Surface type). Those describe
        parts; these describe whole-page skeletons.
        
        **Lazy loading:** load only the one entry you picked, addressed by its heading anchor
        (e.g. `#macrostat-led`). Reading the whole catalog wastes context — the chosen entry
        carries every rule the build needs.
        
        **Variety rule:** `validate_design.py` penalizes a macrostructure that matches either
        of the last two projects (mirrors the font/palette step-down). Vary the structural
        axis across consecutive builds, not just the colors.
        
        | id | one-line when-to-use |
        |---|---|
        | `macro:stat-led` | a single number or proof point is the whole argument |
        | `macro:long-document` | dense reading: docs, essays, legal, changelogs |
        | `macro:bento` | many small features of unequal weight, shown at a glance |
        | `macro:manifesto` | a point of view to argue, not a product to sell |
        | `macro:split-hero` | product and its proof sit side by side, equally weighted |
        | `macro:gallery-grid` | the work is visual and the images are the pitch |
        | `macro:timeline` | sequence or progression is the core idea |
        | `macro:comparison` | the decision is "this versus that" |
        | `macro:single-focus` | one action, nothing else; capture or convert |
        | `macro:dashboard` | an operator surface for live data and controls |
        
        ---
        
        ## macro:stat-led
        
        **When to use:** one metric, result, or proof point carries the entire argument
        (uptime, dollars saved, users served). Lead with the number, justify it after.
        
        **Layout skeleton:**
        1. Oversized stat block — the figure set larger than the headline, with a one-line claim under it.
        2. Context strip — two or three supporting figures or a short sentence that frames the big number.
        3. Evidence — how the number is earned (method, source, customer).
        4. Narrative body — the story behind the figure, scannable.
        5. Single call to action tied to the claim.
        
        **Anti-cliche note:** the stat must be a real measured figure, set as type, not a
        spinning odometer or count-up animation on scroll. A fabricated round number ("10x
        faster") with no source reads as filler; show the actual figure and where it came from.
        
        ---
        
        ## macro:long-document
        
        **When to use:** the value is in dense, sustained reading — documentation, an essay,
        legal text, a detailed changelog. Comprehension and navigation beat visual drama.
        
        **Layout skeleton:**
        1. Title block — title, one-line summary, last-updated date.
        2. Sticky table of contents (sidebar on wide screens, collapsible on mobile).
        3. Body sections with stable heading anchors and generous measure (60-75ch).
        4. Inline asides or callouts for warnings and tips.
        5. Prev/next or related-document footer.
        
        **Anti-cliche note:** resist breaking a reading page into floating cards or a grid.
        Long-form reading wants one calm column with strong typographic hierarchy, not a
        dashboard of boxes. Line length and vertical rhythm do the work here.
        
        ---
        
        ## macro:bento
        
        **When to use:** several features of unequal importance, shown together so the eye
        grasps the whole offering at a glance. The grid itself communicates relative weight.
        
        **Layout skeleton:**
        1. Short framing headline.
        2. Bento grid — tiles of mixed sizes; the largest tile holds the lead feature, smaller tiles hold secondary ones.
        3. Each tile: one icon-or-visual, a short label, one sentence. One job per tile.
        4. Single closing action below the grid.
        
        **Anti-cliche note:** vary tile sizes to express priority — a uniform grid of equal
        cards is the templated look this structure exists to avoid. If every tile is the same
        size, it is a card grid, not a bento. Give the lead feature visibly more room.
        
        ---
        
        ## macro:manifesto
        
        **When to use:** there is a point of view to argue, a stance to take, a belief to
        state — not a product to demo. Editorial weight over conversion mechanics.
        
        **Layout skeleton:**
        1. Statement — a single bold claim, full width, set large.
        2. Argument sections — short numbered or titled positions, each one paragraph.
        3. Pull quotes or emphasized lines that carry the through-line.
        4. Signature or attribution.
        5. One quiet action (subscribe, read more) — low-pressure.
        
        **Anti-cliche note:** keep chrome minimal. Manifestos lose force when wrapped in
        product-marketing furniture (badge rows, logo walls, "trusted by"). The words are the
        design; let typography and whitespace carry the conviction.
        
        ---
        
        ## macro:split-hero
        
        **When to use:** the product and its proof deserve equal weight in the first viewport —
        a screenshot beside the pitch, a demo beside the claim. Neither dominates.
        
        **Layout skeleton:**
        1. Two-column first viewport: left holds headline, subhead, action; right holds the product visual or live demo.
        2. Below the fold: alternating split rows, each pairing one claim with one supporting visual.
        3. Proof band — logos or a single testimonial, full width.
        4. Closing action.
        
        **Anti-cliche note:** the right-hand visual must be real product, not a generic
        floating-laptop mockup or abstract gradient blob. A split hero with a stock device
        frame and placeholder UI signals the page has nothing real to show.
        
        ---
        
        ## macro:gallery-grid
        
        **When to use:** the work is inherently visual — photography, design, art, physical
        product — and the images themselves are the argument. Text is caption, not pitch.
        
        **Layout skeleton:**
        1. Minimal title bar — name and one line, nothing more.
        2. Image grid or masonry — the dominant element; let images run large.
        3. Optional filter or category strip.
        4. Per-item detail on click or hover — title, short note.
        5. Contact or inquiry action in the footer.
        
        **Anti-cliche note:** let the images breathe at full strength; avoid burying them under
        heavy overlays, uniform hover-zoom, or text scrims on every tile. If the grid needs
        explanatory copy on each image to make sense, the images are not carrying their job.
        
        ---
        
        ## macro:timeline
        
        **When to use:** sequence or progression is the core idea — a roadmap, a history, a
        process, a journey. The order of events is the message.
        
        **Layout skeleton:**
        1. Framing headline naming the span (years, stages, steps).
        2. Spine — a vertical or horizontal axis with dated or numbered nodes.
        3. Each node: a moment with a short title, one-line description, optional visual.
        4. Direction is unmistakable (top-to-bottom or left-to-right, never both at once).
        5. Closing node that points at the present or the next step.
        
        **Anti-cliche note:** the sequence must carry real meaning — dates, ordered stages,
        causal steps. A timeline used to decorate unordered features (where the order is
        arbitrary) misleads the reader; use `macro:bento` for unordered features instead.
        
        ---
        
        ## macro:comparison
        
        **When to use:** the reader's decision is "this versus that" — plans, us-versus-them,
        before-and-after. The structure exists to make the difference legible.
        
        **Layout skeleton:**
        1. Headline naming the choice.
        2. Comparison table or paired columns — shared rows, one column per option.
        3. Each row is one dimension; differences are visually marked, not just listed.
        4. A recommended or highlighted option, clearly but fairly indicated.
        5. Action per option.
        
        **Anti-cliche note:** keep the comparison honest. A table engineered so one column wins
        every row (strawman competitor, cherry-picked rows) reads as manipulative and erodes
        trust. Show real trade-offs; a fair comparison persuades better than a rigged one.
        
        ---
        
        ## macro:single-focus
        
        **When to use:** exactly one action matters — sign up, download, book, subscribe.
        Everything on the page serves that one conversion; nothing competes with it.
        
        **Layout skeleton:**
        1. One headline stating the single promise.
        2. One supporting line.
        3. One form or one button — the only interactive focus.
        4. Minimal proof (one line, one logo row) only if it lifts conversion.
        5. No secondary navigation that leads away.
        
        **Anti-cliche note:** resist adding a second competing call to action or a full nav bar
        — they leak attention from the one job. If a second action feels necessary, the page is
        probably trying to be two pages; split it or pick the one action that matters most.
        
        ---
        
        ## macro:dashboard
        
        **When to use:** an operator surface — live data, controls, status. Function over
        persuasion. Follows app rules, not landing-page rules (see `app-vs-landing-rules.md`).
        
        **Layout skeleton:**
        1. Top bar — product, primary context switcher, account.
        2. Left navigation — sections of the app.
        3. Main region — data tables, charts, or the primary work surface, dense but readable.
        4. Detail panel or drawer for the selected item.
        5. Status and system feedback in a consistent, quiet location.
        
        **Anti-cliche note:** apply Linear-style restraint — calm surfaces, few colors, strong
        typography, tight spacing. Decorative gradients, marketing-style hero blocks, and card
        mosaics belong on landing pages; on a dashboard they add noise and hide the data the
        operator came for.
        
      • oklch-color-harmony.md 7.2 KB
        # oklch() Color Harmony
        
        <!-- Loaded by distinctive-frontend-design when task involves color palette, harmony, oklch, perceptual color, or color generation -->
        
        Build harmonious palettes with oklch(). The oklch() color space is perceptually uniform: the same lightness (L) and chroma (C) values produce visually balanced colors regardless of hue (H). This makes it the right tool for generating palette companions — fix L and C, vary only H, and every resulting color looks like it belongs in the same family.
        
        ## Why oklch Beats HSL
        
        HSL's "50% lightness" produces wildly different perceived brightness across hues. Yellow at `hsl(60, 100%, 50%)` looks bright and light. Blue at `hsl(240, 100%, 50%)` looks dark and heavy. They are mathematically equivalent in HSL and perceptually incompatible on screen.
        
        oklch fixes this. `oklch(55% 0.15 60)` (yellow region) and `oklch(55% 0.15 240)` (blue region) have the same perceived brightness. This means you can rotate the hue wheel and every stop produces a color that pairs naturally with the others.
        
        ```css
        /* HSL: these "match" mathematically but look unbalanced */
        --yellow: hsl(60, 100%, 50%);   /* appears very light */
        --blue:   hsl(240, 100%, 50%);  /* appears very dark */
        
        /* oklch: these actually match perceptually */
        --yellow: oklch(70% 0.15 90);   /* warm, balanced */
        --blue:   oklch(70% 0.15 250);  /* cool, same visual weight */
        ```
        
        ## Core Technique: Fix L and C, Vary H
        
        The foundation of oklch harmony: pick a lightness and chroma, then select hues based on their angular relationship on the color wheel.
        
        ```css
        :root {
          --primary:   oklch(55% 0.15 250);  /* blue */
          --secondary: oklch(55% 0.15 200);  /* teal */
          --accent:    oklch(55% 0.15 330);  /* rose */
        }
        ```
        
        All three colors share the same visual weight. They harmonize because their only difference is hue angle.
        
        ## Harmony Types
        
        ### Analogous Harmony (Hues Within 30 Degrees)
        
        Colors that sit next to each other on the wheel. Produces calm, cohesive palettes with low contrast between hues. Best for backgrounds, navigation, and surfaces that should feel unified.
        
        ```css
        /* Analogous: ocean palette */
        --deep-sea:   oklch(40% 0.12 230);  /* blue */
        --coastal:    oklch(40% 0.12 210);  /* blue-teal, 20deg away */
        --lagoon:     oklch(40% 0.12 250);  /* blue-indigo, 20deg away */
        ```
        
        ### Complementary Harmony (Hues 180 Degrees Apart)
        
        Maximum contrast between two hues. Creates energy and visual tension. Use for primary/accent pairs where the accent needs to pop against the primary.
        
        ```css
        /* Complementary: corporate with punch */
        --primary: oklch(45% 0.14 250);  /* blue */
        --accent:  oklch(60% 0.18 70);   /* warm orange, 180deg away */
        ```
        
        Note: the accent uses higher lightness (60% vs 45%) and higher chroma (0.18 vs 0.14) because accents need to draw the eye. Exact L/C matching is the starting point, not the final answer — adjust for visual hierarchy after establishing the base relationship.
        
        ### Triadic Harmony (Hues 120 Degrees Apart)
        
        Three colors evenly spaced around the wheel. Balanced energy without the tension of complementary. Good for UI that needs three distinct functional zones.
        
        ```css
        /* Triadic: balanced three-color system */
        --primary:   oklch(50% 0.15 250);  /* blue */
        --secondary: oklch(50% 0.15 10);   /* red-orange, 120deg */
        --tertiary:  oklch(50% 0.15 130);  /* green, 240deg */
        ```
        
        ### Split Complementary (Hue + Two Neighbors of Its Complement)
        
        Take one hue, find its complement (180deg away), then use the two hues 30deg on either side of that complement. Provides contrast without the intensity of pure complementary.
        
        ```css
        /* Split complementary: sophisticated contrast */
        --primary: oklch(50% 0.14 250);  /* blue */
        --warm-a:  oklch(55% 0.16 40);   /* complement neighbor: warm red */
        --warm-b:  oklch(55% 0.16 100);  /* complement neighbor: warm yellow-green */
        ```
        
        ## Lightness Roles: Text vs Background
        
        Lightness determines where a color works in the layout:
        
        | Role | Lightness Range | Example |
        |------|----------------|---------|
        | Text on light background | L < 40% | `oklch(25% 0.08 250)` |
        | Interactive elements (buttons, links) | L: 40-60% | `oklch(50% 0.15 250)` |
        | Borders, dividers | L: 60-75% | `oklch(70% 0.06 250)` |
        | Subtle tints, hover backgrounds | L: 85-93% | `oklch(90% 0.03 250)` |
        | Page backgrounds | L > 93% | `oklch(97% 0.005 250)` |
        
        ```css
        /* Full blue scale from one hue, varying only L and C */
        :root {
          --blue-text:    oklch(25% 0.08 250);
          --blue-primary: oklch(50% 0.15 250);
          --blue-border:  oklch(72% 0.06 250);
          --blue-hover:   oklch(92% 0.03 250);
          --blue-surface: oklch(97% 0.005 250);
        }
        ```
        
        ## Dark Mode Inversion
        
        The dark mode technique: swap lightness values while keeping hue and chroma. Text goes light, backgrounds go dark, interactive elements shift toward the middle.
        
        ```css
        /* Light mode */
        :root {
          --text:    oklch(20% 0.02 250);
          --surface: oklch(97% 0.005 250);
          --primary: oklch(50% 0.15 250);
        }
        
        /* Dark mode: invert L, keep H and C */
        [data-theme="dark"] {
          --text:    oklch(92% 0.02 250);   /* was 20%, now 92% */
          --surface: oklch(15% 0.02 250);   /* was 97%, now 15% */
          --primary: oklch(65% 0.15 250);   /* bumped from 50% for contrast on dark */
        }
        ```
        
        The primary color shifts from 50% to 65% lightness in dark mode because interactive elements need more lightness to maintain contrast against dark surfaces. The exact bump depends on the surface darkness — test with a contrast checker.
        
        ## Chroma Guidelines
        
        Chroma (C) controls color intensity. Higher chroma = more saturated, vivid color. Lower chroma = more muted, greyed.
        
        | Chroma Range | Character | Use For |
        |-------------|-----------|---------|
        | 0.00 - 0.02 | Nearly neutral | Backgrounds, surfaces, body text |
        | 0.03 - 0.08 | Subtle tint | Tinted surfaces, subtle borders, muted states |
        | 0.10 - 0.18 | Vivid, clean | Primary colors, buttons, links, accents |
        | 0.20+ | Very saturated | Use sparingly — badges, alerts, error states |
        
        Warning: high-chroma colors at extreme lightness values (very light or very dark) may fall outside the sRGB gamut. Check with `color-gamut: srgb` media query or test in browsers that show gamut warnings.
        
        ## Building a Complete Palette
        
        Starting from a brand blue of `oklch(50% 0.15 250)`, build a full palette:
        
        ```css
        :root {
          /* Primary: brand blue */
          --primary:       oklch(50% 0.15 250);
          --primary-hover: oklch(45% 0.15 250);
          --primary-light: oklch(92% 0.03 250);
        
          /* Secondary: analogous (30deg away) */
          --secondary:       oklch(50% 0.12 220);
          --secondary-hover: oklch(45% 0.12 220);
        
          /* Accent: complementary (180deg away) */
          --accent:       oklch(58% 0.18 70);
          --accent-hover: oklch(52% 0.18 70);
        
          /* Neutrals: same hue, minimal chroma */
          --text:         oklch(20% 0.015 250);
          --text-muted:   oklch(45% 0.01 250);
          --border:       oklch(80% 0.01 250);
          --surface:      oklch(97% 0.005 250);
          --surface-alt:  oklch(94% 0.008 250);
        
          /* Functional: fixed hues, consistent L/C */
          --success: oklch(55% 0.16 145);
          --warning: oklch(65% 0.16 85);
          --error:   oklch(55% 0.18 25);
          --info:    oklch(55% 0.14 250);
        }
        ```
        
        The neutrals carry a whisper of the primary hue (H: 250) with near-zero chroma. This tints the greys ever so slightly toward blue, creating a cohesive feel. Pure grey (`oklch(x% 0 0)`) is fine but feels disconnected from the rest of the palette.
        
      • performance-budgets.md 10.6 KB
        # Animation Performance Budgets Reference
        
        > **Scope**: Concrete frame budgets, CSS property render costs, layout thrashing detection, and performance failure modes for CSS animations and transitions.
        > **Version range**: CSS3+, Chrome/Safari/Firefox (2022+), applies to all animation strategies
        > **Generated**: 2026-04-16 — compositor-thread promotion behavior is stable across modern browsers
        
        ---
        
        ## Overview
        
        Animation performance lives or dies on one constraint: the browser has ~16ms per frame at 60fps, ~11ms at 90fps. CSS animations that trigger layout or paint run on the main thread and steal from that budget. Animations that only affect `transform` and `opacity` run on the compositor thread and cost essentially nothing. The single most impactful performance decision is choosing the right CSS property.
        
        ---
        
        ## Pattern Table: CSS Property Render Cost
        
        | Property | Render Path | Thread | Use For |
        |----------|-------------|--------|---------|
        | `transform: translate/scale/rotate` | Composite only | Compositor | All movement, scaling, rotation |
        | `opacity` | Composite only | Compositor | Fades, reveals, stagger effects |
        | `filter: blur/brightness` | Paint + Composite | Main | Glow effects — use sparingly |
        | `background-color` | Paint | Main | Avoid animating — use opacity overlay instead |
        | `width` / `height` | Layout + Paint + Composite | Main | Never animate — use `transform: scale` |
        | `top` / `left` / `margin` | Layout + Paint + Composite | Main | Never animate — use `transform: translate` |
        | `box-shadow` | Paint + Composite | Main | Costly — animate opacity of a pseudo-element instead |
        | `border-radius` | Paint | Main | Acceptable for slow UI transitions (> 300ms) |
        | `clip-path` | Paint + Composite | Main | Expensive on large elements — test on target hardware |
        
        ---
        
        ## Correct Patterns
        
        ### Compositor-Safe Movement (transform instead of top/left)
        
        All movement animations must use `transform: translate()`, not `top`/`left`/`margin`. This is the highest-impact performance rule.
        
        ```css
        /* ✅ Compositor thread — zero layout cost */
        .hero-title {
          transform: translateY(24px);
          opacity: 0;
          animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        
        @keyframes slide-up {
          to { transform: translateY(0); opacity: 1; }
        }
        
        /* ❌ Main thread layout — causes jank on mid-range devices */
        .hero-title {
          position: relative;
          top: 24px;
          animation: slide-up 0.8s ease both;
        }
        @keyframes slide-up {
          to { top: 0; }
        }
        ```
        
        **Why**: `transform` does not affect document flow — the compositor can run it on the GPU without touching the DOM layout tree. `top` triggers layout recalculation on every frame.
        
        ---
        
        ### GPU Layer Promotion with `will-change`
        
        Use `will-change` only on elements that will actually animate, and remove it after the animation completes.
        
        ```css
        /* ✅ Promote before animation, demote after */
        .hero-title {
          will-change: transform, opacity;
          animation: slide-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        
        /* Remove after — excessive will-change wastes GPU memory */
        .hero-title.animation-complete {
          will-change: auto;
        }
        ```
        
        **Why**: `will-change` tells the browser to create a new compositor layer ahead of time. Without it, the browser creates the layer at animation start, causing a frame spike. But every active `will-change` layer consumes GPU memory — leaving it on all elements exhausts the budget on low-end devices.
        
        ---
        
        ### Animating box-shadow via Pseudo-Element
        
        `box-shadow` triggers paint on every frame. For hover glow effects, animate the opacity of a pre-rendered pseudo-element instead.
        
        ```css
        /* ✅ Pre-renders shadow, animates only opacity (compositor) */
        .cta-button {
          position: relative;
        }
        .cta-button::after {
          content: '';
          position: absolute;
          inset: 0;
          border-radius: inherit;
          box-shadow: 0 0 32px rgba(99, 102, 241, 0.6);
          opacity: 0;
          transition: opacity 0.3s ease;
        }
        .cta-button:hover::after {
          opacity: 1;
        }
        
        /* ❌ Animates box-shadow directly — paint on every frame */
        .cta-button {
          box-shadow: none;
          transition: box-shadow 0.3s ease;
        }
        .cta-button:hover {
          box-shadow: 0 0 32px rgba(99, 102, 241, 0.6);
        }
        ```
        
        ---
        
        ## Pattern Catalog
        <!-- no-pair-required: section-header-only; pairs live in each sub-section below -->
        
        ### Use transform Instead of Layout Properties for Animation
        
        **Detection**:
        ```bash
        # Find @keyframes blocks that animate layout properties
        grep -rn 'width\|height\|top:\|left:\|margin\|padding' --include="*.css" --include="*.scss" -A2 | grep -B2 '%\|from\|to'
        
        # More targeted: find keyframe blocks with layout properties
        rg '@keyframes' --type css -A 20 | grep -E '(width|height|top:|left:|margin|padding):'
        
        # Find transition on layout properties
        grep -rn 'transition:.*\(width\|height\|top\|left\|margin\)' --include="*.css" --include="*.scss"
        rg 'transition-property:\s*(width|height|top|left|margin)' --type css
        ```
        
        **Signal**:
        ```css
        @keyframes expand {
          from { width: 0; height: 0; }
          to   { width: 200px; height: 200px; }
        }
        .card {
          transition: margin-top 0.3s ease; /* layout shift on hover */
        }
        ```
        
        **Why this matters**: Layout properties trigger the full pipeline (Layout, Paint, Composite) on every frame. At 60fps this means 60 layout recalculations per second. On mid-range mobile this consistently drops below 30fps and causes visible jank.
        
        **Preferred action**: Replace layout properties with their `transform` equivalents. `transform: scale` replaces `width`/`height` expansion; `transform: translate` replaces `top`/`left` movement:
        ```css
        @keyframes expand {
          from { transform: scale(0); }
          to   { transform: scale(1); }
        }
        .card {
          transition: transform 0.3s ease; /* composite only */
        }
        ```
        
        ---
        
        ### Animate Opacity of Pre-Rendered Pseudo-Elements for Color
        
        **Detection**:
        ```bash
        # Find transitions/animations on color properties
        grep -rn 'transition:.*color\b\|transition-property:\s*color\b' --include="*.css" --include="*.scss"
        grep -rn 'transition:.*background-color\|transition-property:\s*background-color' --include="*.css" --include="*.scss"
        
        # Find color in keyframes
        rg '@keyframes' --type css -A 15 | grep -E 'color:|background-color:'
        ```
        
        **Signal**:
        ```css
        .nav-link {
          transition: color 0.2s ease, background-color 0.2s ease;
        }
        ```
        
        **Why this matters**: Both `color` and `background-color` trigger paint. For short transitions (under 200ms) this is acceptable on desktop. On mobile, or for elements that appear many times on the page (nav items, list rows), it causes accumulated paint cost.
        
        **Preferred action**: Animate the opacity of a positioned pseudo-element that contains the colored state. The pseudo-element is pre-rendered; only its opacity changes, keeping the transition on the compositor:
        ```css
        /* Animate a positioned pseudo-element's opacity instead of the text color */
        .nav-link {
          position: relative;
        }
        .nav-link::before {
          content: attr(data-hover-text);
          position: absolute;
          color: var(--color-accent);
          opacity: 0;
          transition: opacity 0.2s ease;
        }
        .nav-link:hover::before { opacity: 1; }
        
        /* Or accept the paint cost for subtle nav transitions; not every hover needs optimization */
        ```
        
        ---
        
        ### Declare Explicit Transition Properties
        
        **Detection**:
        ```bash
        # Find transition: all declarations
        grep -rn 'transition:\s*all\b' --include="*.css" --include="*.scss" --include="*.module.css"
        rg 'transition:\s*all\b' --type css
        ```
        
        **Signal**:
        ```css
        .card {
          transition: all 0.3s ease; /* animates everything that changes */
        }
        ```
        
        **Why this matters**: `transition: all` watches every animatable property. Adding a layout-triggering property later (even indirectly through a media query) silently introduces jank. It also animates properties you never intended to animate (e.g., `display`, `visibility`, inherited colors).
        
        **Preferred action**: Declare only the compositor-safe properties you actually want to animate. Explicit lists are self-documenting and immune to accidental layout animations:
        ```css
        .card {
          transition: transform 0.3s ease, opacity 0.2s ease; /* explicit, compositor-safe */
        }
        ```
        
        ---
        
        ### Scope will-change to Actively Animating Elements
        
        **Detection**:
        ```bash
        # Find will-change declarations
        grep -rn 'will-change:' --include="*.css" --include="*.scss" --include="*.module.css"
        rg 'will-change:' --type css
        ```
        
        **Signal**:
        ```css
        * { will-change: transform; }  /* nuclear option — crashes low-end devices */
        .card { will-change: transform, opacity, filter; } /* too many properties */
        ```
        
        **Why this matters**: Each `will-change` layer consumes GPU memory. Blanket use exhausts VRAM on low-end mobile (typically 512MB to 1GB GPU memory budget), causing the browser to fall back to software rendering, which is slower than not using `will-change` at all.
        
        **Preferred action**: Apply `will-change` only to the element immediately before its animation starts, and remove it via JS after the animation ends. Use the AnimationEvent `animationend` listener or a class swap:
        ```js
        el.classList.add('is-animating'); // CSS sets will-change: transform
        el.addEventListener('animationend', () => {
          el.classList.remove('is-animating'); // CSS removes will-change
        }, { once: true });
        ```
        
        ---
        
        ## Error-Fix Mappings
        
        | Symptom | Root Cause | Fix |
        |---------|------------|-----|
        | Jank on scroll (Chrome DevTools shows "Forced reflow") | Animating `top`/`left` or reading `offsetTop` in a rAF loop | Switch to `transform: translate`; use `IntersectionObserver` instead of scroll position reads |
        | Green paint flashes in Chrome "Paint flashing" overlay | `background-color` or `box-shadow` animating | Switch to pseudo-element opacity trick for box-shadow; avoid animating background-color |
        | GPU memory warning in DevTools Layers panel | Too many `will-change` layers active simultaneously | Scope `will-change` to only actively-animating elements |
        | 30fps on mobile but 60fps on desktop | Layout properties animating (`width`/`margin`) | Replace with `transform: scale/translate` equivalent |
        | `transition: all` catching unexpected properties | Media query or JS adds a layout-triggering property to the element | Replace `transition: all` with explicit property list |
        
        ---
        
        ## Quick Frame Budget Reference
        
        | Target FPS | Frame Budget | Comfortable animation budget (half) |
        |------------|-------------|--------------------------------------|
        | 60fps | 16.67ms | ~8ms for JS + CSS combined |
        | 90fps | 11.11ms | ~5ms |
        | 120fps | 8.33ms | ~4ms |
        
        **Rule**: If DevTools Performance panel shows any frame over 16ms during an animation, investigate. Frames over 33ms are visible jank to most users.
        
        ---
        
        ## See Also
        
        - `animation-patterns.md` — choreography patterns with easing curves and timing values
        - `background-techniques.md` — layered gradient recipes (background gradients do not animate, so no paint cost)
        
      • phase-details.md 11.7 KB
        ## Phase Details
        
        ### Phase 1: Aesthetic Direction Examples
        
        Example directions and what they mean:
        - **Neo-Brutalist Technical**: Bold typography, harsh contrasts, geometric precision, industrial textures
        - **Warm Artisan**: Handcrafted feel, organic colors, subtle textures, serif elegance
        - **Midnight Synthwave**: Dark backgrounds, neon accents, retro-futurism, gradient glows
        - **Botanical Minimal**: Natural greens, generous whitespace, serif elegance, organic shapes
        - **Arctic Technical**: Cool blues, sharp geometry, monospace accents, clean precision
        
        ### Phase 1: Narrative Brief Detail
        
        Before any code or visual exploration, commit three sentences to the page:
        
        1. **Visual thesis**: One sentence describing the mood and energy of the page. Example: "A calm, sun-warmed product page for a pottery studio where the ceramics themselves are the loudest element."
        2. **Content plan**: Name the sections in order. For landing pages use Hero, Supporting imagery, Product detail, Social proof, Final CTA. For apps use the top navigation targets and what lives under each. Assign one job to each section, not two.
        3. **Interaction thesis**: Two or three motion ideas. No more. Examples: "hero headline reveals on load with a 600ms stagger", "sticky product image follows the scroll through the detail section", "testimonials cross-fade on hover".
        
        The narrative brief is the design brief. Every later phase is expected to be consistent with it. If a phase is producing choices that contradict the visual thesis or content plan, stop and revise the brief rather than drifting the design.
        
        ### Phase 2: Selection Process Detail
        
        Select font pairing using this process:
        1. Identify 3-5 candidate fonts from the appropriate aesthetic category
        2. Eliminate any that feel "obvious" or overused for this context -- resist the first instinct and explore deeper in the catalog, because beautiful unexpected combinations are the goal
        3. Test combinations: Display font + Body font, or single font family with weight variation
        4. Verify the pairing creates clear visual hierarchy
        5. Check against project history to confirm you are not reusing recent choices
        
        Selection criteria:
        - Matches aesthetic direction from Phase 1
        - Creates clear hierarchy (Display/Heading + Body, or single font with weight variation)
        - Is unexpected -- avoid first instinct, explore deeper in the catalog
        - Has not been used in recent projects
        - **Two typefaces maximum**. If the pairing needs a third family to feel complete, something upstream is wrong. A single family with weight and size variation beats three families fighting each other.
        - **Brand first on branded pages**. When the page is promoting a named product or brand, the product name must be set at hero level in the display face. The product name is not a label, it is the loudest element in the first viewport.
        
        ### Phase 2: Font Validation Block
        
        ```bash
        # TODO: scripts/font_validator.py not yet implemented
        # Manual alternative: check fonts against banned list
        # Banned: Inter, Roboto, Arial, Helvetica, system fonts, Space Grotesk
        ```
        
        Manually verify: no banned fonts in selection or fallback stacks (`sans-serif` alone counts as a banned system font), pairing not recently used, aesthetic match with direction.
        
        ### Phase 3: Inspiration Sources
        
        Inspiration sources include:
        - **Cultural aesthetics**: Japanese indigo, Scandinavian earth tones, Mediterranean warmth
        - **IDE themes**: Dracula, Nord, Gruvbox, Tokyo Night, Catppuccin
        - **Natural phenomena**: Desert sunsets, deep ocean, autumn forests, arctic twilight
        - **Historical periods**: Art Deco, Mid-century modern, Victorian industrial
        - **Artistic movements**: Bauhaus, De Stijl, Impressionism
        
        Select an inspiration source that resonates with the project context from Phase 1. The palette must trace back to that context -- convenience or personal preference is not a valid reason for a color choice.
        
        ### Phase 3: Palette Checks
        
        Check against failure modes in `references/preferred-patterns.json`:
        - No purple (#8B5CF6, #A855F7) as accent on white background -- the most cliched color scheme in modern web design, signaling generic SaaS template
        - No evenly distributed colors without clear dominance
        - No generic blue (#3B82F6) as primary on white
        - No pastels without saturation variation
        - No pure black (#000000) or pure white (#FFFFFF) as dominant color
        
        **Do instead**: Ground the palette in the inspiration source selected in Phase 3 rather than convenience defaults. A dark teal dominant derived from "arctic twilight" is always more defensible than reaching for purple or generic blue. Confirm the 60/30/10 ratio has a clear dominant before locking the palette, and verify using `references/color-inspirations.json`.
        
        ### Phase 3: Palette Validation Block
        
        <!-- no-pair-required: bash-comment-in-code-fence; heading parsed from code comment, not a guidance block -->
        ```bash
        # TODO: scripts/palette_analyzer.py not yet implemented
        # Manual check: verify palette against the cliche list in references/preferred-patterns.json
        ```
        
        Manually verify: no cliche patterns, clear 60/30/10 dominance ratio, sufficient contrast for accessibility. Report results with specific hex values rather than describing colors abstractly.
        
        ### Phase 4: Three Motion Slots Detail
        
        **Step 1: Fill the three motion slots**. Pick one from each slot, not five from slot A:
        
        1. **Entrance slot**: One entrance sequence in the hero. The hero headline, the hero media, or a staggered reveal of both. Happens once, on load.
        2. **Scroll slot**: One scroll-linked or sticky effect. A sticky product image following the scroll, a parallax depth layer, a reveal-on-scroll sequence for the supporting sections.
        3. **Interaction slot**: One hover, reveal, or layout transition. A hover effect on the primary CTA, a card that expands on click, a tab that slides under the label.
        
        If a proposed motion does not fit into one of these three slots, it is extra noise and should be cut.
        
        **Moments NOT worth animating** (resist the urge):
        - Every hover state on every element
        - Every button click feedback
        - Low-importance UI elements (footers, metadata)
        - Background elements that distract from content
        
        **Decorative-only litmus**. For every motion in the design, ask: does removing this motion change what the user understands about the page? If the answer is no, the motion is decorative and should be cut. Motion is a tool for establishing hierarchy and presence, not a tool for making the page feel "alive".
        
        **Recommended stack**: Framer Motion for React, CSS transitions for simple hover/focus. Use one animation stack per page.
        
        **Step 2: Design choreography** for each identified moment. Reference `references/animation-patterns.md` for battle-tested patterns:
        - Orchestrated page load with staggered reveal
        - State transition choreography (empty to populated)
        - Loading-to-success celebration sequences
        - Scroll-triggered section reveals
        - Interactive hover effects (use sparingly)
        
        ### Phase 4: Easing and Timing Tables
        
        Easing by purpose:
        - **Entrances**: `cubic-bezier(0.22, 1, 0.36, 1)` -- smooth deceleration into view
        - **Exits**: `cubic-bezier(0.4, 0, 1, 1)` -- smooth acceleration out of view
        - **Interactions**: `cubic-bezier(0.4, 0, 0.2, 1)` -- Material Design standard for hover/click
        - **Elastic**: `cubic-bezier(0.68, -0.55, 0.265, 1.55)` -- playful overshoot for celebrations
        
        Duration by scope:
        - Micro-interactions (hover, focus): 150-250ms
        - Component transitions (card, modal): 300-500ms
        - Page transitions (hero load, section): 500-800ms
        - Stagger delay between elements: 100-200ms
        - Never exceed 1000ms for any UI animation
        
        ### Phase 5: Hero Composition Rules (landing pages)
        
        The first viewport must read as one composition, not a grid of parts. Apply these hard rules before any background work:
        
        - **One composition**. The first screen the user sees must read as a single unified image. If a new user cannot describe the hero in one sentence, it is not a composition yet.
        - **No cards in the hero. Ever.** The hero is where the product speaks directly. Putting the hero content inside a rounded card with a drop shadow instantly converts a landing page into a generic dashboard tile.
        - **Full-bleed hero by default**. The hero spans the full viewport width on landing pages. No content-width containers, no max-width wrapping, no sidebars stealing from the hero.
        - **Brand-first composition**. On branded pages, the product name must be set at hero scale in the display typeface from Phase 2. The product name is the loudest thing in the first viewport.
        - **One job per section**. The hero has one purpose, one takeaway, one CTA intent. If the hero is promising two things, cut one.
        - **Hero image litmus**. Remove the hero image mentally. Does the page still work without it? If yes, the image was too weak and needs to be replaced, not decorated.
        
        Apps and dashboards follow different rules; see `references/app-vs-landing-rules.md`.
        
        ### Phase 5: Background Techniques
        
        Choose technique from `references/background-techniques.md` based on aesthetic direction:
        - **Layered radial gradients**: Atmospheric depth with soft colored glows (sophisticated, landing pages)
        - **Geometric patterns**: Grid lines, dots, diagonal stripes (technical precision, developer tools)
        - **Noise textures**: Grain overlays for tactile organic feel (portfolios, artisan brands)
        - **Contextual effects**: IDE scanlines, paper texture, cursor spotlight (thematic immersion)
        - **Multi-layer composition**: Combine 2-3 techniques for rich atmospheric depth
        
        A good atmospheric background combines at minimum:
        - Base surface color (never pure white or pure black)
        - Gradient layer for depth and focus direction
        - Pattern or texture layer for character
        
        ### Phase 6: Validation Report Checks
        
        The report checks:
        - No banned fonts in selection or fallback stacks
        - No cliche color schemes detected
        - Font pairing uniqueness versus recent projects
        - Two typefaces maximum in the selected pairing
        - Color dominance ratio meets 60/30/10 target
        - Exactly one accent color in the accent slot (functional colors do not count)
        - Sufficient contrast ratios (WCAG AA minimum)
        - Animation strategy is defined (not missing)
        - Motion count fits the 2-to-3 slot rule (entrance, scroll, interaction)
        - Every motion passes the decorative-only litmus
        - Background atmosphere is present (not flat)
        - Narrative brief is present (visual thesis, content plan, interaction thesis)
        - Surface type classified as landing page or app, with matching rule set applied
        - For landing pages: hero reads as one composition, no cards in hero, full-bleed hero confirmed
        - For apps: Linear-style restraint applied, no decorative gradients or card mosaics
        - Macrostructure variety: chosen `macro:*` id differs from the last two projects (variety check step-down)
        - Rendered-CSS slop scan (when `--emitted-css` is passed): warnings reported for `transition-all`, `universal-hover-scale`, `gradient-text-headline`, `focus-ring-fade`, `emoji-feature-icon`, `two-line-cta`, `contrast-canary`
        
        ### Phase 7: Design Stamp Field Reference
        
        The first line of generated CSS is a one-line stamp recording the build's results so a later run can re-audit statelessly and the variety check can recover the macro id from output:
        
        ```
        /* vexjoy-design: macro=<id> theme=<name> contrast=<pass|fail> nav=<id> footer=<id> mobile=<pass|fail> */
        ```
        
        | field | value |
        |---|---|
        | `macro` | the Phase 1 `macro:*` id |
        | `theme` | palette name from Phase 3 |
        | `contrast` | `pass` if text meets WCAG AA against its background, else `fail` |
        | `nav` | navigation pattern id used (e.g. `top-bar`, `sidebar`, `none`) |
        | `footer` | footer pattern id used (e.g. `slim`, `sitemap`, `none`) |
        | `mobile` | `pass` if the layout is verified responsive, else `fail` |
        
        The stamp is a claim, not proof. `scripts/css_slop_rules.py` re-scans the rendered CSS independently; the slop scan verifies the output rather than trusting the stamp text.
        
      • pptx-conversion.md 9.7 KB
        # PPTX Conversion Reference
        
        > **Scope**: python-pptx extraction patterns for the PPTX-to-HTML conversion path — text, notes, images, shapes, and failure modes.
        > **Version range**: python-pptx 0.6.21+ (pip install python-pptx). Python 3.8+.
        > **Generated**: 2026-04-17
        
        ---
        
        ## Overview
        
        PPTX conversion uses `python-pptx` to extract slide content before building the HTML deck. The most common failure modes are (1) silently skipping slides that use grouped or embedded OLE shapes, and (2) losing slide notes because `text_frame` is checked on the wrong object. Always extract text, notes, and image references in a single pass and validate the slide count before proceeding.
        
        ---
        
        ## Pattern Table
        
        | Task | API | Notes |
        |------|-----|-------|
        | Open presentation | `prs = Presentation(path)` | Raises `PackageNotFoundError` on corrupt/missing file |
        | Iterate slides | `for slide in prs.slides` | `prs.slides` is 0-indexed |
        | Slide dimensions | `prs.slide_width`, `prs.slide_height` | In EMUs; divide by 914400 for inches |
        | Slide notes | `slide.notes_slide.notes_text_frame.text` | Raises `AttributeError` if no notes — always guard |
        | Shape text | `shape.text_frame.text` | Only on shapes where `shape.has_text_frame` is True |
        | Placeholder text | `shape.placeholder_format` | Check `ph_idx` for title (0), body (1) |
        | Images | `shape.image.blob`, `shape.image.ext` | Only when `shape.shape_type == MSO_SHAPE_TYPE.PICTURE` |
        | Tables | `shape.table.rows[i].cells[j].text_frame.text` | Only when `shape.has_table` |
        
        ---
        
        ## Correct Patterns
        
        ### Safe slide extraction loop
        
        ```python
        from pptx import Presentation
        from pptx.enum.shapes import MSO_SHAPE_TYPE
        import base64
        
        def extract_slides(pptx_path: str) -> list[dict]:
            prs = Presentation(pptx_path)
            results = []
        
            for slide_num, slide in enumerate(prs.slides, start=1):
                slide_data = {
                    'number': slide_num,
                    'title': '',
                    'body': [],
                    'notes': '',
                    'images': [],
                }
        
                for shape in slide.shapes:
                    # Title placeholder (ph_idx == 0)
                    if shape.has_text_frame:
                        ph = shape.placeholder_format
                        if ph is not None and ph.idx == 0:
                            slide_data['title'] = shape.text_frame.text.strip()
                        elif ph is not None and ph.idx == 1:
                            # Body placeholder — collect non-empty paragraphs
                            for para in shape.text_frame.paragraphs:
                                text = para.text.strip()
                                if text:
                                    slide_data['body'].append(text)
                        elif ph is None:
                            # Freestanding text box
                            text = shape.text_frame.text.strip()
                            if text:
                                slide_data['body'].append(text)
        
                    # Images
                    if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
                        img_blob = shape.image.blob
                        img_ext = shape.image.ext
                        img_b64 = base64.b64encode(img_blob).decode('utf-8')
                        slide_data['images'].append({
                            'ext': img_ext,
                            'data_uri': f'data:image/{img_ext};base64,{img_b64}',
                        })
        
                # Speaker notes — guard AttributeError for slides with no notes
                try:
                    notes_tf = slide.notes_slide.notes_text_frame
                    slide_data['notes'] = notes_tf.text.strip()
                except AttributeError:
                    pass
        
                results.append(slide_data)
        
            return results
        ```
        
        **Why**: `shape.has_text_frame` must be checked before accessing `.text_frame` — not all shapes have one. Notes access via `notes_slide` raises `AttributeError` on slides where no notes placeholder exists; the try/except is mandatory, not defensive.
        
        ---
        
        ### Dependency check with clear error
        
        ```python
        def check_python_pptx() -> None:
            try:
                import pptx  # noqa: F401
            except ImportError:
                print("Error: python-pptx is not installed.")
                print("Install it with: pip install python-pptx")
                print("Or provide slide content manually.")
                raise SystemExit(1)
        ```
        
        **Why**: Silent import failure causes an `AttributeError` or `NameError` later in the extraction, not a clear message. Always check the import upfront with actionable instructions.
        
        ---
        
        ### Slide count validation
        
        ```python
        def validate_extraction(slides: list[dict], pptx_path: str) -> None:
            from pptx import Presentation
            expected = len(Presentation(pptx_path).slides)
            if len(slides) != expected:
                print(f"Warning: extracted {len(slides)} slides but PPTX has {expected}.")
                print("Grouped shapes or embedded OLE objects may have been skipped.")
        ```
        
        **Why**: Grouped shapes (MSO_SHAPE_TYPE.GROUP) and OLE objects (MSO_SHAPE_TYPE.OLE_OBJECT) are common in slides built from templates. The loop silently skips them — a count mismatch is the only signal that content was lost.
        
        ---
        
        ## Pattern Catalog
        
        ### Guard text_frame Access with has_text_frame
        
        **Detection**:
        ```bash
        grep -n '\.text_frame' convert.py | grep -v 'has_text_frame'
        rg 'shape\.text_frame' --type py | grep -v 'has_text_frame'
        ```
        
        **Signal**:
        ```python
        for shape in slide.shapes:
            text = shape.text_frame.text  # AttributeError on Picture, Table, GroupShape
        ```
        
        **Why this matters**: `AttributeError: 'Picture' object has no attribute 'text_frame'` on the first slide with an image. Extraction aborts with no output.
        
        **Do instead:** Guard every `text_frame` access with `shape.has_text_frame`:
        
        ```python
        for shape in slide.shapes:
            if shape.has_text_frame:
                text = shape.text_frame.text
        ```
        
        ---
        
        ### Wrap Notes Access in try/except AttributeError
        
        **Detection**:
        ```bash
        grep -n 'notes_slide' convert.py | grep -v 'try\|except\|AttributeError'
        rg 'notes_text_frame' --type py | grep -v 'try\|except'
        ```
        
        **Signal**:
        ```python
        notes = slide.notes_slide.notes_text_frame.text
        ```
        
        **Why this matters**: Raises `AttributeError: 'NoneType' object has no attribute 'notes_text_frame'` on slides with no notes. PPTX files created from blank templates frequently have no notes placeholder on every slide.
        
        **Do instead:** Wrap notes access in a `try/except AttributeError` block:
        
        ```python
        try:
            notes = slide.notes_slide.notes_text_frame.text.strip()
        except AttributeError:
            notes = ''
        ```
        
        ---
        
        ### Embed Images as Base64 Data URIs
        
        **Detection**:
        ```bash
        grep -n 'open.*wb\|\.write(img' convert.py
        rg 'image\.blob.*open|with open.*image' --type py
        ```
        
        **Signal**:
        ```python
        with open(f'slide_{i}_img.png', 'wb') as f:
            f.write(shape.image.blob)
        # Then references <img src="slide_1_img.png"> in HTML
        ```
        
        **Why this matters**: The output must be a single self-contained `.html` file with no external dependencies. External image files break when the HTML is shared or opened from a different directory.
        
        **Do instead:** Embed images as base64 data URIs so the HTML file is self-contained:
        
        ```python
        import base64
        b64 = base64.b64encode(shape.image.blob).decode('utf-8')
        data_uri = f'data:image/{shape.image.ext};base64,{b64}'
        # Use data_uri in <img src="...">
        ```
        
        ---
        
        ### Recurse into Grouped Shapes
        
        **Detection**:
        ```bash
        grep -n 'MSO_SHAPE_TYPE\|shape_type' convert.py | grep -v 'GROUP'
        rg 'for shape in slide\.shapes' --type py -A 5 | grep -v 'GROUP\|group'
        ```
        
        **Signal**:
        ```python
        for shape in slide.shapes:
            if shape.has_text_frame:
                ...  # GroupShape has no text_frame — silently skipped
        ```
        
        **Why this matters**: `GROUP` shapes contain child shapes with text. Skipping them silently drops bullet points and labels that appear in the original slide.
        
        **Do instead:** Use a recursive generator to walk into grouped shapes before processing:
        
        ```python
        def iter_shapes(shapes):
            from pptx.enum.shapes import MSO_SHAPE_TYPE
            for shape in shapes:
                if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
                    yield from iter_shapes(shape.shapes)
                else:
                    yield shape
        
        for shape in iter_shapes(slide.shapes):
            if shape.has_text_frame:
                ...
        ```
        
        ---
        
        ## Error-Fix Mappings
        
        | Error | Root Cause | Fix |
        |-------|-----------|-----|
        | `AttributeError: 'Picture' object has no attribute 'text_frame'` | Accessing `.text_frame` without `has_text_frame` check | Add `if shape.has_text_frame:` guard |
        | `AttributeError: 'NoneType' object has no attribute 'notes_text_frame'` | Notes access on slide with no notes | Wrap in `try/except AttributeError` |
        | `ModuleNotFoundError: No module named 'pptx'` | python-pptx not installed | `pip install python-pptx`; check upfront with import guard |
        | `PackageNotFoundError` | PPTX file corrupt, wrong path, or not a real PPTX | Check file exists and has `.pptx` extension before `Presentation()` call |
        | Fewer extracted slides than expected | Grouped shapes or OLE objects skipped silently | Validate count; recurse into GROUP shapes |
        | Images not visible in output HTML | Images saved as external files, not embedded | Use base64 data URI in `<img src>` |
        | Table content missing | `shape.has_table` not checked; table shapes skipped | Add `elif shape.has_table:` branch; iterate `shape.table.rows` |
        | Slide order wrong | `enumerate(prs.slides)` is correct; check if slides list is being sorted | Do not sort; PPTX slide order is authoring order |
        
        ---
        
        ## Detection Commands Reference
        
        ```bash
        # text_frame access without guard
        grep -n '\.text_frame' convert.py | grep -v 'has_text_frame'
        
        # notes without AttributeError guard
        grep -n 'notes_slide\|notes_text_frame' convert.py | grep -v 'try\|except'
        
        # external image files (should use data URI instead)
        grep -n "open.*'wb'" convert.py
        
        # missing GROUP shape recursion
        grep -n 'for shape in slide.shapes' convert.py
        ```
        
        ---
        
        ## See Also
        
        - `STYLE_PRESETS.md` — CSS base block and presets applied after extraction
        - `slide-controller.md` — SlideController JS patterns for the output HTML
        
      • roll-text.md 7.2 KB
        # Roll Text — per-character slot transition
        
        Animates a text element from string A to string B. Each character sits in a clipped cell; the old glyph slides out while the new glyph slides in, staggered left to right. Vanilla JS + CSS transitions. No build step, no npm, no canvas, no animation loop.
        
        Clean-room implementation written from a behavior spec; no external code or license applies.
        
        ## Behavior
        
        - `rollTo(el, next, opts)` retargets `el` to display `next`.
        - Unchanged characters stay static; changed characters roll.
        - Strings of different lengths work: missing positions roll to/from an empty cell, and each cell animates its width from old-glyph width to new-glyph width, so the line reflows smoothly.
        - Interruptible: a new call mid-animation rebuilds from the last requested target and rolls to the new one.
        - `prefers-reduced-motion: reduce` collapses duration and stagger to 0 (instant swap).
        - Screen readers get the plain string via `aria-label`; the per-character cells are `aria-hidden`.
        
        ## Standalone demo (copy-paste runnable)
        
        Save as `roll-text-demo.html` and open in any browser. Works offline.
        
        ```html
        <!doctype html>
        <html lang="en">
        <head>
        <meta charset="utf-8">
        <title>Roll text demo</title>
        <style>
          body { font-family: system-ui, sans-serif; background: #14141c; color: #f2f0ea;
                 display: grid; place-items: center; min-height: 100vh; margin: 0; }
          main { text-align: center; }
          h1 { font-size: 3rem; margin: 0 0 1.5rem; }
          button { font: inherit; padding: 0.4rem 1rem; margin: 0 0.25rem;
                   background: #2c2c3a; color: inherit; border: 1px solid #4a4a5e;
                   border-radius: 6px; cursor: pointer; }
        
          /* Roll-text core */
          .rolltext { display: inline-flex; --cell-h: 1.2em; line-height: var(--cell-h); }
          .rolltext .rt-cell { display: inline-block; overflow: hidden;
                               height: var(--cell-h); white-space: pre; }
          .rolltext .rt-col { display: flex; flex-direction: column; will-change: transform; }
          .rolltext .rt-glyph { display: block; height: var(--cell-h);
                                line-height: var(--cell-h); white-space: pre; }
        </style>
        </head>
        <body>
        <main>
          <h1 id="headline"></h1>
          <button id="next">Next phrase</button>
          <button id="spam">Interrupt test</button>
        </main>
        <script>
        /* Roll-text core ----------------------------------------------------- */
        function rollTo(el, next, opts) {
          var cfg = Object.assign({
            duration: 450,                       /* ms per character roll */
            stagger: 45,                         /* ms delay added per character */
            easing: 'cubic-bezier(0.33, 0, 0.2, 1)',
            direction: 'up'                      /* 'up' or 'down' */
          }, opts || {});
          if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
            cfg.duration = 0;
            cfg.stagger = 0;
          }
        
          /* Retarget cleanly: start from the last requested string. */
          var prev = el.dataset.rtTarget != null ? el.dataset.rtTarget : el.textContent;
          el.dataset.rtTarget = next;
          el.classList.add('rolltext');
          el.setAttribute('aria-label', next);
          el.replaceChildren();
        
          var count = Math.max(prev.length, next.length);
          var movers = [];
          for (var i = 0; i < count; i++) {
            var from = prev[i] || '';
            var to = next[i] || '';
            var cell = document.createElement('span');
            cell.className = 'rt-cell';
            cell.setAttribute('aria-hidden', 'true');
            if (from === to) {
              cell.textContent = from;           /* unchanged: static cell */
              el.appendChild(cell);
              continue;
            }
            var col = document.createElement('span');
            col.className = 'rt-col';
            /* Stack order decides roll direction. */
            col.appendChild(makeGlyph(cfg.direction === 'up' ? from : to));
            col.appendChild(makeGlyph(cfg.direction === 'up' ? to : from));
            if (cfg.direction === 'down') col.style.transform = 'translateY(-50%)';
            cell.appendChild(col);
            el.appendChild(cell);
            movers.push({ cell: cell, col: col, from: from, to: to, delay: i * cfg.stagger });
          }
        
          /* Measure glyph widths in the element's own font. */
          var probe = document.createElement('span');
          probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;';
          el.appendChild(probe);
          movers.forEach(function (m) {
            probe.textContent = m.from;
            m.w0 = probe.getBoundingClientRect().width;
            probe.textContent = m.to;
            m.w1 = probe.getBoundingClientRect().width;
          });
          probe.remove();
        
          /* Commit start state, force one reflow, then set end state.
             The reflow makes the browser register the start values so the
             transition actually runs. No rAF loop needed. */
          movers.forEach(function (m) { m.cell.style.width = m.w0 + 'px'; });
          void el.offsetHeight;
          movers.forEach(function (m) {
            var t = cfg.duration + 'ms ' + cfg.easing + ' ' + m.delay + 'ms';
            m.col.style.transition = 'transform ' + t;
            m.cell.style.transition = 'width ' + t;
            m.col.style.transform = cfg.direction === 'up' ? 'translateY(-50%)' : 'translateY(0)';
            m.cell.style.width = m.w1 + 'px';
          });
        }
        
        function makeGlyph(ch) {
          var s = document.createElement('span');
          s.className = 'rt-glyph';
          s.textContent = ch;
          return s;
        }
        
        /* Demo wiring --------------------------------------------------------- */
        var phrases = ['Hello, world', 'Different length here', 'Hi', 'Roll the text'];
        var idx = 0;
        var headline = document.getElementById('headline');
        rollTo(headline, phrases[0]);
        
        document.getElementById('next').addEventListener('click', function () {
          idx = (idx + 1) % phrases.length;
          rollTo(headline, phrases[idx]);
        });
        
        /* Two rapid calls: the second retargets mid-animation. */
        document.getElementById('spam').addEventListener('click', function () {
          rollTo(headline, 'Interrupted...');
          setTimeout(function () { rollTo(headline, 'Retargeted cleanly'); }, 200);
        });
        </script>
        </body>
        </html>
        ```
        
        ## Extraction guide (for embedding in an artifact)
        
        1. Copy the three CSS rules under "Roll-text core" into the artifact's `<style>`. Rename the `rolltext`/`rt-*` classes if they collide.
        2. Copy `rollTo` and `makeGlyph` into the artifact's `<script>`. No other code is required.
        3. Call `rollTo(element, 'new string')` whenever the text should change. The first call may target an empty element; pass the initial string.
        4. Keep the `data-rt-target` attribute untouched — it stores the last target so interruption retargets from a known string instead of half-rolled glyphs.
        5. Verify standalone: open the file from disk; text rolls with no console errors and no network requests.
        
        ## Knobs
        
        | Knob | Default | Effect |
        |---|---|---|
        | `duration` | `450` ms | Length of each character's roll and width change |
        | `stagger` | `45` ms | Extra delay per character index; 0 = all roll together |
        | `easing` | `cubic-bezier(0.33, 0, 0.2, 1)` | Any CSS easing; an overshoot curve (y > 1) gives a springy land |
        | `direction` | `'up'` | `'up'` rolls old glyph out the top; `'down'` out the bottom |
        | `--cell-h` (CSS) | `1.2em` | Cell height; raise it if descenders clip in the chosen font |
        
        ## Notes
        
        - Monospace fonts make the width transition invisible (all glyphs equal width) — that is fine, not a bug.
        - For a rolling counter, call `rollTo` with the formatted number string; unchanged digits stay still, changed digits roll.
        - Cells use `white-space: pre` so space characters keep their width.
        
      • shader-integration-react.md 16.7 KB
        # Shader Integration in React 19
        
        Mounting standalone WebGL2 fragment shaders on React card components without Three.js. Covers the shared context singleton pattern, the `useCardShader` hook, visibility gating with IntersectionObserver, and the mobile CSS fallback.
        
        ---
        
        ## The Context Limit Problem
        
        Browsers cap WebGL contexts at 8–16 per page. A hand of 8 cards each creating their own WebGL2 context hits this limit immediately. The solution is a single shared WebGL2 rendering context that draws to an offscreen buffer, then blits the result to each card's 2D canvas.
        
        **Architecture:**
        
        ```
        WebGL2 singleton (1 context total)
          ├── Renders to shared offscreen texture
          ├── Card A: HTMLCanvasElement (2D context) ← receives blit
          ├── Card B: HTMLCanvasElement (2D context) ← receives blit
          └── Card C: HTMLCanvasElement (2D context) ← receives blit
        ```
        
        Each card has a `<canvas>` element with a `2D` rendering context. The singleton WebGL2 context renders the shader, then `drawImage` copies the rendered frame to each card's 2D canvas. This uses only 1 WebGL2 context total regardless of how many cards are on screen.
        
        ---
        
        ## WebGL2 Feature Detection
        
        Always check before attempting WebGL2. Fall back to CSS — do not throw or log errors that flood the console.
        
        ```typescript
        // src/components/cards/effects/webglSupport.ts
        
        let _supportsWebGL2: boolean | null = null;
        
        export function supportsWebGL2(): boolean {
          if (_supportsWebGL2 !== null) return _supportsWebGL2;
        
          try {
            const canvas = document.createElement('canvas');
            _supportsWebGL2 = !!canvas.getContext('webgl2');
          } catch {
            _supportsWebGL2 = false;
          }
        
          return _supportsWebGL2;
        }
        ```
        
        ---
        
        ## Shared WebGL2 Context Singleton
        
        ```typescript
        // src/components/cards/effects/webglContext.ts
        
        import type { RefObject } from 'react';
        
        interface CardShaderEntry {
          canvasRef: RefObject<HTMLCanvasElement | null>;
          rarity: string;
          isHovered: boolean;
          isUpgraded: boolean;
          isVisible: boolean;
          mouseX: number;
          mouseY: number;
        }
        
        interface ShaderProgram {
          program: WebGLProgram;
          locations: {
            u_time: WebGLUniformLocation | null;
            u_rarity: WebGLUniformLocation | null;
            u_hover: WebGLUniformLocation | null;
            u_mouse: WebGLUniformLocation | null;
            u_resolution: WebGLUniformLocation | null;
            u_upgraded: WebGLUniformLocation | null;
          };
        }
        
        // Singleton state — module-level, lives for the page lifetime
        let gl: WebGL2RenderingContext | null = null;
        let offscreenCanvas: HTMLCanvasElement | null = null;
        let shimmerProgram: ShaderProgram | null = null;
        let rareProgram: ShaderProgram | null = null;
        let legendaryProgram: ShaderProgram | null = null;
        let quadVAO: WebGLVertexArrayObject | null = null;
        let rafId: number | null = null;
        let startTime: number | null = null;
        let hoverValues = new Map<string, number>(); // cardId → current lerped hover value
        
        const registeredCards = new Map<string, CardShaderEntry>();
        
        // --- Shader source imports ---
        // Import from cardShaders.ts (see below for sources)
        import { VERTEX_SHADER, SHIMMER_FRAG, RARE_FRAG, LEGENDARY_FRAG } from './cardShaders';
        
        function compileShader(
          gl: WebGL2RenderingContext,
          source: string,
          type: number
        ): WebGLShader {
          const shader = gl.createShader(type);
          if (!shader) throw new Error('Failed to create shader');
        
          gl.shaderSource(shader, source);
          gl.compileShader(shader);
        
          if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            const log = gl.getShaderInfoLog(shader);
            gl.deleteShader(shader);
            throw new Error(`Shader compile error: ${log ?? 'unknown'}`);
          }
        
          return shader;
        }
        
        function createProgram(
          gl: WebGL2RenderingContext,
          vertSrc: string,
          fragSrc: string
        ): ShaderProgram {
          const vert = compileShader(gl, vertSrc, gl.VERTEX_SHADER);
          const frag = compileShader(gl, fragSrc, gl.FRAGMENT_SHADER);
        
          const program = gl.createProgram();
          if (!program) throw new Error('Failed to create program');
        
          gl.attachShader(program, vert);
          gl.attachShader(program, frag);
          gl.linkProgram(program);
        
          gl.deleteShader(vert);
          gl.deleteShader(frag);
        
          if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            const log = gl.getProgramInfoLog(program);
            gl.deleteProgram(program);
            throw new Error(`Program link error: ${log ?? 'unknown'}`);
          }
        
          return {
            program,
            locations: {
              u_time: gl.getUniformLocation(program, 'u_time'),
              u_rarity: gl.getUniformLocation(program, 'u_rarity'),
              u_hover: gl.getUniformLocation(program, 'u_hover'),
              u_mouse: gl.getUniformLocation(program, 'u_mouse'),
              u_resolution: gl.getUniformLocation(program, 'u_resolution'),
              u_upgraded: gl.getUniformLocation(program, 'u_upgraded'),
            },
          };
        }
        
        function initSingleton(): boolean {
          if (gl !== null) return true; // Already initialized
        
          try {
            offscreenCanvas = document.createElement('canvas');
            offscreenCanvas.width = 400;
            offscreenCanvas.height = 560;
        
            const ctx = offscreenCanvas.getContext('webgl2');
            if (!ctx) return false;
            gl = ctx;
        
            // Compile all three shader programs upfront
            shimmerProgram = createProgram(gl, VERTEX_SHADER, SHIMMER_FRAG);
            rareProgram = createProgram(gl, VERTEX_SHADER, RARE_FRAG);
            legendaryProgram = createProgram(gl, VERTEX_SHADER, LEGENDARY_FRAG);
        
            // Create quad VAO
            quadVAO = gl.createVertexArray();
            gl.bindVertexArray(quadVAO);
        
            const vbo = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
            gl.bufferData(
              gl.ARRAY_BUFFER,
              new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
              gl.STATIC_DRAW
            );
        
            // a_position is always location 0 — bind it once
            gl.enableVertexAttribArray(0);
            gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
            gl.bindVertexArray(null);
        
            // Enable blending for screen blend mode
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
        
            startRenderLoop();
            return true;
          } catch (err) {
            console.warn('[webgl-card-effects] WebGL2 init failed:', err);
            gl = null;
            return false;
          }
        }
        
        function rarityToUniform(rarity: string): number {
          const map: Record<string, number> = {
            starter: 0.0,
            common: 0.0,
            uncommon: 0.25,
            rare: 0.5,
            legendary: 1.0,
          };
          return map[rarity] ?? 0.0;
        }
        
        function selectProgram(rarity: string): ShaderProgram | null {
          if (rarity === 'legendary') return legendaryProgram;
          if (rarity === 'rare') return rareProgram;
          if (rarity === 'uncommon') return shimmerProgram;
          return null;
        }
        
        function renderFrame(timestamp: number): void {
          if (!gl || !offscreenCanvas || !quadVAO) return;
        
          const elapsed = startTime === null ? 0 : (timestamp - startTime) / 1000;
          // Wrap at 1000 seconds to avoid float precision loss in the shader
          const u_time = elapsed % 1000.0;
        
          let anyVisible = false;
        
          for (const [cardId, entry] of registeredCards) {
            if (!entry.isVisible) continue;
            anyVisible = true;
        
            const program = selectProgram(entry.rarity);
            if (!program) continue;
        
            const canvas = entry.canvasRef.current;
            if (!canvas) continue;
        
            const ctx2d = canvas.getContext('2d');
            if (!ctx2d) continue;
        
            const w = canvas.width || canvas.clientWidth;
            const h = canvas.height || canvas.clientHeight;
        
            // Resize offscreen canvas to match card canvas if needed
            if (offscreenCanvas.width !== w || offscreenCanvas.height !== h) {
              offscreenCanvas.width = w;
              offscreenCanvas.height = h;
            }
        
            // Lerp hover value toward target
            const targetHover = entry.isHovered ? 1.0 : 0.0;
            const currentHover = hoverValues.get(cardId) ?? 0.0;
            const lerpedHover = currentHover + (targetHover - currentHover) * 0.12;
            hoverValues.set(cardId, lerpedHover);
        
            // Render
            gl.viewport(0, 0, w, h);
            gl.clearColor(0, 0, 0, 0);
            gl.clear(gl.COLOR_BUFFER_BIT);
        
            gl.useProgram(program.program);
            gl.bindVertexArray(quadVAO);
        
            const loc = program.locations;
            gl.uniform1f(loc.u_time, u_time);
            gl.uniform1f(loc.u_rarity, rarityToUniform(entry.rarity));
            gl.uniform1f(loc.u_hover, lerpedHover);
            gl.uniform2f(loc.u_mouse, entry.mouseX, entry.mouseY);
            gl.uniform2f(loc.u_resolution, w, h);
            gl.uniform1f(loc.u_upgraded, entry.isUpgraded ? 1.0 : 0.0);
        
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
            gl.bindVertexArray(null);
        
            // Blit offscreen WebGL result to the card's 2D canvas
            ctx2d.clearRect(0, 0, w, h);
            ctx2d.drawImage(offscreenCanvas, 0, 0, w, h);
          }
        
          // If nothing is visible, slow down RAF to save power
          if (!anyVisible) {
            rafId = requestAnimationFrame(renderFrame);
            return;
          }
        
          rafId = requestAnimationFrame(renderFrame);
        }
        
        // 30fps throttle — card shimmer does not need 60fps
        let _lastRenderTime = 0;
        const TARGET_FRAME_MS = 1000 / 30;
        
        function renderFrameThrottled(timestamp: number): void {
          if (startTime === null) startTime = timestamp;
        
          if (timestamp - _lastRenderTime >= TARGET_FRAME_MS) {
            _lastRenderTime = timestamp;
            renderFrame(timestamp);
          } else {
            rafId = requestAnimationFrame(renderFrameThrottled);
          }
        }
        
        function startRenderLoop(): void {
          if (rafId !== null) return;
          rafId = requestAnimationFrame(renderFrameThrottled);
        }
        
        // --- Public API ---
        
        export function registerCard(cardId: string, entry: CardShaderEntry): boolean {
          if (!initSingleton()) return false;
          registeredCards.set(cardId, entry);
          hoverValues.set(cardId, 0.0);
          return true;
        }
        
        export function updateCard(
          cardId: string,
          updates: Partial<Omit<CardShaderEntry, 'canvasRef'>>
        ): void {
          const existing = registeredCards.get(cardId);
          if (!existing) return;
          registeredCards.set(cardId, { ...existing, ...updates });
        }
        
        export function unregisterCard(cardId: string): void {
          registeredCards.delete(cardId);
          hoverValues.delete(cardId);
        
          // Stop RAF if no cards remain
          if (registeredCards.size === 0 && rafId !== null) {
            cancelAnimationFrame(rafId);
            rafId = null;
          }
        }
        ```
        
        ---
        
        ## useCardShader Hook
        
        ```typescript
        // src/components/cards/effects/useCardShader.ts
        
        import { useEffect, useRef, useId } from 'react';
        import type { RefObject } from 'react';
        import { supportsWebGL2 } from './webglSupport';
        import { registerCard, updateCard, unregisterCard } from './webglContext';
        
        interface UseCardShaderOptions {
          rarity: string;
          isHovered: boolean;
          isUpgraded: boolean;
          enabled: boolean;
        }
        
        export function useCardShader(
          options: UseCardShaderOptions
        ): RefObject<HTMLCanvasElement | null> {
          const canvasRef = useRef<HTMLCanvasElement | null>(null);
          // useId provides a stable unique ID per component instance
          const cardId = useId();
          const observerRef = useRef<IntersectionObserver | null>(null);
          const isRegisteredRef = useRef(false);
        
          const { rarity, isHovered, isUpgraded, enabled } = options;
        
          // Registration effect: runs once on mount, cleans up on unmount
          useEffect(() => {
            if (!enabled || !supportsWebGL2()) return;
        
            const canvas = canvasRef.current;
            if (!canvas) return;
        
            // Size the canvas to match its CSS-rendered dimensions
            const rect = canvas.getBoundingClientRect();
            const dpr = window.devicePixelRatio || 1;
            canvas.width = Math.round(rect.width * dpr);
            canvas.height = Math.round(rect.height * dpr);
        
            const registered = registerCard(cardId, {
              canvasRef,
              rarity,
              isHovered,
              isUpgraded,
              isVisible: false,
              mouseX: 0.5,
              mouseY: 0.5,
            });
        
            if (!registered) return;
            isRegisteredRef.current = true;
        
            // IntersectionObserver: only animate when card is in viewport
            observerRef.current = new IntersectionObserver(
              (entries) => {
                const isVisible = entries[0]?.isIntersecting ?? false;
                updateCard(cardId, { isVisible });
              },
              { threshold: 0.1 }
            );
            observerRef.current.observe(canvas);
        
            // ResizeObserver: keep canvas pixel dimensions in sync with CSS
            const resizeObserver = new ResizeObserver((entries) => {
              const entry = entries[0];
              if (!entry) return;
              const dpr = window.devicePixelRatio || 1;
              const w = Math.round(entry.contentRect.width * dpr);
              const h = Math.round(entry.contentRect.height * dpr);
              if (canvas.width !== w || canvas.height !== h) {
                canvas.width = w;
                canvas.height = h;
              }
            });
            resizeObserver.observe(canvas);
        
            return () => {
              observerRef.current?.disconnect();
              resizeObserver.disconnect();
              if (isRegisteredRef.current) {
                unregisterCard(cardId);
                isRegisteredRef.current = false;
              }
            };
            // eslint-disable-next-line react-hooks/exhaustive-deps
          }, [enabled, cardId]); // Only re-register if enabled or ID changes
        
          // Update effect: syncs reactive state to the singleton each render
          useEffect(() => {
            if (!isRegisteredRef.current) return;
            updateCard(cardId, { rarity, isHovered, isUpgraded });
          }, [cardId, rarity, isHovered, isUpgraded]);
        
          // Mouse tracking effect
          useEffect(() => {
            if (!enabled || !isRegisteredRef.current) return;
        
            const canvas = canvasRef.current;
            if (!canvas) return;
        
            function handleMouseMove(e: MouseEvent): void {
              const rect = canvas!.getBoundingClientRect();
              const x = (e.clientX - rect.left) / rect.width;
              const y = 1.0 - (e.clientY - rect.top) / rect.height; // Flip Y for GL coords
              updateCard(cardId, { mouseX: x, mouseY: y });
            }
        
            function handleMouseLeave(): void {
              updateCard(cardId, { mouseX: 0.5, mouseY: 0.5 });
            }
        
            // Walk up to the card container (canvas is a child of the card div)
            const cardContainer = canvas.closest('[data-card-container]') ?? canvas.parentElement;
            if (!cardContainer) return;
        
            cardContainer.addEventListener('mousemove', handleMouseMove as EventListener);
            cardContainer.addEventListener('mouseleave', handleMouseLeave);
        
            return () => {
              cardContainer.removeEventListener('mousemove', handleMouseMove as EventListener);
              cardContainer.removeEventListener('mouseleave', handleMouseLeave);
            };
          }, [enabled, cardId]);
        
          return canvasRef;
        }
        ```
        
        ---
        
        ## Integrating Mouse Tracking in FramedCard
        
        Add `data-card-container` to the `motion.div` in `FramedCard.tsx` so `useCardShader` can locate the event target:
        
        ```tsx
        <motion.div
          ref={cardRef}
          data-card-container
          // ... existing props
        >
        ```
        
        The hook's mouse listener walks up the DOM to find this attribute. This keeps the hook self-contained and avoids prop drilling mouse coordinates.
        
        ---
        
        ## Rarity-to-Uniform Mapping
        
        ```typescript
        // src/components/cards/effects/cardShaders.ts (mapping export)
        
        export type CardRarity = 'starter' | 'common' | 'uncommon' | 'rare' | 'legendary';
        
        export function rarityToFloat(rarity: CardRarity | string): number {
          const map: Record<string, number> = {
            starter:  0.0,
            common:   0.0,
            uncommon: 0.25,
            rare:     0.5,
            legendary: 1.0,
          };
          return map[rarity] ?? 0.0;
        }
        
        // Which shader tier to use per rarity
        export function shouldUseShader(rarity: string, size: string): boolean {
          if (size === 'xs' || size === 'sm') return false;
          return ['uncommon', 'rare', 'legendary'].includes(rarity);
        }
        ```
        
        ---
        
        ## CSS Fallback
        
        When WebGL2 is unavailable, the `<canvas>` element is not rendered (because `shouldRenderShader` is false). The existing CSS shimmer class from `FramedCard.tsx` handles the fallback automatically — no extra code needed.
        
        To test the fallback path locally, temporarily override `supportsWebGL2` to return false:
        
        ```typescript
        // For testing only — remove before committing
        import { supportsWebGL2 } from './webglSupport';
        // @ts-expect-error: test override
        supportsWebGL2._override = false;
        ```
        
        ---
        
        ## Patterns to Detect and Fix
        
        **Creating a new WebGL2 context per card instance**: Browsers cap at 8–16 total contexts. With a hand of 8+ cards, you will hit this limit. The singleton pattern above is non-negotiable.
        
        **Running RAF at 60fps for card shimmer**: Card shimmer doesn't need 60fps. The 30fps throttle (`TARGET_FRAME_MS = 1000 / 30`) halves GPU load with no perceptible quality difference for a slow organic shimmer.
        
        **Skipping IntersectionObserver**: Cards not in the viewport still consume RAF budget without the observer. For a game with a large card collection view, this wastes significant CPU/GPU.
        
        **Forgetting canvas `width`/`height` vs CSS size**: CSS `w-full h-full` sets display size. `canvas.width` and `canvas.height` set the drawing buffer. On a 2x DPI display, a CSS `200px` canvas with `canvas.width = 200` renders blurry. Always multiply by `devicePixelRatio`.
        
        **Using `getContext('webgl')` (WebGL1)**: The GLSL shaders in this skill use `#version 300 es` which requires WebGL2. WebGL1 contexts will fail to compile these shaders. Always call `getContext('webgl2')`.
        
        **Not cleaning up on unmount**: If `unregisterCard` isn't called in the cleanup function, the singleton continues rendering to a destroyed canvas. The `RefObject` will still hold a reference preventing GC, but `drawImage` will fail silently.
        
      • slide-controller.md 9.4 KB
        # SlideController Reference
        
        > **Scope**: JavaScript `SlideController` class implementation for single-file HTML presentations — keyboard, touch, wheel, Intersection Observer, and transition guard patterns.
        > **Version range**: Modern browsers (Chrome 88+, Firefox 87+, Safari 14+). Vanilla JS; no framework dependencies.
        > **Generated**: 2026-04-17
        
        ---
        
        ## Overview
        
        `SlideController` manages navigation between full-viewport slides. The two most common failure modes are (1) multi-slide jumps on wheel events because wheel fires many times per scroll gesture, and (2) reveal animations silently broken because `display: none` removes elements from the Intersection Observer callback tree. Every controller must guard both.
        
        ---
        
        ## Correct Patterns
        
        ### Canonical SlideController skeleton
        
        Complete, copy-pasteable implementation satisfying all Phase 4 requirements.
        
        ```javascript
        class SlideController {
          constructor() {
            this.slides = Array.from(document.querySelectorAll('.slide'));
            this.current = 0;
            this.navigating = false;
            this._wheelTimer = null;
        
            this._bindKeyboard();
            this._bindTouch();
            this._bindWheel();
            this._bindIO();
            this._updateIndicator();
          }
        
          go(index) {
            const target = Math.max(0, Math.min(index, this.slides.length - 1));
            if (target === this.current || this.navigating) return;
        
            this.navigating = true;
            this.current = target;
            this.slides[target].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
            this._updateIndicator();
        
            // Release guard after transition completes
            setTimeout(() => { this.navigating = false; }, 600);
          }
        
          _bindKeyboard() {
            document.addEventListener('keydown', (e) => {
              switch (e.key) {
                case 'ArrowRight': case 'Space':     e.preventDefault(); this.go(this.current + 1); break;
                case 'ArrowLeft':  case 'Backspace': e.preventDefault(); this.go(this.current - 1); break;
                case 'Home': this.go(0); break;
                case 'End':  this.go(this.slides.length - 1); break;
              }
            });
          }
        
          _bindTouch() {
            let startX = 0;
            document.addEventListener('touchstart', (e) => { startX = e.changedTouches[0].clientX; }, { passive: true });
            document.addEventListener('touchend', (e) => {
              const dx = e.changedTouches[0].clientX - startX;
              if (Math.abs(dx) > 50) this.go(this.current + (dx < 0 ? 1 : -1));
            }, { passive: true });
          }
        
          _bindWheel() {
            document.addEventListener('wheel', (e) => {
              clearTimeout(this._wheelTimer);
              this._wheelTimer = setTimeout(() => {
                if (!this.navigating) this.go(this.current + (e.deltaY > 0 ? 1 : -1));
              }, 150);
            }, { passive: true });
          }
        
          _bindIO() {
            const observer = new IntersectionObserver((entries) => {
              entries.forEach(entry => {
                if (entry.isIntersecting) entry.target.classList.add('visible');
              });
            }, { threshold: 0.5 });
            this.slides.forEach(s => observer.observe(s));
          }
        
          _updateIndicator() {
            const el = document.getElementById('slide-indicator');
            if (el) el.textContent = `${this.current + 1} / ${this.slides.length}`;
          }
        }
        
        document.addEventListener('DOMContentLoaded', () => new SlideController());
        ```
        
        **Why**: The `navigating` flag blocks re-entry until the 600ms scroll animation completes. The 150ms wheel debounce collapses a single scroll gesture (which fires 10–40 wheel events) into one navigation call.
        
        ---
        
        ### Reveal animation CSS pairing
        
        Slides must start hidden via CSS transforms, not `display: none`, so Intersection Observer fires.
        
        ```css
        .slide {
          opacity: 0;
          transform: translateY(20px);
          transition: opacity 400ms ease, transform 400ms ease;
        }
        
        .slide.visible {
          opacity: 1;
          transform: translateY(0);
        }
        
        @media (prefers-reduced-motion: reduce) {
          .slide, .slide.visible {
            transition: none;
            opacity: 1;
            transform: none;
          }
        }
        ```
        
        **Why**: `display: none` removes elements from the layout tree entirely. IntersectionObserver never fires for hidden elements because they have no intersection with the viewport. `opacity: 0` keeps the element in the tree while hiding it visually.
        
        ---
        
        ### Slide indicator markup
        
        ```html
        <div id="slide-indicator" aria-live="polite" style="
          position: fixed; bottom: 1.5rem; right: 2rem;
          font-size: clamp(0.75rem, 1.5vw, 0.9rem);
          color: var(--text-secondary);
          pointer-events: none;
          z-index: 100;
        ">1 / 1</div>
        ```
        
        ---
        
        ## Pattern Catalog
        
        ### Add a navigating Guard to Prevent Re-Entry
        
        **Detection**:
        ```bash
        grep -n 'go(' output.html | grep -v 'navigating'
        rg 'scrollIntoView' output.html | grep -v 'navigating'
        ```
        
        **Signal**:
        ```javascript
        go(index) {
          this.current = Math.max(0, Math.min(index, this.slides.length - 1));
          this.slides[this.current].scrollIntoView({ behavior: 'smooth' });
          // No guard — holding ArrowRight fires 10+ go() calls per second
        }
        ```
        
        **Why this matters**: Each `go()` call triggers a smooth scroll. When the second call fires before the first completes, slides jump out of sync with `this.current`. On fast keyboards or trackpads, the deck can advance 3-5 slides per keypress.
        
        **Do instead:** Set `this.navigating = true` at the start of `go()`, clear it with `setTimeout(() => this.navigating = false, 600)`, and guard entry with `if (this.navigating) return`.
        
        ---
        
        ### Debounce Wheel Events with clearTimeout
        
        **Detection**:
        ```bash
        grep -n 'wheel' output.html | grep -v 'clearTimeout\|debounce\|timer\|Timer'
        ```
        
        **Signal**:
        ```javascript
        document.addEventListener('wheel', (e) => {
          this.go(this.current + (e.deltaY > 0 ? 1 : -1));
        });
        ```
        
        **Why this matters**: A single trackpad scroll fires 20-80 `wheel` events. Without debounce, one swipe advances 20+ slides. The `navigating` guard alone is insufficient — it blocks concurrent calls, but debounce collapses burst events before they enter `go()`.
        
        **Do instead:** `clearTimeout(this._wheelTimer); this._wheelTimer = setTimeout(() => { ... }, 150);`
        
        ---
        
        ### Use opacity Instead of display:none for Slides
        
        **Detection**:
        ```bash
        grep -n 'display.*none' output.html | grep -iv 'comment\|//'
        rg '\.slide[^}]*display\s*:\s*none' output.html
        ```
        
        **Signal**:
        ```css
        .slide { display: none; }
        .slide.active { display: flex; }
        ```
        
        **Why this matters**: `display: none` removes the element from the accessibility tree and from the IntersectionObserver callback cycle. The `.visible` class never gets added. All reveal animations silently fail. Screen readers skip hidden slides.
        
        **Do instead:** Use `opacity: 0; pointer-events: none; position: absolute` to hide, and `.visible { opacity: 1; pointer-events: auto; position: relative }` to show.
        
        ---
        
        ### Add { passive: true } to Touch and Wheel Listeners
        
        **Detection**:
        ```bash
        grep -n 'touchstart\|touchend\|wheel' output.html | grep -v 'passive'
        ```
        
        **Signal**:
        ```javascript
        document.addEventListener('touchstart', handler);
        document.addEventListener('wheel', handler);
        ```
        
        **Why this matters**: Without `{ passive: true }`, the browser must wait for the handler to return before scrolling, causing 50–200ms jank on mobile. Chrome 73+ logs a console warning.
        
        **Do instead:** Add `{ passive: true }` to all touch and wheel listeners. HTML slide decks should not call `preventDefault()` on these events.
        
        ---
        
        ### Capture Space Key for Slide Advance
        
        **Detection**:
        ```bash
        grep -n "case 'Space'" output.html
        ```
        
        **Signal**:
        ```javascript
        document.addEventListener('keydown', (e) => {
          if (e.key === 'ArrowRight') this.go(this.current + 1);
          // Space not handled — browser default scrolls the page
        });
        ```
        
        **Why this matters**: Space is the standard "advance" key in every presentation app. Missing it breaks presenter muscle memory. The browser default scrolls the viewport, causing visible flicker before the controller corrects position.
        
        **Do instead:** `case 'Space': e.preventDefault(); this.go(this.current + 1); break;`
        
        ---
        
        ## Error-Fix Mappings
        
        | Symptom | Root Cause | Fix |
        |---------|-----------|-----|
        | Deck jumps 3-5 slides on single keypress | Missing `navigating` guard | Add flag; clear with `setTimeout(, 600)` |
        | One trackpad swipe advances entire deck | No wheel debounce | `clearTimeout` + 150ms `setTimeout` pattern |
        | Reveal animations never fire | `display: none` on `.slide` | Use `opacity: 0` + `transform` instead |
        | Touch swipe does nothing | Missing `touchstart`/`touchend` listeners | Add both with `{ passive: true }` |
        | Slide indicator always shows "1 / 1" | `_updateIndicator()` not called in `go()`, or `#slide-indicator` element missing from HTML | Call in `go()` and `constructor`; add element to markup |
        | Space bar scrolls page | `Space` key not captured; no `preventDefault()` | Handle in `keydown` switch with `e.preventDefault()` |
        | Last slide unreachable | Off-by-one: clamping to `slides.length` | Use `Math.min(index, this.slides.length - 1)` |
        
        ---
        
        ## Detection Commands Reference
        
        ```bash
        # Missing navigating guard
        grep -n 'scrollIntoView' output.html | grep -v 'navigating'
        
        # No wheel debounce
        grep -n 'addEventListener.*wheel' output.html | grep -v 'clearTimeout\|debounce'
        
        # display:none on slides
        grep -n 'display.*none' output.html
        
        # Missing passive listeners
        grep -n "addEventListener('touchstart\|addEventListener('wheel" output.html | grep -v 'passive'
        
        # Space key missing
        grep -cn "case 'Space'" output.html   # should be >= 1
        
        # Negated clamp (CSS bug from STYLE_PRESETS)
        grep -n '\-clamp(' output.html
        ```
        
        ---
        
        ## See Also
        
        - `STYLE_PRESETS.md` — CSS base block, theme presets, density limits, validation breakpoints
        - `pptx-conversion.md` — python-pptx extraction patterns for PPTX-to-HTML path
        
      • text-animation-patterns.md 4.3 KB
        # Text Animation Patterns — companions to roll-text
        
        Three small dependency-free patterns. Each is self-contained: copy the CSS into `<style>`, the JS into `<script>`, call the function. All respect `prefers-reduced-motion` and keep the readable string in `aria-label` when they split text into spans.
        
        ## 1. Split-letter reveal
        
        Entrance animation: each letter fades and rises in with a stagger. Use for headlines on first paint or when a section scrolls into view.
        
        ```html
        <style>
          .reveal-glyph { display: inline-block; white-space: pre;
                          opacity: 0; transform: translateY(0.5em); }
          .reveal-glyph.is-in { opacity: 1; transform: none;
                                transition: opacity 350ms ease-out, transform 350ms ease-out; }
          @media (prefers-reduced-motion: reduce) {
            .reveal-glyph { opacity: 1; transform: none; }
          }
        </style>
        <script>
        function revealText(el, stagger) {
          stagger = stagger == null ? 35 : stagger;
          var text = el.textContent;
          el.setAttribute('aria-label', text);
          el.replaceChildren();
          text.split('').forEach(function (ch, i) {
            var s = document.createElement('span');
            s.className = 'reveal-glyph';
            s.setAttribute('aria-hidden', 'true');
            s.textContent = ch;
            el.appendChild(s);
            void s.offsetHeight;                      /* register start state */
            s.style.transitionDelay = (i * stagger) + 'ms';
            s.classList.add('is-in');
          });
        }
        </script>
        ```
        
        Usage: `revealText(document.querySelector('h1'))`. For scroll-triggered reveals, call it from an `IntersectionObserver` callback (see html-artifact `scrollytelling-patterns.md`).
        
        ## 2. Typewriter
        
        Characters appear one at a time with a blinking caret. Use for terminal-style output or narrative emphasis. Returns a cancel function so a new run can interrupt the old.
        
        ```html
        <style>
          .tw-caret::after { content: ''; display: inline-block; width: 0.08em;
                             height: 1em; background: currentColor; vertical-align: -0.1em;
                             animation: tw-blink 1s steps(1) infinite; }
          @keyframes tw-blink { 50% { opacity: 0; } }
        </style>
        <script>
        function typeText(el, text, speed) {
          speed = speed == null ? 40 : speed;          /* ms per character */
          if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) speed = 0;
          el.setAttribute('aria-label', text);
          el.textContent = '';
          el.classList.add('tw-caret');
          var i = 0;
          var timer = setInterval(function () {
            el.textContent = text.slice(0, ++i);
            if (i >= text.length) {
              clearInterval(timer);
              el.classList.remove('tw-caret');
            }
          }, speed);
          return function cancel() { clearInterval(timer); el.classList.remove('tw-caret'); };
        }
        </script>
        ```
        
        Usage: `var stop = typeText(el, 'Booting...'); /* later */ stop();`. With `speed = 0` the interval still fires immediately, so reduced-motion users get near-instant text.
        
        ## 3. Crossfade swap
        
        Old text fades out, new text fades in with a slight vertical drift. Use when whole phrases change (status lines, tab labels) and per-character motion would be noise.
        
        ```html
        <style>
          .xfade { display: inline-block;
                   transition: opacity 200ms ease, transform 200ms ease; }
          .xfade.is-out { opacity: 0; transform: translateY(-0.3em); }
          @media (prefers-reduced-motion: reduce) { .xfade { transition: none; } }
        </style>
        <script>
        function swapText(el, next) {
          el.classList.add('xfade', 'is-out');
          function commit() {
            el.removeEventListener('transitionend', commit);
            el.textContent = next;
            el.classList.remove('is-out');           /* fades back in */
          }
          if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
            el.textContent = next;
            el.classList.remove('is-out');
            return;
          }
          el.addEventListener('transitionend', commit);
        }
        </script>
        ```
        
        Usage: `swapText(statusEl, 'Saved')`. Interruption-safe enough for status lines: a second call before `transitionend` re-adds `is-out` and the newest `next` wins because each call registers a fresh listener — if calls can arrive faster than 200ms, debounce them.
        
        ## Choosing a pattern
        
        | Intent | Pattern |
        |---|---|
        | String-to-string transition, counters, labels | roll-text (see `roll-text.md`) |
        | First-paint or on-scroll headline entrance | Split-letter reveal |
        | Terminal output, narrative pacing | Typewriter |
        | Phrase swaps where motion should stay quiet | Crossfade swap |
        
    • html-artifact-refs
      • design-system.md 4.9 KB
        # HTML Artifact Design System
        
        Design principles, theme selection, and quality rules for html-builder. CSS implementations are in `templates/themes/` — injected by `assemble-template.py`.
        
        ---
        
        ## Theme Selection
        
        **Default themes vary by shape** — see table below.
        
        | Shape | Default Theme | Rationale |
        |---|---|---|
        | spec | Birchline | Warm professional tone for comparison grids |
        | code-review | Dark Focus | Developer-familiar, high-contrast diffs |
        | prototype | Interactive Warm | Clean surface, prominent interactive controls |
        | report | Birchline | Professional, scannable long-form |
        | editor | Interactive Warm | Clear affordances, prominent shadows |
        | data-viz | Dark Focus | Charts pop on dark backgrounds |
        | diagram | Dark Focus | SVG elements pop, technical aesthetic |
        | deck | Dark Focus | Slide contrast, presentation-ready |
        
        **Fallback:** Minimal Document for long-form reading. Override any default with `--theme`.
        
        **Dark mode toggle:** Every artifact includes light/dark toggle (top-right). Request `theme-toggle` component via `assemble-template.py --components theme-toggle`.
        
        ---
        
        ## Theme Files (in templates/themes/)
        
        | Theme | File | Character |
        |---|---|---|
        | Birchline | `birchline.css` | Warm, earthy, professional. Clay accent (#D97757) |
        | Dark Focus | `dark-focus.css` | Dark bg, inner glows, blue accent (#64B5F6) |
        | Interactive Warm | `interactive-warm.css` | Clean white, blue accent (#5B8DEF), prominent shadows |
        | Minimal Document | `minimal-document.css` | Serif headings, 680px max-width, generous whitespace |
        
        ### Contrast Ratios (WCAG AA verified)
        
        | Theme | Text on Bg | Secondary on Bg | Accent on Bg |
        |---|---|---|---|
        | Dark Focus | 11.5:1 | 5.8:1 | 5.2:1 |
        | Interactive Warm | 12.8:1 | 7.0:1 | 4.6:1 (white on accent) |
        | Minimal Document | 12.4:1 | 7.5:1 | Muted 3.5:1 (large text only) |
        | Birchline | 14.0:1 | 7.2:1 | 5.1:1 |
        
        ---
        
        ## Token Architecture
        
        All themes share the same semantic alias layer. Components reference aliases, not raw values.
        
        | Layer | Examples | Purpose |
        |---|---|---|
        | Raw colors | `--color-primary`, `--color-danger` | Theme-specific palette |
        | Typography | `--type-body`, `--type-caption` | Font stacks with weight/size/line-height |
        | Spacing | `--sp-1` through `--sp-8` | 4px base scale |
        | Semantic | `--bg-page`, `--text-primary`, `--accent` | Component-facing aliases |
        
        **Rule:** Components use semantic aliases (`--bg-surface`, `--text-muted`, `--accent`). Never reference raw color values directly.
        
        ---
        
        ## Card Variants
        
        Six structural treatments. Use semantic aliases so cards adapt to any theme.
        
        | Variant | Class | Use For |
        |---|---|---|
        | Flat | `.card-flat` | Dense lists, inline content |
        | Outlined | `.card-outlined` | Comparison items, content cards |
        | Elevated | `.card-elevated` | Draggable items, interactive cards |
        | Accent stripe | `.card-accent` | Priority items, callouts |
        | Inset | `.card-inset` | Nested content, code blocks |
        | Horizontal | `.card-horizontal` | Scannable rows, search results |
        
        ---
        
        ## Responsive Breakpoints
        
        | Breakpoint | Width | Behavior |
        |---|---|---|
        | Mobile | < 640px | Single column, stacked |
        | Tablet | 640-1024px | 2 columns where applicable |
        | Desktop | > 1024px | Full layout, side-by-side panels |
        
        Use `min-width` media queries (mobile-first). Container max-width: 1200px.
        
        ---
        
        ## SVG Illustration Conventions
        
        | Property | Value |
        |---|---|
        | Dimensions | 720 x 320px viewBox (standard) |
        | Rendering | Flat -- no gradients, no drop shadows |
        | Stroke width | 1.5-2px |
        | Corner radius | rx="10" |
        | Label font | 11px monospace |
        | Annotation font | 12px sans-serif |
        | Color source | CSS custom properties via embedded `<style>` |
        | Self-contained | Embed `<style>` block inside the SVG |
        | Accessibility | `role="img"` + `aria-label` on every `<svg>` |
        
        ---
        
        ## Accessibility Checklist
        
        1. Color contrast: text on background >= 4.5:1 (normal), >= 3:1 (large text)
        2. Focus indicators: all interactive elements have `:focus-visible` styles
        3. Semantic HTML: headings in order, lists for lists, tables for tabular data
        4. Alt text: every `<img>` has `alt`, every `<svg>` has `role="img"` + `aria-label`
        5. Reduced motion: global reset handles via `prefers-reduced-motion`
        6. Touch targets: interactive elements minimum 44x44px hit area
        7. Language: `<html lang="en">` on root element
        
        ---
        
        ## Patterns to Replace
        
        | Common Mistake | Preferred Approach |
        |---|---|
        | CSS frameworks (Bootstrap, Tailwind CDN) | Use the token system via templates |
        | Random colors per artifact | Use theme tokens |
        | Hardcoded px values | Use `--sp-N` tokens and `--type-*` scale |
        | Dark theme = invert colors | Use Dark Focus preset with tuned contrast |
        | `outline: none` without replacement | Add `:focus-visible` with ring |
        | `<div onclick>` | Use `<button>` or `<a>` elements |
        | Heading level skipping (h1 to h3) | Sequential heading levels |
        | Text as images | Real text with CSS styling |
        | `color-mix()` without fallback | Provide fallback hex for critical paths |
        
      • diagram-layering.md 3.9 KB
        # Diagram Layering Reference
        
        SVG layering technique for architecture diagrams and flowcharts. Produces dark-theme, readable diagrams where arrows appear between components without endpoint bleed-through.
        
        ---
        
        ## Dark Design System Constants
        
        | Token | Value | Use |
        |---|---|---|
        | Background | `#0f172a` | Page background, masking rectangles |
        | Grid overlay | `#1e293b` | Subtle dashed grid lines |
        | Font | JetBrains Mono | Code-style labels; Google Fonts fallback acceptable |
        
        ---
        
        ## Semantic Color Palette
        
        | Category | Name | Hex | Use for |
        |---|---|---|---|
        | Primary | Cyan | `#06b6d4` | Frontend, entry points, user-facing components |
        | Secondary | Emerald | `#10b981` | Backend services, APIs |
        | Tertiary | Violet | `#8b5cf6` | Databases, storage |
        | Accent | Amber | `#f59e0b` | Cloud services, external integrations |
        | Alert | Rose | `#f43f5e` | Security, auth components |
        | Connector | Orange | `#f97316` | Queues, message brokers |
        | Neutral | Slate | `#64748b` | Utilities, shared services |
        | Active | Blue | `#3b82f6` | Currently active state indicator |
        
        Apply at 15-20% opacity for box fills; use full hex for borders and text.
        
        ---
        
        ## SVG Layering Order
        
        Render exactly these 7 layers in sequence. Rendering out of order causes arrow endpoints to bleed through component boxes.
        
        | Step | Layer | How |
        |---|---|---|
        | 1 | Background rectangle | `fill: #0f172a`, full SVG width x height |
        | 2 | Grid overlay | Dashed lines, stroke `#1e293b`, low opacity |
        | 3 | Region/boundary outlines | Dashed, `stroke-dasharray`, group containers only |
        | 4 | Connection arrows | Drawn BEFORE boxes so boxes can cover endpoints |
        | 5 | Opaque masking rectangles | Same fill as background (`#0f172a`), one per component position |
        | 6 | Component boxes | Semi-transparent fill (color at 15-20% opacity), solid colored border |
        | 7 | Text labels and legends | Color matches component category |
        
        ---
        
        ## The Masking Rectangle Technique
        
        **Problem:** Semi-transparent component boxes let arrow endpoints show through the fill, producing a visible stab at each connection point.
        
        **Solution:** After drawing all arrows (step 4), place a solid opaque rectangle at every component location using the background color. Draw the component box on top in step 6. The arrow is hidden under the mask at the component but fully visible between components.
        
        ```xml
        <!-- Step 4: arrow connecting two components -->
        <line x1="220" y1="70" x2="360" y2="70"
              stroke="#06b6d4" stroke-width="1.5" marker-end="url(#arrowCyan)"/>
        
        <!-- Step 5: masking rect covers the arrow endpoint at the destination -->
        <rect x="360" y="50" width="120" height="40" fill="#0f172a"/>
        
        <!-- Step 6: component box on top of the mask -->
        <rect x="360" y="50" width="120" height="40"
              fill="#06b6d420" stroke="#06b6d4" stroke-width="1.5" rx="4"/>
        <text x="420" y="74" fill="#06b6d4"
              font-family="JetBrains Mono, monospace" font-size="11"
              text-anchor="middle">API Gateway</text>
        ```
        
        The same technique works for curved paths (`<path>`) and diagonal lines.
        
        ---
        
        ## Typography Scale
        
        | Element | Size | Color | Notes |
        |---|---|---|---|
        | Title | 16px | `#e2e8f0` (slate-200) | Single diagram title |
        | Component labels | 11px | Matches component color | Centered in component box |
        | Annotations | 7-8px | `#94a3b8` (slate-400) | Callout notes, port labels |
        | Legend | 9px | `#94a3b8` | Bottom-left legend entries |
        
        ---
        
        ## Arrow Markers
        
        Define one `<marker>` per category color at the top of `<defs>`. Reference by color name in `marker-end` attributes to keep arrows semantically matched to their source component.
        
        ```xml
        <defs>
          <marker id="arrowCyan" markerWidth="10" markerHeight="7"
                  refX="9" refY="3.5" orient="auto">
            <polygon points="0 0, 10 3.5, 0 7" fill="#06b6d4"/>
          </marker>
          <!-- Repeat for each category color -->
        </defs>
        ```
        
        ---
        
        ## Load Signal
        
        Load when: shape = `diagram` detected by `detect-shape.py`, OR request contains "SVG", "architecture diagram", "flowchart", or "sequence diagram".
        
      • infographic-layouts.md 4.9 KB
        # Infographic Layouts Reference
        
        Layout taxonomy and visual style system for infographic generation. Covers 21 layout types and 22 visual styles with pre-tuned content-type pairings.
        
        ---
        
        ## Layout Types (21)
        
        ### Sequences
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `linear-progression` | Step-by-step flow, left to right | Processes, timelines under 8 steps |
        | `comic-strip` | Panel-based narrative | Storytelling, cause-and-effect |
        | `winding-roadmap` | Curving path with milestones | Journeys, multi-stage milestones |
        | `circular-flow` | Cycle that loops back to start | Recurring processes, ecosystems |
        
        ### Hierarchies
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `hierarchical-layers` | Top-down tiers | Org charts, classification systems |
        | `tree-branching` | Root with radiating branches | Taxonomies, decision trees |
        | `funnel` | Wide-to-narrow progression | Conversion funnels, filtering steps |
        | `story-mountain` | Tension arc: setup / rising / climax / falling / resolution | Narrative arcs |
        
        ### Comparisons
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `binary-comparison` | Two columns side by side | A vs B, before/after |
        | `comparison-matrix` | Grid: items on both axes | Feature matrices, 3-6 items x 3-6 criteria |
        | `venn-diagram` | Overlapping circles | Overlap between 2-3 groups |
        
        ### Spatial
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `isometric-map` | 3D-perspective grid | System architecture, city/place metaphors |
        | `structural-breakdown` | Exploded view of a system | Anatomy of a product or system |
        | `hub-spoke` | Central node with radiating connections | Ecosystems, central concepts |
        | `jigsaw` | Interlocking pieces | Components that fit together |
        
        ### Data-Focused
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `dashboard` | Metric cards + charts in a grid | KPI overviews, 5-12 metrics |
        | `periodic-table` | Element-card grid | Categorized collections, 9-25 items |
        | `bento-grid` | Unequal-size tiles | Feature showcases, asymmetric content |
        | `dense-modules` | Compact card grid | Knowledge cards, reference sheets, 20-50 items |
        
        ### Special
        
        | Layout | Structure | When to use |
        |---|---|---|
        | `iceberg` | Visible portion above, hidden bulk below | "More than meets the eye" concepts |
        | `bridge` | Two endpoints connected by an arch | Connecting two states or concepts |
        
        ---
        
        ## Visual Styles (22)
        
        | Style | Aesthetic | Best for |
        |---|---|---|
        | `craft-handmade` | Hand-drawn textures, paper feel | Personal, warm, approachable |
        | `cyberpunk-neon` | Dark bg, bright neon lines | Tech, futuristic, edgy |
        | `kawaii` | Cute, pastel, rounded | Consumer apps, playful |
        | `technical-schematic` | Blueprint, monochrome, precise | Engineering, technical docs |
        | `retro-pop-grid` | 80s grid, bold colors | Nostalgic, energetic |
        | `corporate-memphis` | Geometric shapes, bold outlines | Business, modern SaaS |
        | `minimal-flat` | White space, simple shapes | Clean, professional |
        | `isometric-3d` | 3D perspective, geometric | Architecture, product |
        | `watercolor-editorial` | Soft washes, editorial feel | Creative, premium |
        | `dark-premium` | Dark bg, gold accents | Luxury, sophisticated |
        | `newspaper` | High contrast, serif fonts | Editorial, authoritative |
        | `chalk-blackboard` | Dark bg, chalk texture | Educational, informal |
        | `data-sci-notebook` | Jupyter-like, light bg | Data science, research |
        | `vaporwave` | Gradient purples/pinks | Aesthetic, nostalgic |
        | `sketchnote` | Handwritten + sketches | Learning, notes |
        | `glassmorphism` | Frosted glass, blur | Modern UI, tech |
        | `brutalist` | Raw, bold borders, harsh | Edgy, statement |
        | `warm-editorial` | Warm tones, humanist | Lifestyle, wellness |
        | `science-textbook` | Clean, labeled diagrams | Academic, educational |
        | `comic-book` | Bold outlines, halftone | Fun, narrative |
        | `art-deco` | Geometric ornament, gold | Elegant, historic |
        | `y2k` | Chrome, gradients, pixel | Nostalgic, ironic |
        
        ---
        
        ## Content-Type to Layout Pairings
        
        Pre-tuned recommendations. Use as starting point; adjust for content specifics.
        
        | Content Type | Recommended Layout | Style Match |
        |---|---|---|
        | 5-step process | `linear-progression` | `corporate-memphis` or `minimal-flat` |
        | Timeline (6-12 events) | `winding-roadmap` | `watercolor-editorial` |
        | Recurring cycle | `circular-flow` | `isometric-3d` |
        | A vs B comparison | `binary-comparison` | `minimal-flat` |
        | Feature matrix (products) | `comparison-matrix` | `corporate-memphis` |
        | Taxonomy (10-20 items) | `periodic-table` | `technical-schematic` |
        | System architecture | `isometric-map` | `cyberpunk-neon` or `dark-premium` |
        | KPI dashboard | `dashboard` | `data-sci-notebook` |
        | Concept with hidden depth | `iceberg` | `dark-premium` |
        | Central concept + ecosystem | `hub-spoke` | `glassmorphism` |
        
        ---
        
        ## Load Signal
        
        Load when: shape = `data-viz` detected by `detect-shape.py`, OR request contains "infographic", "layout", "visual", "visualize data", or "chart type".
        
      • pdf-export.md 5.9 KB
        # PDF Export Reference
        
        Phase 6 EXPORT renders an html-artifact to PDF via Playwright. Optional, opt-in, additive — HTML stays the default deliverable.
        
        ---
        
        ## When Phase 6 fires
        
        Trigger conditions (any of these in the current turn's user message):
        
        - "PDF"
        - "export PDF"
        - "make a PDF"
        - "as PDF"
        - "send as PDF"
        - "save as PDF"
        - "PDF version"
        - "PDF export"
        
        If none of these signals are present, Phase 6 stays dormant. The HTML artifact is the deliverable; no follow-up nag.
        
        ---
        
        ## Invocation
        
        ```bash
        python3 skills/meta/html-artifact/scripts/to-pdf.py \
            --input <generated.html> \
            --output <generated.pdf> \
            --json
        ```
        
        Flags:
        
        | Flag | Purpose |
        |---|---|
        | `--input` | Path to the source `.html` file (required) |
        | `--output` | Path for the generated `.pdf` file (required) |
        | `--shape` | Override shape detection (one of the 8 shapes). Optional; `data-shape` attribute drives detection otherwise. |
        | `--json` | Emit machine-readable JSON: `{"output", "page_count", "shape", "bytes"}`. Without it, prints a friendly one-line summary. |
        
        ---
        
        ## Page-size map
        
        | Shape | Page size | Orientation | Margin |
        |---|---|---|---|
        | deck | 13.333in × 7.5in | landscape | 0 |
        | spec | Letter | landscape | 0.5in |
        | code-review | Letter | landscape | 0.5in |
        | prototype | Letter | landscape | 0.5in |
        | data-viz | Letter | landscape | 0.5in |
        | diagram | Letter | landscape | 0.5in |
        | report | Letter | portrait | 0.75in |
        | editor | Letter | portrait | 0.5in |
        | (fallback) | Letter | portrait | 0.5in |
        
        Deck dimensions match Google Slides / PowerPoint widescreen (16:9). One slide per page, no margin, full-bleed.
        
        ---
        
        ## Shape detection
        
        The script reads `<body data-shape="...">` to pick page settings.
        
        `assemble-template.py` adds the attribute automatically. For artifacts hand-crafted outside the assembler, pass `--shape <name>` explicitly. If neither is present, exit code 1 with a hint to re-assemble or supply the flag.
        
        `--shape` overrides the attribute when both are present. Useful for re-rendering a draft as a different shape.
        
        ---
        
        ## Page-count contract
        
        For deck shape, `page_count` in the JSON output equals the count of `class="slide"` occurrences in the HTML. Example:
        
        ```json
        {"output": "/abs/path.pdf", "page_count": 12, "shape": "deck", "bytes": 482104}
        ```
        
        For non-deck shapes, `page_count` is 0 (the PDF renderer doesn't expose page count without re-parsing the PDF; deck is the documented case where slide count drives expectations).
        
        ---
        
        ## Print stylesheets
        
        Each shape pairs with a print stylesheet under `templates/print/`:
        
        | File | Page setup |
        |---|---|
        | `default-print.css` | Letter portrait fallback |
        | `deck-print.css` | One slide per page, no margin, un-stick controls |
        | `spec-print.css` | Tabs collapse to stacked panels, code-block break-inside avoid |
        | `report-print.css` | Heading break-after avoid, table thead repeats |
        | `editor-print.css` | Kanban stacks, export bar un-sticks |
        | `code-review-print.css` | Diff lines no break, line numbers un-stick |
        | `prototype-print.css` | Controls hidden, preview full-width |
        | `data-viz-print.css` | Charts full-width, dashboard grid relaxes |
        | `diagram-print.css` | SVGs centered, captions stay with figures |
        
        Print CSS files self-declare `@page` and `@media print`. The assembler injects them as full stylesheets — no double-wrapping. (This was the May 9 bug fixed in commit `6b3e830d`.)
        
        ---
        
        ## Failure paths
        
        ### Playwright not installed
        
        Exit code 2. Stderr surfaces:
        
        ```
        Error: Playwright is not installed.
        Install with: pip install -e ".[pdf]" && playwright install chromium
        ```
        
        Two-step install is required: the Python package (`playwright`) and the browser binary (`chromium`). Skipping `playwright install chromium` produces a different runtime error from a launching browser missing executable.
        
        ### Missing `data-shape` attribute, no `--shape` flag
        
        Exit code 1. Stderr:
        
        ```
        Error: HTML artifact missing data-shape attribute. Re-assemble with assemble-template.py or pass --shape explicitly.
        ```
        
        ### Malformed HTML
        
        Exit code 1 if input lacks `<html>` or `<body>` tags, or is empty.
        
        ### Browser launch / page-load / page.pdf failure
        
        Exit code 3 with the exception type and message. Most common causes:
        
        - File URL escaping issue (paths with spaces — Playwright handles it, but a relative path without `file://` prefix won't load).
        - `networkidle` never fires (rare — happens if page has long-polling JS; HTML artifacts are static so this is unusual).
        - Disk full / output directory not writable.
        
        ---
        
        ## Troubleshooting
        
        ### Fonts render as system default instead of design tokens
        
        Birchline tokens use a system font stack (`-apple-system`, `Inter`, `Segoe UI`, …). Whatever's available locally renders. Embedded `@font-face` with a `data:` URL works in Playwright PDF; CDN font URLs do not (and would violate the self-contained constraint anyway).
        
        ### Images timing — blank rectangles in the PDF
        
        The script waits for `networkidle` (no in-flight requests for 500ms). If an artifact pulls a resource that races the load event, it won't appear. Fix: inline the resource as a data URL or `<svg>` rather than an external `<img src>`.
        
        ### Large file size
        
        Default PDFs are ~50–500KB. If a generated PDF exceeds 5MB, suspect inlined raster images that should be SVG or CSS, or runaway keyframe animations rendered as discrete frames.
        
        ### Color rendering — backgrounds missing
        
        Print CSS sets `print-color-adjust: exact` and `-webkit-print-color-adjust: exact`. If a generated artifact omits these, the browser strips background colors per print convention. Use `templates/print/{shape}-print.css` as the canonical pattern.
        
        ---
        
        ## What this feature does NOT do
        
        - Email the PDF.
        - Auto-trigger on every artifact (HTML stays default).
        - Open the PDF in a viewer.
        - Embed the PDF in another document.
        
        These are deliberately out of scope. Phase 6 produces a `.pdf` next to the `.html` and reports the path. Whatever ships it onward is the user's call.
        
      • pptx-export.md 10.1 KB
        # PPTX Export — Phase 7 (html-artifact deck shape)
        
        Phase 7 EXPORT-PPTX renders an html-artifact deck (`.html` with
        `<section class="slide">` blocks) into an editable Microsoft PowerPoint
        `.pptx` file. Mirrors Phase 6 EXPORT-PDF in shape: opt-in, signal-triggered,
        deterministic. HTML stays the source of truth; PPTX is a one-way export for
        hand-off.
        
        The bridge re-authors slides natively via `python-pptx` because no general
        HTML→PPTX converter preserves CSS-rich layout. The HTML is parsed into a
        layout-tagged JSON slide-map, then a typed builder paints native PowerPoint
        shapes per layout. Output is a 13.333 × 7.5 in (16:9) deck, dark navy theme,
        Aptos body / Cascadia Code mono, fully editable in PowerPoint 2023+ and
        Keynote.
        
        ---
        
        ## Trigger conditions
        
        Phase 7 fires only on explicit user signal:
        
        `pptx`, `.pptx`, `powerpoint`, `editable deck`, `editable`, `as pptx`,
        `export pptx`, `hand-off`, `corporate template`, `make a deck` (when paired
        with any of the above), `pitch deck` / `slide deck` (when paired with any of
        the above).
        
        Without one of these signals, html-artifact stops at Phase 5 DELIVER (HTML
        only). Phase 7 never auto-runs.
        
        ---
        
        ## Pipeline
        
        ```
        deck.html ──(extract_slides.py)──▶ slides.json ──(_pptx_engine.py)──▶ deck.pptx
                                                                    │
                                                                    ▼ (optional)
                                                               render_pptx.py
                                                                    │
                                                                    ▼
                                                             per-slide PNGs (QA)
        ```
        
        Single command:
        
        ```bash
        python3 skills/meta/html-artifact/scripts/pptx-bridge/run-unified.py \
            --input deck.html \
            --format pptx \
            --out deck.pptx \
            --no-render
        ```
        
        `--out` accepts either a directory (sibling `slides.json`, `report.md`,
        `render/` written next to the .pptx) or a `.pptx` file path (single-file
        mode; no siblings written).
        
        `--no-render` skips the optional LibreOffice QA render. Use it on hosts
        without `soffice` or when you only need the editable PPTX.
        
        Exit codes: `0` ok, `2` bad input or missing tool, `3` conversion failure.
        
        ---
        
        ## Layout types
        
        The extractor classifies each `<section class="slide">` into one of 12
        layout types. Each maps 1:1 to a builder in `_pptx_engine.LAYOUT_BUILDERS`.
        
        | Layout | HTML signal | Builder | Notes |
        |---|---|---|---|
        | `title` | `<section class="slide slide-title active">` | `build_title` | Eyebrow + giant title + subtitle, centered. |
        | `closing` | `<section class="slide slide-title">` (no `active`) | `build_closing` | Same shape as title; accent_text split out from h2 if present. |
        | `content` | `<ul>` of `<li>`, optional `<strong>` prefix | `build_content` | Eyebrow + title + lead + bulleted list + optional callout box. |
        | `metric_grid` | `.metric-grid` with `.metric` cards | `build_metric_grid` | Up to 4 cards horizontally; bigger value, label below, optional desc. |
        | `layer_rows` | `.layer-row` blocks | `build_layer_rows` | Stacked horizontal rows with name, count, desc. |
        | `pipeline` | `.pipeline` with `.pipeline-step` blocks | `build_pipeline` | Horizontal step boxes; label + name; optional caption. |
        | `code_block` | `.code-block` element | `build_code_block` | Mono-font panel; line-by-line color rules ($ = muted, > = accent, ✓ = success). |
        | `compare_table_2col` | `.compare-table` with 2 columns | `build_compare_table_2col` | Two-column side-by-side; cell role (`danger`/`success`/`label`) drives color. |
        | `compare_table_3col` | `.compare-table` with 3 columns | `build_compare_table_3col` | Three-column variant; smaller text, default role chain (`label`/`danger`/`success`). |
        | `outcome_grid` | `.outcome-grid` with `.outcome` cards | `build_outcome_grid` | 3-column card grid with heading + body. |
        | `split_narrow` | `.split-narrow` with two child columns | `build_split_narrow` | Left column prose + callout, right column CLI-row card. |
        | `section` / `section_divider` | Legacy fallback | `build_section_divider` | Eyebrow + large left-aligned title + subtitle. |
        
        **Type normalization:** the engine lowercases, strips, and replaces `-`/space
        with `_` before lookup. So `metric-grid`, `Metric Grid`, and `metric_grid`
        all hit the same builder. Unknown types fall back to `build_content`.
        
        **Public registry:** `_pptx_engine.SUPPORTED_LAYOUTS` is the set of accepted
        layout keys. Used by `run-unified.py`'s fidelity report to flag fall-throughs.
        
        ---
        
        ## THEME dict
        
        `_pptx_engine.THEME` is the single source of truth for v2 visual identity.
        Builders never inline hex codes — always reference `THEME["..."]`.
        
        | Key | RGB | Role |
        |---|---|---|
        | `bg` | `#1A1A2E` | Slide background (dark navy). |
        | `card_bg` | `#23233D` | Surface / card / metric background. |
        | `code_bg` | `#16162A` | Code panel background (slightly darker than bg). |
        | `border` | `#3A3A5C` | Default rectangle border. |
        | `fg` | `#E8E8F0` | Primary text (off-white). |
        | `fg_sec` | `#A0A0B8` | Secondary / lead body text. |
        | `muted` | `#6E6E8A` | Eyebrow, muted captions, header rows. |
        | `accent` | `#64B5F6` | Sky blue brand accent (eyebrow, bullets, accent text). |
        | `success` | `#81C784` | Softer green for dark bg (success roles, ✓ lines). |
        | `danger` | `#EF5350` | Red (danger roles). |
        | `font_body` | `Aptos` | Body text font; falls back to PowerPoint's substitution table on hosts without Aptos. |
        | `font_mono` | `Cascadia Code` | Code / inline-mono font. |
        
        PowerPoint 2023+ ships both Aptos and Cascadia Code. On older hosts, the
        viewer falls back to Calibri / Consolas via PowerPoint's font substitution.
        This is a deliberate portability trade-off — embedding fonts in `.pptx`
        roughly doubles file size and is not python-pptx-supported.
        
        The legacy `PALETTES["vexjoy-dark"]` dict is kept as a passthrough for old
        callers that pass a `design.palette` argument; all current builders read
        `THEME` directly.
        
        ---
        
        ## CLI reference
        
        ### `run-unified.py` — full pipeline
        
        ```bash
        python3 skills/meta/html-artifact/scripts/pptx-bridge/run-unified.py \
            --input <html_path> \
            --format pptx \
            --out <pptx_path_or_dir> \
            [--no-render]
        ```
        
        - `--input` — HTML deck file (must contain `<section class="slide">` blocks).
        - `--format` — `pptx` (default). `pdf` is rejected here; use
          `scripts/to-pdf.py` for HTML→PDF.
        - `--out` — `.pptx` file path (single-file mode) OR directory (writes the
          .pptx plus `slides.json`, `report.md`, and optional `render/` siblings).
        - `--no-render` — skip the optional LibreOffice QA step. Required on hosts
          without `soffice`.
        
        ### `extract_slides.py` — HTML → slide-map JSON
        
        ```bash
        python3 .../extract_slides.py --input deck.html --output slides.json
        ```
        
        Standalone extractor. Useful for inspecting the slide-map intermediate or
        hand-editing it before re-running the engine.
        
        ### `_pptx_engine.py` — slide-map JSON → .pptx
        
        ```bash
        python3 .../_pptx_engine.py --slide-map slides.json --design design.json --output deck.pptx
        ```
        
        `design.json` is `{"palette": "vexjoy-dark"}`; the THEME dict drives all
        visuals regardless. Useful for power users who hand-author slide-maps.
        
        ### `render_pptx.py` — .pptx → per-slide PNGs (QA only)
        
        ```bash
        python3 .../render_pptx.py --input deck.pptx --output-dir ./qa/ [--dpi 150] [--keep-pdf]
        ```
        
        Soft-dependency on `soffice` (LibreOffice) and optionally `pdftoppm`
        (poppler-utils). Used by the QA loop only; not required for the editable
        .pptx itself.
        
        ---
        
        ## Validation
        
        After Phase 7, `run-unified.py` writes `report.md` (in directory mode) with:
        
        | Axis | Pass criterion |
        |---|---|
        | `slide_count_match` | Got 12 slides for the vexjoy fixture; tolerance ±2. |
        | `text_frame_density` | ≥120 editable text frames (no rasterized text). |
        | `aspect_ratio_widescreen` | 13.33 × 7.50 in, aspect ≈ 1.778. |
        | `layout_coverage` | All extracted types map to native builders (no fall-throughs to default). |
        | `build_succeeded` | Output ≥10 KB and parses cleanly via python-pptx. |
        
        Each axis scores 0–2; total normalized to a 0–10 fidelity number. The
        vexjoy-agent-management fixture currently scores 10/10 deterministically.
        
        ---
        
        ## Failure modes
        
        | Symptom | Cause | Fix |
        |---|---|---|
        | `extractor produced 0 slides` | HTML has no `<section class="slide">` blocks. | Confirm the deck was generated by html-artifact's deck shape; hand-written HTML must use the `slide` class. |
        | `python-pptx not installed` | Missing dep. | `pip install python-pptx`. |
        | `pptx not produced` / size <1 KB | Engine raised an exception silently caught earlier. | Re-run with `python3 -X dev` or read the printed `FAIL: ...` line. |
        | Fonts wrong in PowerPoint | Host lacks Aptos / Cascadia Code. | Update to PowerPoint 2023+, or accept the substitution (Segoe UI / Consolas). |
        | QA render skipped | No `soffice` on PATH. | `brew install --cask libreoffice` (macOS) or `sudo apt install libreoffice-impress` (Debian). The PPTX itself is unaffected. |
        | Layout falls back to `content` | New layout type added in HTML but not registered in `LAYOUT_BUILDERS`. | Add a builder + extractor branch; update `SUPPORTED_LAYOUTS`. |
        
        ---
        
        ## Engine architecture
        
        `_pptx_engine.py` is organized in three layers:
        
        1. **THEME dict + `hex_to_rgb` / `get_palette`** — visual constants.
        2. **Low-level primitives** — `fill_bg`, `add_text`, `add_rect`,
           `add_eyebrow_and_title`. All theme-aware. Builders never call
           python-pptx directly for fill/line color.
        3. **Layout builders** — one function per layout type. Each takes
           `(prs, slide_data, _palette)` and returns the slide. Builders are pure
           functions over the THEME dict + slide data; no global state.
        
        `build_presentation(slide_map, design, output_path)` is the entry point:
        sets 16:9 dimensions, normalizes each slide's `type`, dispatches via
        `LAYOUT_BUILDERS`, saves.
        
        ---
        
        ## When NOT to use this
        
        - For a viewable deck on screen → keep the HTML; it has the best visuals
          and zero export step.
        - For a PDF → use `scripts/to-pdf.py` (Phase 6); it's faster than going
          through PPTX.
        - For a deck whose source is markdown / a slide-map JSON written by hand →
          call `_pptx_engine.py` directly with `--slide-map`. The HTML pipeline is
          for html-artifact-generated decks.
        
      • shape-diagram-illustration.md 4.3 KB
        # Shape: Diagram & Illustration
        
        > **Shape**: diagram | **Signal words**: architecture, flowchart, sequence, data-flow, figure sheet
        > CSS layout classes: `templates/shapes/diagram.css` (injected by assemble-template.py)
        
        ---
        
        ## Core Principle
        
        Diagrams are STRUCTURAL. Communicate relationships through spatial positioning, connection lines, and labeled nodes. Interactive features (click-to-expand, hover-to-highlight) reveal detail without cluttering the primary view.
        
        ---
        
        ## Layout Patterns
        
        | Layout | Use When | Structure |
        |---|---|---|
        | Single diagram + legend | One flowchart or architecture diagram | Full-width SVG, legend below |
        | Figure sheet | Multiple illustrations for a blog post | 2-3 column grid of labeled SVG panels |
        | Sequence diagram | Message passing between actors | Full-width SVG with lifelines, vertical flow |
        | Annotated diagram + callout panel | Complex system with numbered callouts | Diagram left (70%), callout list right (30%) |
        | Stacked diagrams | Before/after or layer views | Vertical stack with section headings |
        
        ---
        
        ## SVG Construction Rules
        
        | Element | Pattern | Notes |
        |---|---|---|
        | viewBox | `0 0 720 320` (standard) or `720 480` (tall) | Fixed coordinates; CSS scales to container |
        | Rendering | Flat, no gradients | Clean, technical aesthetic |
        | Stroke | `1.5` for boxes, `2` for emphasis | Consistent weight |
        | Corner radius | `rx="10"` | Matches `--radius-md` |
        | Labels | `font-size="11"`, `font-family="var(--font-mono)"` | Monospace for technical |
        | Node fill | `color-mix(in srgb, <color> 10%, var(--bg-page))` | Tinted background |
        | Node stroke | Full semantic color | `var(--color-info)`, `var(--color-primary)`, etc. |
        | Sync arrow | Solid line + `marker-end` | Gray stroke |
        | Async arrow | Dashed `stroke-dasharray="6 4"` | Info/blue stroke |
        | Grouping box | Dashed stroke `<rect>`, low-opacity fill | Groups related nodes |
        | Accessibility | `role="img"` + `aria-label` on every `<svg>` | Required |
        
        **Arrowhead markers:** Define in `<defs>` block. Standard (gray), accent (primary), async (info).
        
        ---
        
        ## Key CSS Classes (from templates/shapes/diagram.css)
        
        | Class | Purpose |
        |---|---|
        | `.diagram-container` | Bordered surface with horizontal scroll fallback |
        | `.diagram-legend` | Flex-wrap legend below diagram |
        | `.figure-grid` | Auto-fit grid for figure sheets |
        | `.figure-panel` | Bordered card with SVG + figcaption + copy button |
        | `.copy-svg-btn` | Appears on hover, copies SVG markup |
        | `.diagram-node` | Clickable SVG group with hover/focus states |
        | `.node-detail-panel` | Detail panel for clicked nodes |
        | `.callout-dot` | Numbered SVG circles for annotated diagrams |
        
        ---
        
        ## Interaction Patterns
        
        - **Click-to-expand:** Clicking a node shows detail below the diagram (`aria-live="polite"`)
        - **Hover-to-highlight:** Dim unrelated elements, highlight connected paths
        - **Copy SVG:** Button copies serialized SVG for reuse in other documents
        - **Callout panel:** Click numbered dots to highlight matching callout entry
        
        ---
        
        ## Diagram Type Templates
        
        | Type | Key Elements |
        |---|---|
        | Flowchart | Horizontal nodes + arrows, labeled edges |
        | Architecture | Layer groups (dashed boxes), nodes within layers, cross-layer arrows |
        | Sequence | Actor headers, dashed lifelines, activation bars, solid/dashed messages |
        | Data flow | Nodes with port labels, directional arrows, async (dashed) for queues |
        
        ---
        
        ## Common Mistakes
        
        | Mistake | Fix |
        |---|---|
        | External image references in SVG | All content inline -- no `<image href>` |
        | No legend | Always include legend mapping colors to categories |
        | Nodes without text labels | Every node needs a text label, not just color |
        | SVG not responsive | Use `viewBox` + CSS `width: 100%` + `min-width` for horizontal scroll |
        | Missing keyboard support for clickable nodes | Add `tabindex="0"` + `role="button"` + Enter/Space handler |
        | Hover-only interactions | Provide click alternative; hover is not available on touch |
        
        ---
        
        ## Accessibility
        
        - Every `<svg>` has `role="img"` and descriptive `aria-label`
        - Interactive nodes: `tabindex="0"`, `role="button"`, `aria-label`
        - Enter/Space activates focused nodes
        - Color not sole indicator: all nodes have text labels
        - Legend always present
        - `prefers-reduced-motion` disables SVG transitions
        - Detail panel uses `aria-live="polite"` for dynamic updates
        
    • threejs-builder-refs
      • advanced-animation.md 12.1 KB
        ---
        description: AnimationMixer with morph targets, skeletal animation, procedural IK approximation, GSAP integration, particle system animation via BufferGeometry attributes
        agent: threejs-builder
        category: visual-techniques
        version_range: "Three.js r150+"
        ---
        
        # Advanced Animation Reference
        
        > **Scope**: Three.js animation systems beyond basic rotation — AnimationMixer, morph targets, skeletal rigs, procedural IK, GSAP integration, and GPU-driven particle animation. GLTF model loading patterns live in gltf-loading.md.
        > **Version range**: Three.js r150+
        > **Generated**: 2026-04-08
        
        ---
        
        ## AnimationMixer with Morph Targets
        
        Morph targets (blend shapes) animate between pre-defined geometry states. Common for facial expressions and soft-body deformation.
        
        ```javascript
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        
        const loader = new GLTFLoader();
        const gltf = await loader.loadAsync('/models/character.glb');
        scene.add(gltf.scene);
        
        // AnimationMixer drives both skeleton and morph targets from GLTF
        const mixer = new THREE.AnimationMixer(gltf.scene);
        
        // Morph targets are accessed via mesh.morphTargetInfluences[]
        // Three.js r150+: morphTargetDictionary maps names to indices
        const mesh = gltf.scene.getObjectByName('Face');
        if (mesh && mesh.morphTargetDictionary) {
          const smileIndex = mesh.morphTargetDictionary['smile'];  // name from blender
          const angryIndex = mesh.morphTargetDictionary['angry'];
        
          // Animate morph weights directly (0.0 = neutral, 1.0 = full morph)
          mesh.morphTargetInfluences[smileIndex] = 0.0;
        
          // GSAP tween for smooth expression transition
          gsap.to(mesh.morphTargetInfluences, {
            [smileIndex]: 1.0,
            duration: 0.3,
            ease: 'power2.out',
          });
        }
        
        // Play an AnimationClip from the GLTF (drives skeleton + morph via keyframes)
        const clip = gltf.animations.find(a => a.name === 'Idle');
        if (clip) {
          const action = mixer.clipAction(clip);
          action.play();
        }
        
        // Update mixer in animation loop
        const clock = new THREE.Clock();
        renderer.setAnimationLoop(() => {
          const delta = clock.getDelta();
          mixer.update(delta); // delta in seconds, not ms
          renderer.render(scene, camera);
        });
        ```
        
        ### Crossfading Between Animations
        
        ```javascript
        // Crossfade from current action to next
        function crossFadeTo(mixer, fromAction, toAction, duration = 0.3) {
          toAction.enabled = true;
          toAction.setEffectiveTimeScale(1);
          toAction.setEffectiveWeight(1);
          toAction.time = 0;
        
          fromAction.crossFadeTo(toAction, duration, true);
          toAction.play();
        }
        
        const idleAction = mixer.clipAction(gltf.animations.find(a => a.name === 'Idle'));
        const walkAction = mixer.clipAction(gltf.animations.find(a => a.name === 'Walk'));
        
        idleAction.play();
        // Later:
        crossFadeTo(mixer, idleAction, walkAction, 0.3);
        ```
        
        ---
        
        ## Skeletal Animation and Bone Manipulation
        
        ### Direct Bone Manipulation (Procedural Override)
        
        ```javascript
        // Access skeleton from a SkinnedMesh
        const skinnedMesh = gltf.scene.getObjectByName('Body');
        const skeleton = skinnedMesh.skeleton;
        
        // List all bones
        skeleton.bones.forEach((bone, i) => {
          console.log(i, bone.name);
        });
        
        // Get specific bone by name
        const headBone = skeleton.bones.find(b => b.name === 'Head');
        const spineBone = skeleton.bones.find(b => b.name === 'Spine');
        
        // Rotate bones procedurally (overrides AnimationMixer for those bones)
        renderer.setAnimationLoop(() => {
          const t = clock.getElapsedTime();
          // Head tracks mouse position
          headBone.rotation.y = THREE.MathUtils.lerp(
            headBone.rotation.y,
            mouseTargetY,
            0.1 // lerp factor — controls follow speed
          );
          headBone.rotation.x = THREE.MathUtils.clamp(
            THREE.MathUtils.lerp(headBone.rotation.x, mouseTargetX, 0.1),
            -0.4, 0.4
          );
        
          mixer.update(clock.getDelta());
          renderer.render(scene, camera);
        });
        ```
        
        ### SkeletonHelper for Debug Visualization
        
        ```javascript
        // Visualize bone positions during development
        const helper = new THREE.SkeletonHelper(gltf.scene);
        helper.visible = true; // set false in production
        scene.add(helper);
        
        // Remove before shipping:
        // scene.remove(helper);
        // helper.dispose();
        ```
        
        ---
        
        ## Procedural Animation
        
        ### IK Approximation (Cyclic Coordinate Descent)
        
        Full IK solvers are complex. For 2-3 bone chains (arms, legs), CCD gives good results without a library:
        
        ```javascript
        // Two-bone IK: shoulder → elbow → wrist targeting a point
        function solveTwoBoneIK(root, mid, tip, target) {
          const rootPos = new THREE.Vector3().setFromMatrixPosition(root.matrixWorld);
          const midPos = new THREE.Vector3().setFromMatrixPosition(mid.matrixWorld);
          const tipPos = new THREE.Vector3().setFromMatrixPosition(tip.matrixWorld);
        
          const upperLen = rootPos.distanceTo(midPos);
          const lowerLen = midPos.distanceTo(tipPos);
          const totalLen = upperLen + lowerLen;
          const targetDist = rootPos.distanceTo(target);
        
          // Clamp target to reachable range
          const reach = Math.min(targetDist, totalLen * 0.99);
          const reachTarget = new THREE.Vector3()
            .subVectors(target, rootPos)
            .normalize()
            .multiplyScalar(reach)
            .add(rootPos);
        
          // Law of cosines for elbow angle
          const d = rootPos.distanceTo(reachTarget);
          const cosAngle = (upperLen * upperLen + d * d - lowerLen * lowerLen)
            / (2 * upperLen * d);
          const angle = Math.acos(THREE.MathUtils.clamp(cosAngle, -1, 1));
        
          // Apply rotation to root bone (simplified — assumes Y-up local space)
          const dir = new THREE.Vector3().subVectors(reachTarget, rootPos).normalize();
          root.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
          root.rotateZ(angle);
        }
        ```
        
        ### Spring Physics (Secondary Animation)
        
        ```javascript
        // Spring system for hair, tail, or accessory bounce
        class Spring {
          constructor(stiffness = 100, damping = 15, mass = 1) {
            this.stiffness = stiffness;
            this.damping = damping;
            this.mass = mass;
            this.position = 0;
            this.velocity = 0;
            this.target = 0;
          }
        
          update(delta) {
            const force = -this.stiffness * (this.position - this.target)
                          - this.damping * this.velocity;
            this.velocity += (force / this.mass) * delta;
            this.position += this.velocity * delta;
            return this.position;
          }
        }
        
        const tailSpring = new Spring(80, 12, 1);
        
        renderer.setAnimationLoop(() => {
          const delta = clock.getDelta();
          tailSpring.target = characterVelocityX * 0.3; // tail lags behind movement
          const tailAngle = tailSpring.update(delta);
          tailBone.rotation.z = tailAngle;
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ## GSAP Integration with Three.js Objects
        
        GSAP can tween any numeric property, including Three.js uniforms, positions, and quaternions:
        
        ```javascript
        import gsap from 'gsap';
        
        // Basic position tween
        gsap.to(mesh.position, {
          y: 2,
          duration: 1.5,
          ease: 'elastic.out(1, 0.4)',
        });
        
        // Rotation — use radians
        gsap.to(mesh.rotation, {
          y: Math.PI * 2,
          duration: 2,
          ease: 'power2.inOut',
        });
        
        // Shader uniform tween
        gsap.to(material.uniforms.uProgress, {
          value: 1.0,
          duration: 1.2,
          ease: 'power3.in',
          onUpdate: () => {
            material.needsUpdate = false; // uniforms don't need needsUpdate
          },
        });
        
        // Camera animation with timeline
        const tl = gsap.timeline({ defaults: { ease: 'power2.inOut', duration: 1.5 } });
        tl.to(camera.position, { x: 5, y: 3, z: 8 })
          .to(camera.position, { x: -5, y: 1, z: 5 }, '+=0.5') // 0.5s after previous
          .to(camera.position, { x: 0, y: 2, z: 10 }, '<0.3'); // 0.3s before previous end
        
        // Quaternion tween via fromRotationMatrix (GSAP doesn't natively support quaternions)
        const targetQuat = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI, 0));
        gsap.to({}, {
          duration: 1,
          ease: 'power2.inOut',
          onUpdate: function() {
            camera.quaternion.slerp(targetQuat, this.ratio);
          },
        });
        ```
        
        ---
        
        ## Particle System Animation via BufferGeometry
        
        GPU-friendly particle system using custom BufferGeometry attributes:
        
        ```javascript
        const count = 10000;
        const positions = new Float32Array(count * 3);
        const velocities = new Float32Array(count * 3);
        const lifetimes = new Float32Array(count);
        const phases = new Float32Array(count); // random phase offset per particle
        
        // Initialize
        for (let i = 0; i < count; i++) {
          const i3 = i * 3;
          positions[i3]     = (Math.random() - 0.5) * 20;
          positions[i3 + 1] = Math.random() * 10;
          positions[i3 + 2] = (Math.random() - 0.5) * 20;
        
          velocities[i3]     = (Math.random() - 0.5) * 0.02;
          velocities[i3 + 1] = Math.random() * 0.05;
          velocities[i3 + 2] = (Math.random() - 0.5) * 0.02;
        
          lifetimes[i] = Math.random() * 3.0 + 1.0; // 1-4 second lifetime
          phases[i] = Math.random() * Math.PI * 2;
        }
        
        const geometry = new THREE.BufferGeometry();
        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
        geometry.setAttribute('aVelocity', new THREE.BufferAttribute(velocities, 3));
        geometry.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1));
        
        const material = new THREE.PointsMaterial({
          size: 0.05,
          sizeAttenuation: true,
          transparent: true,
          alphaTest: 0.001,
          depthWrite: false,
        });
        
        const particles = new THREE.Points(geometry, material);
        scene.add(particles);
        
        // CPU-side animation (for < 10k particles)
        renderer.setAnimationLoop((time) => {
          const t = time * 0.001;
          const pos = geometry.attributes.position.array;
          const vel = geometry.attributes.aVelocity.array;
        
          for (let i = 0; i < count; i++) {
            const i3 = i * 3;
            pos[i3]     += vel[i3];
            pos[i3 + 1] += vel[i3 + 1];
            pos[i3 + 2] += vel[i3 + 2];
        
            // Reset when off-screen
            if (pos[i3 + 1] > 10) {
              pos[i3 + 1] = 0;
            }
          }
        
          geometry.attributes.position.needsUpdate = true;
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ## Pattern Catalog
        
        ### Pass Delta Time to mixer.update()
        
        **Detection**:
        ```bash
        grep -rn "mixer\.update" --include="*.js" --include="*.ts"
        rg "mixer\.update\(time\)" --type js
        ```
        
        **Signal**:
        ```javascript
        renderer.setAnimationLoop((time) => {
          mixer.update(time); // BAD: time is ms since page load (~120000ms after 2 minutes)
        });
        ```
        
        **Why this matters**: `mixer.update()` expects elapsed seconds since last frame (delta), not absolute timestamp. Passing raw `time` makes animations play at 1000x speed because time is in milliseconds.
        
        **Preferred action**:
        ```javascript
        const clock = new THREE.Clock();
        renderer.setAnimationLoop(() => {
          const delta = clock.getDelta(); // seconds since last call, typically 0.016 at 60fps
          mixer.update(delta);
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ### Apply Bone Overrides After mixer.update()
        
        **Detection**:
        ```bash
        grep -rn "bone\.rotation\|bone\.quaternion" --include="*.js" --include="*.ts"
        ```
        
        **Signal**:
        ```javascript
        renderer.setAnimationLoop(() => {
          mixer.update(delta); // sets bone transforms from keyframe data
          headBone.rotation.y = mouseTargetY; // immediately overwritten — no effect seen
        });
        ```
        
        **Why this matters**: `mixer.update()` overwrites bone transforms from animation keyframes on the same frame tick. Manual bone changes set before or on the same tick are overwritten.
        
        **Preferred action**: Apply bone overrides AFTER `mixer.update()` in the same frame:
        ```javascript
        renderer.setAnimationLoop(() => {
          mixer.update(delta);
          // Now override specific bones AFTER the mixer writes
          headBone.rotation.y += (mouseTargetY - headBone.rotation.y) * 0.1;
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ### Dispose Mixer on Component Unmount
        
        **Detection**:
        ```bash
        grep -rn "AnimationMixer" --include="*.js" --include="*.ts"
        rg "mixer\." --type js | grep -v "dispose\|update\|stopAll"
        ```
        
        **Signal**:
        ```javascript
        // In a React Three Fiber component:
        useEffect(() => {
          const mixer = new THREE.AnimationMixer(scene);
          // No cleanup
        }, []);
        ```
        
        **Why this matters**: `AnimationMixer` holds references to scene objects, preventing GC. Multiple mixers accumulate on route changes.
        
        **Preferred action**:
        ```javascript
        useEffect(() => {
          const mixer = new THREE.AnimationMixer(scene);
          const action = mixer.clipAction(clip);
          action.play();
        
          return () => {
            mixer.stopAllAction();
            mixer.uncacheRoot(scene); // releases internal refs
          };
        }, []);
        ```
        
        ---
        
        ## See Also
        
        - `references/gltf-loading.md` — GLTF model loading, SkeletonUtils.clone, asset manifests
        - `references/performance-patterns.md` — InstancedMesh for particle systems at scale
        - `references/shader-patterns.md` — GPU-driven animation via vertex shaders
        
      • advanced-topics.md 11 KB
        # Advanced Three.js Topics
        
        Progressive disclosure reference for topics beyond simple scenes.
        
        ---
        
        ## Loading 3D Models (GLTF/GLB)
        
        For loading external 3D models, use GLTFLoader from Three.js examples:
        
        ```html
        <script type="module">
            import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
            import { GLTFLoader } from 'https://unpkg.com/three@0.160.0/examples/jsm/loaders/GLTFLoader.js';
            import { OrbitControls } from 'https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js';
        
            const scene = new THREE.Scene();
            const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
            const renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            document.body.appendChild(renderer.domElement);
        
            // Lighting for model
            const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
            scene.add(ambientLight);
            const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
            directionalLight.position.set(5, 10, 7);
            scene.add(directionalLight);
        
            // Load model
            const loader = new GLTFLoader();
            loader.load(
                'path/to/model.glb',
                (gltf) => {
                    scene.add(gltf.scene);
                    camera.position.z = 5;
        
                    // Auto-center and scale
                    const box = new THREE.Box3().setFromObject(gltf.scene);
                    const center = box.getCenter(new THREE.Vector3());
                    const size = box.getSize(new THREE.Vector3());
                    gltf.scene.position.sub(center);
                    const maxDim = Math.max(size.x, size.y, size.z);
                    camera.position.z = maxDim * 2;
                },
                (progress) => {
                    console.log((progress.loaded / progress.total * 100) + '% loaded');
                },
                (error) => {
                    console.error('An error happened', error);
                }
            );
        
            const controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
        
            renderer.setAnimationLoop(() => {
                controls.update();
                renderer.render(scene, camera);
            });
        
            window.addEventListener('resize', () => {
                camera.aspect = window.innerWidth / window.innerHeight;
                camera.updateProjectionMatrix();
                renderer.setSize(window.innerWidth, window.innerHeight);
            });
        </script>
        ```
        
        ---
        
        ## Post-Processing (Bloom, Depth of Field)
        
        For visual effects like bloom, use the EffectComposer:
        
        ```html
        <script type="module">
            import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
            import { EffectComposer } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js';
            import { RenderPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/RenderPass.js';
            import { UnrealBloomPass } from 'https://unpkg.com/three@0.160.0/examples/jsm/postprocessing/UnrealBloomPass.js';
        
            // Basic setup...
            const renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.toneMapping = THREE.ReinhardToneMapping;
        
            // Post-processing
            const renderScene = new RenderPass(scene, camera);
        
            const bloomPass = new UnrealBloomPass(
                new THREE.Vector2(window.innerWidth, window.innerHeight),
                1.5,  // strength
                0.4,  // radius
                0.85  // threshold
            );
        
            const composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);
        
            renderer.setAnimationLoop(() => {
                composer.render();
            });
        </script>
        ```
        
        ---
        
        ## Custom Shaders (ShaderMaterial)
        
        For custom visual effects, write GLSL shaders:
        
        ```javascript
        const vertexShader = `
            varying vec2 vUv;
            void main() {
                vUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
        `;
        
        const fragmentShader = `
            uniform float time;
            varying vec2 vUv;
            void main() {
                vec3 color = 0.5 + 0.5 * cos(time + vUv.xyx + vec3(0, 2, 4));
                gl_FragColor = vec4(color, 1.0);
            }
        `;
        
        const material = new THREE.ShaderMaterial({
            vertexShader,
            fragmentShader,
            uniforms: {
                time: { value: 0 }
            }
        });
        
        renderer.setAnimationLoop((time) => {
            material.uniforms.time.value = time * 0.001;
            renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ## Text and Sprites
        
        For 2D text or labels in 3D space:
        
        ```javascript
        // Canvas-based text sprite
        function createTextSprite(message, scale = 1) {
            const canvas = document.createElement('canvas');
            const context = canvas.getContext('2d');
            canvas.width = 256;
            canvas.height = 64;
        
            context.fillStyle = 'rgba(0, 0, 0, 0)';
            context.fillRect(0, 0, canvas.width, canvas.height);
            context.font = 'Bold 24px Arial';
            context.fillStyle = 'white';
            context.textAlign = 'center';
            context.fillText(message, canvas.width / 2, canvas.height / 2);
        
            const texture = new THREE.CanvasTexture(canvas);
            const material = new THREE.SpriteMaterial({ map: texture });
            const sprite = new THREE.Sprite(material);
            sprite.scale.set(scale * 4, scale, 1);
            return sprite;
        }
        
        const label = createTextSprite('Hello Three.js!', 1);
        label.position.set(0, 2, 0);
        scene.add(label);
        ```
        
        ---
        
        ## Raycasting (Mouse Picking)
        
        For clicking/touching 3D objects:
        
        ```javascript
        const raycaster = new THREE.Raycaster();
        const mouse = new THREE.Vector2();
        
        window.addEventListener('click', (event) => {
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(scene.children);
        
            if (intersects.length > 0) {
                const object = intersects[0].object;
                // Do something with clicked object
                object.material.color.setHex(Math.random() * 0xffffff);
            }
        });
        ```
        
        ---
        
        ## Environment Maps (Reflections)
        
        For realistic reflections on metallic surfaces:
        
        ```javascript
        import { RGBELoader } from 'https://unpkg.com/three@0.160.0/examples/jsm/loaders/RGBELoader.js';
        
        const rgbeLoader = new RGBELoader();
        rgbeLoader.load('path/to/environment.hdr', (texture) => {
            texture.mapping = THREE.EquirectangularReflectionMapping;
            scene.environment = texture;
            scene.background = texture;
        });
        
        // Material with reflections
        const material = new THREE.MeshStandardMaterial({
            color: 0x444444,
            metalness: 1,
            roughness: 0.1
        });
        ```
        
        ---
        
        ## InstancedMesh (Many Similar Objects)
        
        For rendering thousands of identical objects efficiently:
        
        ```javascript
        const count = 1000;
        const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
        const material = new THREE.MeshStandardMaterial({ color: 0x44aa88 });
        const mesh = new THREE.InstancedMesh(geometry, material, count);
        
        const dummy = new THREE.Object3D();
        for (let i = 0; i < count; i++) {
            dummy.position.set(
                (Math.random() - 0.5) * 20,
                (Math.random() - 0.5) * 20,
                (Math.random() - 0.5) * 20
            );
            dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
            dummy.updateMatrix();
            mesh.setMatrixAt(i, dummy.matrix);
        }
        
        scene.add(mesh);
        ```
        
        ---
        
        ## Physics Integration (Cannon.js)
        
        For physics-based interactions:
        
        ```html
        <script type="module">
            import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
            import * as CANNON from 'https://unpkg.com/cannon-es@0.20.0/dist/cannon-es.js';
        
            // Three.js setup
            const scene = new THREE.Scene();
            const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
            const renderer = new THREE.WebGLRenderer();
            renderer.setSize(window.innerWidth, window.innerHeight);
            document.body.appendChild(renderer.domElement);
        
            // Cannon.js world
            const world = new CANNON.World();
            world.gravity.set(0, -9.82, 0);
        
            // Sync mesh with physics body
            const geometry = new THREE.SphereGeometry(0.5);
            const material = new THREE.MeshStandardMaterial({ color: 0xff6600 });
            const mesh = new THREE.Mesh(geometry, material);
            scene.add(mesh);
        
            const body = new CANNON.Body({
                mass: 1,
                shape: new CANNON.Sphere(0.5),
                position: new CANNON.Vec3(0, 5, 0)
            });
            world.addBody(body);
        
            // Ground
            const groundBody = new CANNON.Body({
                type: CANNON.Body.STATIC,
                shape: new CANNON.Plane()
            });
            groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
            world.addBody(groundBody);
        
            const timeStep = 1 / 60;
            renderer.setAnimationLoop(() => {
                world.step(timeStep);
                mesh.position.copy(body.position);
                mesh.quaternion.copy(body.quaternion);
                renderer.render(scene, camera);
            });
        </script>
        ```
        
        ---
        
        ## Installation with npm
        
        For production apps, install Three.js via npm:
        
        ```bash
        npm install three
        ```
        
        ```javascript
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        
        // Same API as CDN version
        ```
        
        ---
        
        ## TypeScript Support
        
        Three.js includes TypeScript definitions:
        
        ```typescript
        import * as THREE from 'three';
        
        const scene: THREE.Scene = new THREE.Scene();
        const geometry: THREE.BoxGeometry = new THREE.BoxGeometry(1, 1, 1);
        const material: THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
            color: 0x44aa88
        });
        const cube: THREE.Mesh = new THREE.Mesh(geometry, material);
        scene.add(cube);
        ```
        
        ---
        
        ## Key Module Import Paths (r160+)
        
        ```javascript
        // Core
        import * as THREE from 'three';
        
        // Addons (three/addons/ in npm, examples/jsm/ in CDN)
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        ```
        
        ---
        
        ## Performance Tips
        
        1. **Reuse geometries and materials**: Create once, use many times
        2. **Use InstancedMesh**: For 100+ identical objects
        3. **Limit shadow map resolution**: 1024-2048 is usually sufficient
        4. **Disable antialiasing**: For pixel art or performance-critical apps
        5. **Use frustum culling**: Objects outside view are skipped (automatic)
        6. **Merge geometries**: Combine static objects into one mesh
        7. **Use LOD (Level of Detail)**: Switch to simpler geometries at distance
        
        ```javascript
        // Geometry merging (requires addon import)
        import { BufferGeometryUtils } from 'three/addons/utils/BufferGeometryUtils.js';
        
        const geometries = [];
        for (let i = 0; i < 10; i++) {
            geometries.push(new THREE.BoxGeometry(1, 1, 1));
        }
        const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
        ```
        
        ---
        
        ## Debug Helpers
        
        ```javascript
        // Grid helper
        const gridHelper = new THREE.GridHelper(10, 10);
        scene.add(gridHelper);
        
        // Axes helper (RGB = XYZ)
        const axesHelper = new THREE.AxesHelper(5);
        scene.add(axesHelper);
        
        // Stats.js for performance monitoring
        import Stats from 'https://unpkg.com/three@0.160.0/examples/jsm/libs/stats.module.js';
        const stats = new Stats();
        document.body.appendChild(stats.dom);
        
        renderer.setAnimationLoop(() => {
            stats.begin();
            // render...
            stats.end();
        });
        ```
        
      • build-recipes.md 7.3 KB
        # Three.js Builder — Build Recipes and Error Handling
        
        ## Phase 1: Additional Reference Loading Signals
        
        **Visual quality signal**: If the user's request implies high visual quality (portfolio, game, showcase, "make it look good", "impressive", "polished"), also load `references/visual-polish.md` alongside the paradigm reference. It contains specific material recipes, lighting setups, and post-processing configurations that bridge the gap between technically correct and visually impressive.
        
        **Custom shader signal**: If the user mentions custom shaders, GLSL, ShaderMaterial, vertex displacement, postprocessing effects (bloom, chromatic aberration, dissolve), or custom visual effects, load `references/shader-patterns.md`. It contains complete GLSL patterns with working code, failure modes with detection commands, and the postprocessing pipeline setup.
        
        **Performance signal**: If the user mentions many objects (particles, foliage, crowds), InstancedMesh, performance profiling, draw call reduction, texture compression, or memory issues, load `references/performance-patterns.md`.
        
        **Advanced animation signal**: If the user mentions AnimationMixer, morph targets, skeletal rigs, IK, spring physics, GSAP + Three.js, or GPU particle animation, load `references/advanced-animation.md`.
        
        ## Phase 1: Scene Plan Template
        
        ```markdown
        ## Scene Plan
        - Geometry: [primitives or model loading]
        - Material: [Basic/Standard/Physical/Shader]
        - Lighting: [ambient + directional + fill / custom]
        - Animation: [rotation / wave / mouse / physics]
        - Controls: [OrbitControls / none / custom]
        - Extras: [post-processing / raycasting / particles]
        ```
        
        ---
        
        ## Phase 2: Core Constraints (Imperative Paradigm)
        
        - **Single HTML file output by default** unless user specifies otherwise
        - **Include resize handling** that caps `devicePixelRatio` at 2 and updates camera aspect on window change
        - **Use a top-level `CONFIG` object** for all visual constants (colors, speeds, sizes) — no magic numbers scattered through code
        - **Separate concerns into modular setup functions**: `createScene()`, `createLights()`, `createMeshes()` — this enables testing and reuse
        - **Include three-point lighting by default**: ambient light + directional light + fill light, unless user specifies a different lighting strategy
        - **Use `renderer.setAnimationLoop()` instead of manual `requestAnimationFrame()`** for cleaner animation setup
        
        ## Phase 3: Core Constraints (Imperative Paradigm)
        
        - **Never allocate geometry or materials inside the animation loop** — this causes garbage collection pauses and frame rate collapse
        - **Use the `time` parameter (in milliseconds) for time-based animation** — multiply by small factors (0.001, 0.0005) for smooth motion
        - **Include OrbitControls by default** for interactive scenes (unless user requests a specific control scheme) — but in R3F, OrbitControls from `@react-three/drei` conflicts with custom camera controllers; see the R3F reference for when to use which
        - **Transform only position, rotation, and scale per frame** — all geometry and materials are static
        
        ## Phase 4: Core Constraints
        
        - **Remove all debug helpers** (AxesHelper, GridHelper, Stats) unless user explicitly requested them
        - **Remove all commented-out code and TODO markers**
        - **Every scene must handle window resize** and render correctly at all viewport sizes
        - **Lighting must produce visible surfaces** — no black screens from missing lights
        - **Colors and visual style must match the intended context** — this is non-negotiable quality bar
        
        ---
        
        ## Phase 2: HTML Boilerplate (Imperative)
        
        Every app starts with this structure:
        
        ```html
        <!DOCTYPE html>
        <html lang="en">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>[App Title]</title>
            <style>
                * { margin: 0; padding: 0; box-sizing: border-box; }
                body { overflow: hidden; background: #000; }
                canvas { display: block; }
            </style>
        </head>
        <body>
            <script type="module">
                import * as THREE from 'https://unpkg.com/three@0.160.0/build/three.module.js';
                // Additional imports as needed
            </script>
        </body>
        </html>
        ```
        
        ## Phase 2: Scene Infrastructure (Imperative)
        
        ```javascript
        // CONFIG object at top level
        const CONFIG = {
            colors: { /* color hex values */ },
            speeds: { /* animation speeds */ },
            sizes: { /* geometric dimensions */ }
        };
        
        // Scene, camera, renderer
        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(
            75, window.innerWidth / window.innerHeight, 0.1, 1000
        );
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        document.body.appendChild(renderer.domElement);
        
        // Resize handler (always include)
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
        ```
        
        ## Phase 3: Animation Loop (Imperative)
        
        ```javascript
        renderer.setAnimationLoop((time) => {
            // Update animations
            // Update controls if present
            renderer.render(scene, camera);
        });
        ```
        
        Apply transforms per frame. Time-based animation should follow the pattern:
        ```javascript
        mesh.rotation.x += CONFIG.speeds.rotation * (time * 0.001);
        ```
        
        ---
        
        ## Phase 4: Polish Verification Steps
        
        **Step 1: Verify responsive behavior**
        - Resize browser window — canvas fills viewport without distortion
        - `devicePixelRatio` capped at 2
        - Test at common mobile/tablet/desktop breakpoints
        
        **Step 2: Verify visual quality**
        - Lighting produces visible surfaces (no black screen from missing lights)
        - Materials look correct (metalness/roughness values appropriate)
        - Colors and style match the intended context
        
        **Step 3: Test the output**
        - Open the HTML file in a browser or serve it locally
        - Confirm no console errors or warnings
        - Confirm animations and interactions work as intended
        
        **Step 4: Clean up**
        - Remove any debug helpers (AxesHelper, GridHelper, Stats) unless user wanted them
        - Ensure no commented-out code or TODO markers remain
        
        ---
        
        ## Error Handling
        
        ### Error: "Black Screen / Nothing Renders"
        Cause: Missing lights (StandardMaterial requires light), object not added to scene, or camera pointing wrong direction
        Solution:
        1. Verify at least one light is added to the scene (AmbientLight + DirectionalLight)
        2. Confirm all meshes are added with `scene.add(mesh)`
        3. Check camera position -- `camera.position.z = 5` as baseline
        4. If using BasicMaterial or NormalMaterial, lights are not the issue -- check geometry and camera
        
        ### Error: "OrbitControls is not defined"
        Cause: Incorrect import path or missing import statement
        Solution:
        1. For CDN: `import { OrbitControls } from 'https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js'`
        2. For npm: `import { OrbitControls } from 'three/addons/controls/OrbitControls.js'`
        3. Never use `THREE.OrbitControls` -- addons are not on the THREE namespace in modern Three.js
        
        ### Error: "Model Loads But Is Invisible or Tiny"
        Cause: Model scale/position does not match scene scale, or model is centered at wrong origin
        Solution:
        1. Compute bounding box: `new THREE.Box3().setFromObject(gltf.scene)`
        2. Center the model: `gltf.scene.position.sub(box.getCenter(new THREE.Vector3()))`
        3. Scale camera distance: `camera.position.z = Math.max(size.x, size.y, size.z) * 2`
        
      • game-architecture.md 10.4 KB
        # Game Architecture Reference
        
        Architecture patterns for Three.js games: EventBus, GameState, Constants, restart-safety,
        and pre-ship validation. Load this file when the project has game-like structure (enemies,
        scoring, player lifecycle, multiple game systems).
        
        ---
        
        ## EventBus: Decoupled System Communication
        
        The EventBus is a central message bus that lets game systems (audio, visuals, scoring,
        networking) communicate without direct references. This is the core decoupling pattern
        for games with multiple interacting systems.
        
        ```javascript
        class EventBus {
          constructor() {
            this._listeners = {}
          }
        
          on(event, callback) {
            if (!this._listeners[event]) this._listeners[event] = []
            this._listeners[event].push(callback)
            // Return unsubscribe function
            return () => this.off(event, callback)
          }
        
          off(event, callback) {
            if (!this._listeners[event]) return
            this._listeners[event] = this._listeners[event].filter((cb) => cb !== callback)
          }
        
          emit(event, data) {
            const listeners = this._listeners[event] ?? []
            listeners.forEach((cb) => cb(data))
          }
        
          // Remove all listeners for an event — use during restart
          clear(event) {
            delete this._listeners[event]
          }
        
          // Remove ALL listeners — use on full reset
          reset() {
            this._listeners = {}
          }
        }
        
        // Singleton — one bus for the whole game
        export const bus = new EventBus()
        ```
        
        ### SPECTACLE_* Event Convention
        
        Pre-wire visual effect events using the `SPECTACLE_` prefix. All visual/audio systems
        subscribe to these events. Game logic emits them — it never calls visual systems directly.
        
        ```javascript
        // Game events — emitted by logic, consumed by any number of systems
        const EVENTS = {
          // Spectacle events: visual/audio systems subscribe to these
          SPECTACLE_EXPLOSION:   'spectacle:explosion',    // { position, radius, intensity }
          SPECTACLE_HIT:         'spectacle:hit',          // { position, attacker, defender }
          SPECTACLE_LEVELUP:     'spectacle:levelup',      // { position, level }
          SPECTACLE_DEATH:       'spectacle:death',        // { position, entity }
          SPECTACLE_COLLECTIBLE: 'spectacle:collectible',  // { position, type }
        
          // Game state events
          GAME_START:   'game:start',
          GAME_PAUSE:   'game:pause',
          GAME_RESUME:  'game:resume',
          GAME_OVER:    'game:over',    // { score, cause }
          GAME_RESTART: 'game:restart',
        
          // Gameplay events
          PLAYER_DAMAGED:  'player:damaged',   // { amount, source }
          PLAYER_DIED:     'player:died',
          ENEMY_KILLED:    'enemy:killed',     // { enemy, position, points }
          SCORE_CHANGED:   'score:changed',    // { score, delta }
        }
        
        // Visual system subscribes — doesn't know who emitted
        bus.on(EVENTS.SPECTACLE_EXPLOSION, ({ position, radius }) => {
          spawnExplosionParticles(position, radius)
          screenShake(0.3)
        })
        
        // Audio system subscribes independently
        bus.on(EVENTS.SPECTACLE_EXPLOSION, ({ intensity }) => {
          playSound('explosion', { volume: intensity })
        })
        
        // Scoring system subscribes to game events
        bus.on(EVENTS.ENEMY_KILLED, ({ points }) => {
          gameState.score += points
          bus.emit(EVENTS.SCORE_CHANGED, { score: gameState.score, delta: points })
        })
        
        // Game logic emits — no knowledge of what subscribes
        function killEnemy(enemy) {
          const points = ENEMY_POINTS[enemy.type]
          scene.remove(enemy.mesh)
          bus.emit(EVENTS.SPECTACLE_DEATH, { position: enemy.position, entity: enemy })
          bus.emit(EVENTS.ENEMY_KILLED, { enemy, position: enemy.position, points })
        }
        ```
        
        ---
        
        ## GameState Singleton
        
        Centralized, reset-capable state. All systems read from here. Mutation goes through
        methods that emit events as side effects.
        
        ```javascript
        class GameState {
          constructor() {
            this.reset()
          }
        
          reset() {
            this.score = 0
            this.health = CONSTANTS.PLAYER_MAX_HEALTH
            this.level = 1
            this.lives = 3
            this.inventory = []
            this.isRunning = false
            this.isPaused = false
            this.elapsedTime = 0
            this.enemiesKilled = 0
          }
        
          start() {
            this.reset()
            this.isRunning = true
            bus.emit(EVENTS.GAME_START, {})
          }
        
          pause() {
            this.isPaused = true
            bus.emit(EVENTS.GAME_PAUSE, {})
          }
        
          resume() {
            this.isPaused = false
            bus.emit(EVENTS.GAME_RESUME, {})
          }
        
          takeDamage(amount) {
            this.health = Math.max(0, this.health - amount)
            bus.emit(EVENTS.PLAYER_DAMAGED, { amount, source: null })
        
            if (this.health <= 0) {
              this.isRunning = false
              bus.emit(EVENTS.GAME_OVER, { score: this.score, cause: 'health' })
            }
          }
        
          addScore(points) {
            this.score += points
            bus.emit(EVENTS.SCORE_CHANGED, { score: this.score, delta: points })
          }
        
          levelUp() {
            this.level++
            bus.emit(EVENTS.SPECTACLE_LEVELUP, { level: this.level })
          }
        }
        
        export const gameState = new GameState()
        ```
        
        ---
        
        ## Constants Module
        
        All magic numbers in one file. Constants are read-only, grouped by system.
        No number should appear in game logic without a name from this module.
        
        ```javascript
        // constants.js
        export const CONSTANTS = {
          // Physics
          GRAVITY: -20,
          JUMP_FORCE: 8,
          PLAYER_SPEED: 5,
          PLAYER_SPRINT_MULTIPLIER: 1.8,
        
          // Player
          PLAYER_MAX_HEALTH: 100,
          PLAYER_INVINCIBILITY_FRAMES: 0.5,  // seconds
        
          // Enemies
          ENEMY_SPEED_BASE: 2.5,
          ENEMY_SPEED_BOSS: 1.2,
          ENEMY_DETECTION_RADIUS: 15,
          ENEMY_ATTACK_RANGE: 1.5,
          ENEMY_ATTACK_DAMAGE: 10,
          ENEMY_ATTACK_COOLDOWN: 1.0,  // seconds
        
          // Spawning
          SPAWN_INTERVAL_INITIAL: 5.0,    // seconds between waves
          SPAWN_INTERVAL_MIN: 1.0,        // fastest spawn rate
          SPAWN_COUNT_INITIAL: 3,
          SPAWN_COUNT_MAX: 20,
        
          // Scoring
          POINTS_ENEMY_BASIC: 10,
          POINTS_ENEMY_ELITE: 50,
          POINTS_ENEMY_BOSS: 500,
          SCORE_MULTIPLIER_COMBO: 1.5,
        
          // Visual
          CAMERA_FOV: 60,
          CAMERA_NEAR: 0.1,
          CAMERA_FAR: 1000,
          SHADOW_MAP_SIZE: 2048,
        
          // Delta cap — prevent physics explosion after tab-away
          MAX_DELTA: 0.05,
        }
        ```
        
        ---
        
        ## Restart-Safety
        
        Every game system must cleanly reset. This is non-negotiable — stale state from a
        previous game session causes impossible-to-reproduce bugs.
        
        **Restart-safety checklist for each system**:
        1. Remove all scene objects added during the session
        2. Cancel all scheduled callbacks (setTimeout, setInterval, mixer listeners)
        3. Reset all internal state variables to initial values
        4. Unsubscribe event listeners added during the session (or call `bus.reset()`)
        
        ```javascript
        class EnemySystem {
          constructor() {
            this.enemies = []
            this.spawnTimer = 0
            this._unsubscribers = []
          }
        
          init() {
            // Subscribe and store unsubscribe functions
            this._unsubscribers.push(
              bus.on(EVENTS.GAME_OVER, () => this.reset()),
              bus.on(EVENTS.GAME_RESTART, () => this.init())
            )
          }
        
          reset() {
            // Remove all enemy meshes from scene
            this.enemies.forEach((e) => {
              scene.remove(e.mesh)
              e.mesh.geometry.dispose()
              e.mesh.material.dispose()
            })
            this.enemies = []
            this.spawnTimer = 0
        
            // Unsubscribe listeners from previous session
            this._unsubscribers.forEach((unsub) => unsub())
            this._unsubscribers = []
          }
        
          update(delta) {
            if (!gameState.isRunning) return
            // ... spawn and update logic
          }
        }
        
        // Top-level game restart handler
        function restartGame() {
          // 1. Reset all systems
          enemySystem.reset()
          projectileSystem.reset()
          uiSystem.reset()
          particleSystem.reset()
        
          // 2. Reset shared state
          gameState.reset()
          bus.reset()  // Clear all event subscriptions
        
          // 3. Re-initialize systems (re-subscribe after bus.reset)
          enemySystem.init()
          projectileSystem.init()
          uiSystem.init()
        
          // 4. Start fresh
          gameState.start()
        }
        ```
        
        **Restart stress test**: Run `restartGame()` 5 times in rapid succession from the browser
        console. No console errors, no ghost enemies, no leaked subscriptions, score starts at 0.
        This is the acceptance test for restart-safety.
        
        ---
        
        ## Pre-Ship Checklist
        
        Run this before delivery. Every item is a pass/fail check, not a judgment call.
        
        ```markdown
        ## Build
        - [ ] `npm run build` completes without errors
        - [ ] Output file size is reasonable (< 5MB for typical games)
        
        ## Runtime
        - [ ] No console errors on initial load
        - [ ] No console errors after 60 seconds of play
        - [ ] No console errors after restart
        
        ## Responsiveness
        - [ ] Canvas fills viewport at 1280x720
        - [ ] Canvas fills viewport at 375x812 (iPhone)
        - [ ] Resize mid-game does not break layout or camera aspect ratio
        
        ## Input
        - [ ] Keyboard controls work (WASD / arrow keys)
        - [ ] Mouse/touch controls work on mobile
        - [ ] Virtual joystick appears on touch devices (if applicable)
        
        ## Game Loop
        - [ ] Restart works without page reload
        - [ ] Restart works 5 times rapidly — no leaked state
        - [ ] Pause/resume works correctly
        
        ## Performance
        - [ ] > 30fps on target hardware at target resolution
        - [ ] No memory leaks after 5 minutes of play (Chrome DevTools > Memory > Heap snapshot)
        - [ ] `Math.min(delta, MAX_DELTA)` applied — verified by pausing tab and resuming
        
        ## Audio (if applicable)
        - [ ] Audio plays on first user interaction
        - [ ] Audio does not play before user interaction (browser policy)
        - [ ] Volume levels balanced
        
        ## Final
        - [ ] No debug console.log statements in production build
        - [ ] No AxesHelper or GridHelper visible
        - [ ] All placeholder text/assets replaced with final content
        ```
        
        ---
        
        ## Error Handling
        
        ### Ghost enemies after restart
        
        Cause: Enemy meshes not removed from scene, or `enemies` array not cleared on reset.
        Solution: In `reset()`, call `scene.remove(e.mesh)` for every enemy, then set `this.enemies = []`.
        
        ### Score persisting across restarts
        
        Cause: `gameState.score` not reset, or UI not re-reading from `gameState`.
        Solution: `gameState.reset()` must be called first in `restartGame()`, before system init.
        
        ### Event listeners firing multiple times per event
        
        Cause: `bus.on()` called in `init()` but `bus.off()` not called before next `init()` call.
        Solution: Store and call unsubscribers before re-subscribing, or call `bus.reset()` before
        re-running `init()` on all systems.
        
        ### Memory growing steadily during play
        
        Cause: Geometries and materials not disposed when enemies are removed.
        Solution: Call `mesh.geometry.dispose()` and `mesh.material.dispose()` when removing objects.
        For textures: `texture.dispose()`. Three.js does NOT garbage-collect GPU resources automatically.
        
        ### Delta cap causing slow-motion after tab-away
        
        Cause: This is the correct behavior — the cap prevents physics explosion at the cost of
        running slower than real-time for one frame after a long pause.
        Solution: This is a feature. The alternative (uncapped delta) causes tunneling and explosions.
        If the effect is noticeable, reduce `MAX_DELTA` further (e.g., `0.033` = 30fps minimum).
        
      • game-patterns.md 12.5 KB
        # Game Patterns Reference
        
        Game-specific Three.js patterns for animation state machines, player controllers,
        camera-relative movement, delta capping, and mobile input.
        Load this file alongside the paradigm reference when building games.
        
        ---
        
        ## Animation State Machine
        
        Character animation in games requires blending between clips (idle, walk, run, attack)
        with smooth crossfades and one-shot handling (death, hit reactions).
        
        ```javascript
        class AnimationController {
          constructor(model, animations) {
            this.mixer = new THREE.AnimationMixer(model)
            this.clips = {}
            this.currentAction = null
        
            // Index clips by name for fast lookup
            animations.forEach((clip) => {
              this.clips[clip.name] = clip
            })
          }
        
          switchAnimation(name, options = {}) {
            const { fadeTime = 0.1, clampWhenFinished = false } = options
        
            const clip = this.clips[name]
            if (!clip) {
              console.warn(`Animation "${name}" not found. Available:`, Object.keys(this.clips))
              return
            }
        
            const nextAction = this.mixer.clipAction(clip)
        
            // Don't restart if already playing this animation
            if (this.currentAction === nextAction) return
        
            nextAction.reset()
            nextAction.clampWhenFinished = clampWhenFinished
            nextAction.loop = clampWhenFinished ? THREE.LoopOnce : THREE.LoopRepeat
        
            if (this.currentAction) {
              this.currentAction.crossFadeTo(nextAction, fadeTime, true)
            }
        
            nextAction.play()
            this.currentAction = nextAction
          }
        
          // Safe clip selection for ambient entities (skip death/die/dead clips)
          playAmbientAnimation(preferredName = 'Idle') {
            const EXCLUDED = /death|die|dead|ko|ragdoll/i
        
            // Try preferred name first
            if (this.clips[preferredName] && !EXCLUDED.test(preferredName)) {
              this.switchAnimation(preferredName)
              return
            }
        
            // Fall back to first non-excluded clip
            const safeClip = Object.keys(this.clips).find((name) => !EXCLUDED.test(name))
            if (safeClip) {
              this.switchAnimation(safeClip)
            } else {
              console.warn('No safe ambient animation found — all clips match exclusion filter')
            }
          }
        
          // One-shot: play once and return to previous animation
          playOnce(name, fadeTime = 0.1, returnTo = 'Idle') {
            const clip = this.clips[name]
            if (!clip) return
        
            const action = this.mixer.clipAction(clip)
            action.reset()
            action.clampWhenFinished = true
            action.loop = THREE.LoopOnce
        
            if (this.currentAction) {
              this.currentAction.crossFadeTo(action, fadeTime, true)
            }
        
            action.play()
            this.currentAction = action
        
            // Listen for finish and crossfade back
            const onFinish = (e) => {
              if (e.action === action) {
                this.switchAnimation(returnTo, { fadeTime })
                this.mixer.removeEventListener('finished', onFinish)
              }
            }
            this.mixer.addEventListener('finished', onFinish)
          }
        
          update(delta) {
            this.mixer.update(delta)
          }
        }
        
        // Usage:
        const anim = new AnimationController(character, gltf.animations)
        anim.switchAnimation('Idle')
        
        // In game loop:
        anim.update(delta)
        
        // On input change:
        if (isMoving) anim.switchAnimation('Walk', { fadeTime: 0.2 })
        else anim.switchAnimation('Idle', { fadeTime: 0.3 })
        
        // On damage:
        anim.playOnce('Hit', 0.05, 'Idle')
        ```
        
        ---
        
        ## Camera-Relative Movement
        
        For isometric and third-person games, the player's movement direction must come from
        the camera's orientation — not world axes. Moving "forward" means moving in the direction
        the camera is facing, projected onto the ground plane.
        
        ```javascript
        // Assumes: camera is the Three.js camera, inputDir is a normalized {x, z} input vector
        // where x = strafe, z = forward/back from player perspective
        
        const cameraForward = new THREE.Vector3()
        const cameraRight = new THREE.Vector3()
        const moveDirection = new THREE.Vector3()
        
        function getCameraRelativeMovement(camera, input) {
          // Extract camera forward projected onto XZ plane (ground)
          camera.getWorldDirection(cameraForward)
          cameraForward.y = 0
          cameraForward.normalize()
        
          // Camera right is perpendicular to forward in XZ plane
          cameraRight.crossVectors(cameraForward, new THREE.Vector3(0, 1, 0)).normalize()
        
          // Combine: forward/back from camera direction, strafe from camera right
          moveDirection.set(0, 0, 0)
          moveDirection.addScaledVector(cameraForward, -input.z)  // -z = forward in most input conventions
          moveDirection.addScaledVector(cameraRight, input.x)
        
          if (moveDirection.length() > 0) moveDirection.normalize()
        
          return moveDirection
        }
        
        // In game loop:
        const input = { x: keys.d - keys.a, z: keys.s - keys.w }  // -1 to +1
        const move = getCameraRelativeMovement(camera, input)
        player.position.addScaledVector(move, speed * delta)
        
        // Face the direction of travel
        if (move.length() > 0.01) {
          const targetAngle = Math.atan2(move.x, move.z)
          player.rotation.y = THREE.MathUtils.lerp(
            player.rotation.y,
            targetAngle,
            10 * delta  // Rotation speed — higher = snappier
          )
        }
        ```
        
        ---
        
        ## Delta Capping
        
        After a tab-away or window focus loss, `requestAnimationFrame` delivers a large delta
        (several seconds of elapsed time). Uncapped, this causes physics objects to tunnel through
        geometry, velocities to explode, and animations to skip.
        
        **Always cap delta before physics or animation updates.**
        
        ```javascript
        renderer.setAnimationLoop((timestamp) => {
          const rawDelta = clock.getDelta()
        
          // Cap at 50ms (= 20fps minimum effective simulation rate)
          // Prevents physics explosion after tab-away or browser pause
          const delta = Math.min(rawDelta, 0.05)
        
          updatePhysics(delta)
          updateAnimations(delta)
          renderer.render(scene, camera)
        })
        ```
        
        Without the cap, a 5-second tab-away delivers delta=5.0 to physics — projectiles
        teleport through walls, enemies fly off-screen, and spring systems explode.
        
        ---
        
        ## Player Controller
        
        Separate physics state from render state. Input is read each frame, applied to velocity,
        velocity applied to position, position applied to the mesh.
        
        ```javascript
        class PlayerController {
          constructor(mesh, config = {}) {
            this.mesh = mesh
            this.position = mesh.position.clone()
            this.velocity = new THREE.Vector3()
        
            this.speed = config.speed ?? 5
            this.jumpForce = config.jumpForce ?? 8
            this.gravity = config.gravity ?? -20
            this.groundY = config.groundY ?? 0
        
            this.isGrounded = true
            this.input = { x: 0, z: 0, jump: false }
          }
        
          setInput(x, z, jump = false) {
            this.input.x = x
            this.input.z = z
            this.input.jump = jump
          }
        
          update(delta, camera) {
            // Camera-relative horizontal movement
            const move = getCameraRelativeMovement(camera, this.input)
            this.velocity.x = move.x * this.speed
            this.velocity.z = move.z * this.speed
        
            // Jump
            if (this.input.jump && this.isGrounded) {
              this.velocity.y = this.jumpForce
              this.isGrounded = false
            }
        
            // Gravity
            if (!this.isGrounded) {
              this.velocity.y += this.gravity * delta
            }
        
            // Apply velocity
            this.position.addScaledVector(this.velocity, delta)
        
            // Ground collision (simple flat ground)
            if (this.position.y <= this.groundY) {
              this.position.y = this.groundY
              this.velocity.y = 0
              this.isGrounded = true
            }
        
            // Sync mesh
            this.mesh.position.copy(this.position)
        
            // Face direction of travel
            if (Math.abs(this.velocity.x) + Math.abs(this.velocity.z) > 0.1) {
              const angle = Math.atan2(this.velocity.x, this.velocity.z)
              this.mesh.rotation.y = THREE.MathUtils.lerp(this.mesh.rotation.y, angle, 10 * delta)
            }
          }
        }
        
        // Key binding setup
        const keys = { w: false, a: false, s: false, d: false, space: false }
        window.addEventListener('keydown', (e) => { keys[e.key.toLowerCase()] = true })
        window.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false })
        
        // In game loop:
        const input = {
          x: (keys.d ? 1 : 0) - (keys.a ? 1 : 0),
          z: (keys.s ? 1 : 0) - (keys.w ? 1 : 0),
        }
        player.setInput(input.x, input.z, keys[' '])
        player.update(delta, camera)
        ```
        
        ---
        
        ## Mobile Input
        
        ### Gyroscope Tilt Controls
        
        ```javascript
        class GyroscopeControls {
          constructor() {
            this.alpha = 0  // Z-axis rotation (compass)
            this.beta = 0   // X-axis tilt (front/back)
            this.gamma = 0  // Y-axis tilt (left/right)
            this.enabled = false
          }
        
          async requestPermission() {
            // iOS 13+ requires explicit permission
            if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
              const state = await DeviceOrientationEvent.requestPermission()
              if (state !== 'granted') return false
            }
            window.addEventListener('deviceorientation', (e) => this.onOrientation(e))
            this.enabled = true
            return true
          }
        
          onOrientation(e) {
            this.alpha = e.alpha ?? 0  // 0-360°
            this.beta = e.beta ?? 0    // -180 to 180° (front/back tilt)
            this.gamma = e.gamma ?? 0  // -90 to 90° (left/right tilt)
          }
        
          // Returns normalized input vector from tilt
          getInput(deadzone = 5, maxTilt = 30) {
            const x = THREE.MathUtils.clamp(this.gamma / maxTilt, -1, 1)
            const z = THREE.MathUtils.clamp(this.beta / maxTilt, -1, 1)
            return {
              x: Math.abs(this.gamma) > deadzone ? x : 0,
              z: Math.abs(this.beta) > deadzone ? z : 0,
            }
          }
        }
        
        // Activate on user gesture (required by browsers):
        document.getElementById('startBtn').addEventListener('click', async () => {
          const gyro = new GyroscopeControls()
          const granted = await gyro.requestPermission()
          if (!granted) console.warn('Gyroscope permission denied')
        })
        ```
        
        ### Virtual Joystick Overlay
        
        ```javascript
        class VirtualJoystick {
          constructor(container) {
            this.input = { x: 0, z: 0 }
            this.active = false
            this.center = { x: 0, y: 0 }
            this.radius = 60  // px
        
            this.stick = document.createElement('div')
            Object.assign(this.stick.style, {
              position: 'absolute', bottom: '80px', left: '80px',
              width: '120px', height: '120px',
              borderRadius: '50%', background: 'rgba(255,255,255,0.2)',
              border: '2px solid rgba(255,255,255,0.4)',
              touchAction: 'none',
            })
        
            this.knob = document.createElement('div')
            Object.assign(this.knob.style, {
              position: 'absolute', top: '30px', left: '30px',
              width: '60px', height: '60px',
              borderRadius: '50%', background: 'rgba(255,255,255,0.6)',
              pointerEvents: 'none',
            })
        
            this.stick.appendChild(this.knob)
            container.appendChild(this.stick)
        
            this.stick.addEventListener('touchstart', (e) => this.onStart(e), { passive: true })
            this.stick.addEventListener('touchmove', (e) => this.onMove(e), { passive: true })
            this.stick.addEventListener('touchend', () => this.onEnd())
          }
        
          onStart(e) {
            const rect = this.stick.getBoundingClientRect()
            this.center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
            this.active = true
          }
        
          onMove(e) {
            if (!this.active) return
            const touch = e.touches[0]
            const dx = touch.clientX - this.center.x
            const dy = touch.clientY - this.center.y
            const dist = Math.min(Math.sqrt(dx * dx + dy * dy), this.radius)
            const angle = Math.atan2(dy, dx)
        
            this.input.x = (dist / this.radius) * Math.cos(angle)
            this.input.z = (dist / this.radius) * Math.sin(angle)
        
            // Move knob visually
            const kx = 30 + (dist / this.radius) * 30 * Math.cos(angle)
            const ky = 30 + (dist / this.radius) * 30 * Math.sin(angle)
            this.knob.style.left = `${kx}px`
            this.knob.style.top = `${ky}px`
          }
        
          onEnd() {
            this.active = false
            this.input = { x: 0, z: 0 }
            this.knob.style.left = '30px'
            this.knob.style.top = '30px'
          }
        }
        
        const joystick = new VirtualJoystick(document.body)
        // In game loop: player.setInput(joystick.input.x, joystick.input.z)
        ```
        
        ---
        
        ## Error Handling
        
        ### Animation crossfade causes T-pose flash
        
        Cause: Fading out an action that was never played, or crossfading to an action with
        reset state issues.
        Solution: Always call `nextAction.reset()` before `crossFadeTo`. Ensure `fadeTime > 0`
        (zero-duration crossfade causes a one-frame T-pose).
        
        ### Camera-relative movement drifts sideways
        
        Cause: `cameraForward` not projected onto XZ plane — includes Y component from camera tilt.
        Solution: Always set `cameraForward.y = 0` and renormalize after getting world direction.
        
        ### Physics explosion after tab-away
        
        Cause: Large delta (seconds) fed to physics unchanged.
        Solution: `const delta = Math.min(rawDelta, 0.05)` before all physics updates.
        
        ### Mobile gyro doesn't fire on iOS
        
        Cause: iOS 13+ requires explicit permission prompt triggered by user gesture.
        Solution: Call `DeviceOrientationEvent.requestPermission()` inside a click/touchstart handler.
        
        ### Virtual joystick jitter on fast swipes
        
        Cause: `touchmove` firing faster than frame rate, causing input oscillation.
        Solution: Smooth the input: `this.input.x = lerp(this.input.x, rawX, 0.3)` in `onMove`.
        
      • gltf-loading.md 8.6 KB
        # GLTF Loading Reference
        
        Patterns for loading, cloning, centering, and rigging GLTF/GLB models in Three.js.
        Load this file when the project involves `.glb` files, GLTF model import, animated characters,
        or skeletal rigs.
        
        ---
        
        ## Coordinate System Contract
        
        Three.js is **right-handed**: +X right, +Y up, +Z toward the camera.
        
        GLTF's default forward direction is **-Z** (away from camera). A model exported facing
        "forward" in Blender will face away from you in Three.js without correction.
        
        **Fix: 180° Y-axis rotation on the loaded scene root.**
        
        ```javascript
        loader.load('character.glb', (gltf) => {
          const model = gltf.scene
          model.rotation.y = Math.PI  // 180° — face toward camera
          scene.add(model)
        })
        ```
        
        This is the #1 cause of "model faces wrong direction" bugs. Apply it by default for
        character and prop models. Leave it off for environment meshes or terrain, which
        are authored to match the scene coordinate system.
        
        ---
        
        ## Basic GLTF Load
        
        ```javascript
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
        
        const loader = new GLTFLoader()
        
        loader.load(
          'path/to/model.glb',
          (gltf) => {
            const model = gltf.scene
            model.rotation.y = Math.PI   // Face camera (see coordinate system above)
            scene.add(model)
        
            // If animated:
            const mixer = new THREE.AnimationMixer(model)
            gltf.animations.forEach((clip) => {
              mixer.clipAction(clip).play()
            })
          },
          (progress) => {
            const pct = (progress.loaded / progress.total * 100).toFixed(1)
            console.log(`Loading: ${pct}%`)
          },
          (error) => {
            console.error('GLTF load error:', error)
          }
        )
        ```
        
        ---
        
        ## Model Caching: Load Once, Clone Many
        
        A GLTF scene object can only exist **once** in the scene graph. Adding the same
        `gltf.scene` to two different parents silently removes it from the first.
        
        **Pattern**: Load once into a cache, clone for each instance.
        
        ```javascript
        const modelCache = new Map()
        
        async function loadModel(path) {
          if (modelCache.has(path)) {
            return modelCache.get(path)
          }
          return new Promise((resolve, reject) => {
            loader.load(path, (gltf) => {
              modelCache.set(path, gltf)
              resolve(gltf)
            }, undefined, reject)
          })
        }
        
        // Later — clone for each character instance:
        const gltf = await loadModel('character.glb')
        const instance = SkeletonUtils.clone(gltf.scene)  // NOT gltf.scene.clone() — see below
        instance.rotation.y = Math.PI
        scene.add(instance)
        ```
        
        ---
        
        ## SkeletonUtils.clone() — Critical for Animated Models
        
        `Object3D.clone()` breaks skeleton bindings. Cloned animated models will freeze in
        **T-pose** because the clone's skinned mesh still points to the original skeleton's bones.
        
        **Always use `SkeletonUtils.clone()` for animated models.**
        
        ```javascript
        import { SkeletonUtils } from 'three/addons/utils/SkeletonUtils.js'
        
        // WRONG — T-pose on all clones:
        const badClone = gltf.scene.clone()
        
        // CORRECT — preserves bone bindings:
        const goodClone = SkeletonUtils.clone(gltf.scene)
        ```
        
        For non-animated (static) models, `object.clone()` is fine.
        
        **Full clone + animate pattern**:
        
        ```javascript
        async function spawnCharacter(position) {
          const gltf = await loadModel('character.glb')
          const clone = SkeletonUtils.clone(gltf.scene)
        
          clone.rotation.y = Math.PI
          clone.position.copy(position)
          scene.add(clone)
        
          // Each clone needs its own AnimationMixer
          const mixer = new THREE.AnimationMixer(clone)
          const idleClip = THREE.AnimationClip.findByName(gltf.animations, 'Idle')
          if (idleClip) mixer.clipAction(idleClip).play()
        
          return { model: clone, mixer }
        }
        ```
        
        ---
        
        ## Auto-Centering and Camera Fitting
        
        Models exported from different tools have wildly different scales and origins.
        Always compute the bounding box and fit the camera to it.
        
        ```javascript
        loader.load('model.glb', (gltf) => {
          const model = gltf.scene
          scene.add(model)
        
          // Step 1: Compute bounding box
          const box = new THREE.Box3().setFromObject(model)
          const center = box.getCenter(new THREE.Vector3())
          const size = box.getSize(new THREE.Vector3())
        
          // Step 2: Center model at world origin
          model.position.sub(center)
        
          // Step 3: Fit camera — distance = longest dimension * 1.5
          const maxDim = Math.max(size.x, size.y, size.z)
          camera.position.set(0, maxDim * 0.5, maxDim * 1.5)
          camera.lookAt(0, 0, 0)
        
          // Update OrbitControls target if present
          if (controls) {
            controls.target.set(0, 0, 0)
            controls.update()
          }
        })
        ```
        
        **Common mistake**: calling `setFromObject` before adding to scene. The bounding box
        uses world-space transforms, so add the model first.
        
        ---
        
        ## Bone Hierarchy: Attaching Objects to Bones
        
        For weapons, accessories, or anything that should follow a character's animation,
        find the bone by name and attach the object as a child.
        
        ```javascript
        function attachToSkeleton(model, boneName, attachment) {
          let targetBone = null
        
          model.traverse((node) => {
            if (node.isBone && node.name === boneName) {
              targetBone = node
            }
          })
        
          if (!targetBone) {
            console.warn(`Bone "${boneName}" not found. Available bones:`)
            model.traverse((node) => {
              if (node.isBone) console.warn(' -', node.name)
            })
            return
          }
        
          targetBone.add(attachment)
        
          // Offset attachment relative to bone origin — adjust per model
          attachment.position.set(0, 0, 0.1)
          attachment.rotation.set(0, 0, 0)
        }
        
        // Usage:
        const sword = await loadModel('sword.glb')
        attachToSkeleton(character, 'mixamorig:RightHand', sword.scene)
        ```
        
        **Bone naming conventions**:
        - Mixamo rigs: `mixamorig:Hips`, `mixamorig:RightHand`, `mixamorig:Head`
        - Blender default: `hand.R`, `hand.L`, `spine.001`
        - Custom rigs: varies — log all bones with the warning branch above when debugging
        
        ---
        
        ## Asset Manifest Pattern
        
        For games with many models, define a central manifest instead of hardcoding paths:
        
        ```json
        // assets/manifest.json
        {
          "characters": {
            "Warrior": "models/characters/warrior.glb",
            "Mage": "models/characters/mage.glb",
            "Archer": "models/characters/archer.glb"
          },
          "props": {
            "Sword": "models/props/sword.glb",
            "Chest": "models/props/chest.glb"
          },
          "environment": {
            "TreePine": "models/env/tree_pine.glb",
            "Rock": "models/env/rock.glb"
          }
        }
        ```
        
        ```javascript
        class AssetManager {
          constructor() {
            this.loader = new GLTFLoader()
            this.cache = new Map()
            this.manifest = null
          }
        
          async init(manifestPath = 'assets/manifest.json') {
            const response = await fetch(manifestPath)
            this.manifest = await response.json()
          }
        
          async load(category, name) {
            const key = `${category}/${name}`
            if (this.cache.has(key)) return this.cache.get(key)
        
            const path = this.manifest[category]?.[name]
            if (!path) throw new Error(`Asset not found in manifest: ${key}`)
        
            return new Promise((resolve, reject) => {
              this.loader.load(path, (gltf) => {
                this.cache.set(key, gltf)
                resolve(gltf)
              }, undefined, reject)
            })
          }
        
          spawnInstance(category, name) {
            const gltf = this.cache.get(`${category}/${name}`)
            if (!gltf) throw new Error(`Asset not cached: ${category}/${name} — call load() first`)
            return SkeletonUtils.clone(gltf.scene)
          }
        }
        
        // Usage:
        const assets = new AssetManager()
        await assets.init()
        await assets.load('characters', 'Warrior')
        const warrior1 = assets.spawnInstance('characters', 'Warrior')
        const warrior2 = assets.spawnInstance('characters', 'Warrior')  // Second clone, same GLB
        ```
        
        ---
        
        ## Error Handling
        
        ### Model loads but is invisible
        
        Cause: Model is too small, too large, or centered far from origin.
        Solution: Always run auto-centering. Log `size` to diagnose:
        ```javascript
        const size = box.getSize(new THREE.Vector3())
        console.log('Model size:', size)  // Tiny values = scale issue, huge = unit mismatch
        ```
        
        ### Model faces wrong direction
        
        Cause: GLTF -Z forward vs Three.js +Z-toward-camera.
        Solution: `model.rotation.y = Math.PI` on the scene root after load.
        
        ### Cloned character stuck in T-pose
        
        Cause: Used `.clone()` on an animated model — skeleton bindings broken.
        Solution: Replace with `SkeletonUtils.clone()` from `three/addons/utils/SkeletonUtils.js`.
        
        ### Animations don't play on clone
        
        Cause: `AnimationMixer` created for original model, not the clone.
        Solution: Each clone needs its own `new THREE.AnimationMixer(clone)`.
        
        ### Bone attachment not following animation
        
        Cause: Object attached to the scene root, not to the bone node.
        Solution: Use `traverse` to find the bone by `node.isBone && node.name === boneName`,
        then attach to `targetBone` directly.
        
        ### Load progress never reaches 100%
        
        Cause: `progress.total` is 0 on servers that omit Content-Length headers.
        Solution: Guard against division by zero:
        ```javascript
        const pct = progress.total ? (progress.loaded / progress.total * 100) : 0
        ```
        
      • performance-patterns.md 11.7 KB
        ---
        description: InstancedMesh, BufferGeometry manipulation, draw call batching, LOD, texture compression, and memory disposal for Three.js r150+
        agent: threejs-builder
        category: performance
        version_range: "Three.js r150+"
        ---
        
        # Performance Patterns Reference
        
        > **Scope**: Three.js-specific performance patterns — instancing, buffer manipulation, draw call budgets, LOD, and memory management. Generic JavaScript optimization is out of scope.
        > **Version range**: Three.js r150+
        > **Hitting a frame-rate target?** Call the Skill tool with `hill-climb`. Baseline p1-low FPS, profile, then accept or revert one change per iteration (`skills/meta/hill-climb/references/domain-playbooks.md`).
        > **Generated**: 2026-04-08
        
        ---
        
        ## InstancedMesh for Thousands of Objects
        
        `InstancedMesh` renders N copies of the same geometry in a single draw call. Use it when you need more than ~50 identical objects (particles, foliage, crowds).
        
        ```javascript
        const count = 5000;
        const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
        const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
        
        const instancedMesh = new THREE.InstancedMesh(geometry, material, count);
        instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // if positions change per frame
        
        const matrix = new THREE.Matrix4();
        const position = new THREE.Vector3();
        const quaternion = new THREE.Quaternion();
        const scale = new THREE.Vector3(1, 1, 1);
        
        // Set each instance's transform
        for (let i = 0; i < count; i++) {
          position.set(
            (Math.random() - 0.5) * 20,
            (Math.random() - 0.5) * 20,
            (Math.random() - 0.5) * 20,
          );
          matrix.compose(position, quaternion, scale);
          instancedMesh.setMatrixAt(i, matrix);
        }
        instancedMesh.instanceMatrix.needsUpdate = true; // required after updates
        scene.add(instancedMesh);
        
        // Per-frame update (animating instances):
        renderer.setAnimationLoop((time) => {
          for (let i = 0; i < count; i++) {
            instancedMesh.getMatrixAt(i, matrix);
            matrix.elements[13] += Math.sin(time * 0.001 + i) * 0.001; // move Y
            instancedMesh.setMatrixAt(i, matrix);
          }
          instancedMesh.instanceMatrix.needsUpdate = true;
          renderer.render(scene, camera);
        });
        ```
        
        ### Per-Instance Color (r150+)
        
        ```javascript
        // InstancedMesh supports per-instance color without a custom shader
        const color = new THREE.Color();
        for (let i = 0; i < count; i++) {
          color.setHSL(i / count, 0.8, 0.6);
          instancedMesh.setColorAt(i, color);
        }
        instancedMesh.instanceColor.needsUpdate = true; // required after updates
        ```
        
        ---
        
        ## BufferGeometry Manipulation
        
        ### Typed Array Access for Maximum Speed
        
        ```javascript
        // Reading existing geometry attributes
        const geometry = new THREE.PlaneGeometry(10, 10, 64, 64);
        const positions = geometry.attributes.position; // BufferAttribute
        const posArray = positions.array; // Float32Array — direct typed array access
        
        // Modify in place (fast — no GC pressure)
        for (let i = 0; i < posArray.length; i += 3) {
          posArray[i + 1] = Math.sin(posArray[i] * 0.5) * 0.5; // modify Y
        }
        positions.needsUpdate = true; // signal GPU upload
        geometry.computeVertexNormals();  // recalculate after vertex changes
        ```
        
        ### Custom BufferGeometry from Scratch
        
        ```javascript
        // Interleaved buffer — position + uv in one buffer (r150+ supports InterleavedBuffer)
        const vertexData = new Float32Array([
          // x, y, z, u, v
          -1, -1, 0,   0, 0,
           1, -1, 0,   1, 0,
           1,  1, 0,   1, 1,
          -1,  1, 0,   0, 1,
        ]);
        
        const interleavedBuffer = new THREE.InterleavedBuffer(vertexData, 5); // stride = 5 floats
        const geometry = new THREE.BufferGeometry();
        geometry.setAttribute('position', new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 0));
        geometry.setAttribute('uv',       new THREE.InterleavedBufferAttribute(interleavedBuffer, 2, 3));
        geometry.setIndex([0, 1, 2,  0, 2, 3]); // two triangles
        ```
        
        ---
        
        ## Draw Call Batching
        
        A draw call is issued per unique material per object. Reducing draw calls is the highest-leverage optimization.
        
        | Strategy | Draw Calls Saved | Tradeoff |
        |----------|-----------------|----------|
        | `InstancedMesh` (N same-geometry objects) | N → 1 | All instances same geometry+material |
        | `BufferGeometryUtils.mergeGeometries()` | N → 1 | No individual transforms after merge |
        | Texture atlas (pack multiple textures) | Avoids material splits | Atlas creation cost, UV remapping |
        | `renderer.info.render.calls` | (monitoring) | Use to measure actual draw calls |
        
        ```javascript
        import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
        
        // Merge static scene geometry into one draw call
        const geometries = staticMeshes.map(m => {
          m.geometry.applyMatrix4(m.matrixWorld); // bake transforms
          return m.geometry;
        });
        const merged = mergeGeometries(geometries, true); // true = preserve groups for multi-material
        const mergedMesh = new THREE.Mesh(merged, materials);
        scene.add(mergedMesh);
        
        // Monitor draw calls (dev only)
        console.log('Draw calls:', renderer.info.render.calls);
        ```
        
        ---
        
        ## LOD (Level of Detail)
        
        ```javascript
        const lod = new THREE.LOD();
        
        // High detail: 0-10 units from camera
        const highGeom = new THREE.SphereGeometry(1, 64, 64);
        lod.addLevel(new THREE.Mesh(highGeom, material), 0);
        
        // Medium: 10-50 units
        const medGeom = new THREE.SphereGeometry(1, 16, 16);
        lod.addLevel(new THREE.Mesh(medGeom, material), 10);
        
        // Low: 50-100 units
        const lowGeom = new THREE.SphereGeometry(1, 4, 4);
        lod.addLevel(new THREE.Mesh(lowGeom, material), 50);
        
        // Nothing past 100
        lod.addLevel(new THREE.Object3D(), 100); // invisible placeholder
        
        scene.add(lod);
        
        // LOD.update() called automatically if autoUpdate is true (r155+)
        // For manual control: lod.update(camera) in animation loop
        ```
        
        ---
        
        ## Frustum Culling
        
        Three.js enables frustum culling automatically (`object.frustumCulled = true` by default). For large scenes, verify nothing disables it accidentally.
        
        ```javascript
        // Check frustum culling is enabled (it should be)
        console.log(mesh.frustumCulled); // should be true
        
        // Custom culling — manually skip rendering based on distance
        renderer.setAnimationLoop(() => {
          const frustum = new THREE.Frustum();
          frustum.setFromProjectionMatrix(
            new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
          );
        
          objects.forEach(obj => {
            obj.visible = frustum.containsPoint(obj.position);
          });
        
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ## Texture Atlasing and Compression
        
        ### KTX2 / Basis Texture Loading (r150+)
        
        KTX2 textures are GPU-native compressed formats. They reduce GPU memory by 4-8x vs PNG/JPEG and eliminate the CPU decode step.
        
        ```javascript
        import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
        import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
        
        const ktx2Loader = new KTX2Loader()
          .setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/basis/')
          .detectSupport(renderer);
        
        // Load compressed texture
        const texture = await ktx2Loader.loadAsync('texture.ktx2');
        material.map = texture;
        
        // Convert PNGs to KTX2 via CLI (toktx from KTX-Software):
        // toktx --t2 --bcmp output.ktx2 input.png
        ```
        
        ### Texture Atlas Pattern
        
        ```javascript
        // Single atlas texture, UV offsets per sprite
        const atlasTexture = new THREE.TextureLoader().load('/atlas.png');
        
        // Define UV regions (normalized 0-1)
        const spriteUVs = {
          player: { x: 0, y: 0, w: 0.25, h: 0.5 },
          enemy:  { x: 0.25, y: 0, w: 0.25, h: 0.5 },
        };
        
        function applyAtlasRegion(geometry, region) {
          const uvs = geometry.attributes.uv.array;
          for (let i = 0; i < uvs.length; i += 2) {
            uvs[i]     = region.x + uvs[i] * region.w;
            uvs[i + 1] = region.y + uvs[i + 1] * region.h;
          }
          geometry.attributes.uv.needsUpdate = true;
        }
        ```
        
        ---
        
        ## Dispose Patterns (Prevent Memory Leaks)
        
        Three.js requires explicit disposal of GPU resources. Forgetting to dispose causes memory growth until tab crash.
        
        ```javascript
        // Dispose a mesh and all its resources
        function disposeMesh(mesh) {
          mesh.geometry.dispose();
        
          // Material can be shared — only dispose if not reused
          if (Array.isArray(mesh.material)) {
            mesh.material.forEach(m => disposeMaterial(m));
          } else {
            disposeMaterial(mesh.material);
          }
        
          scene.remove(mesh);
        }
        
        function disposeMaterial(material) {
          // Dispose all texture maps on the material
          for (const key of Object.keys(material)) {
            if (material[key] instanceof THREE.Texture) {
              material[key].dispose();
            }
          }
          material.dispose();
        }
        
        // Dispose EffectComposer when unmounting
        composer.dispose();
        renderer.dispose();
        ```
        
        ---
        
        ## Pattern Catalog
        
        ### Create Geometry Once, Transform in the Loop
        
        **Detection**:
        ```bash
        grep -rn "new THREE\.\(Box\|Sphere\|Plane\|Cylinder\)Geometry" --include="*.js" --include="*.ts"
        rg "setAnimationLoop|useFrame" --type js -A 20 | grep "new THREE\."
        ```
        
        **Signal**:
        ```javascript
        renderer.setAnimationLoop((time) => {
          // BAD: allocates new geometry every frame at 60fps
          const geo = new THREE.SphereGeometry(Math.sin(time * 0.001), 32, 32);
          mesh.geometry = geo;
          renderer.render(scene, camera);
        });
        ```
        
        **Why this matters**: Each new geometry allocates a GPU buffer. Old geometries are not freed without `dispose()`. At 60fps this creates 3,600 leaked GPU buffers per minute.
        
        **Preferred action**:
        ```javascript
        // Morph the existing geometry's buffer in place
        const geo = new THREE.SphereGeometry(1, 32, 32);
        const mesh = new THREE.Mesh(geo, material);
        scene.add(mesh);
        
        renderer.setAnimationLoop((time) => {
          mesh.scale.setScalar(Math.sin(time * 0.001) * 0.5 + 1.0); // transform, don't rebuild
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ### Allocate Scratch Objects Outside the Render Loop
        
        **Detection**:
        ```bash
        grep -rn "new THREE\.Vector3\|new THREE\.Color\|new THREE\.Matrix4" --include="*.js" --include="*.ts"
        rg "setAnimationLoop|useFrame" --type js -A 30 | grep "new THREE\."
        ```
        
        **Signal**:
        ```javascript
        renderer.setAnimationLoop(() => {
          // BAD: 3 allocations per frame = 10,800 objects/min of GC pressure
          const pos = new THREE.Vector3(mesh.position);
          const dir = new THREE.Vector3(0, 1, 0);
          const target = new THREE.Vector3().addVectors(pos, dir);
        });
        ```
        
        **Why this matters**: JavaScript GC pauses correlate directly with allocation rate. At 60fps, even small allocation storms cause visible frame stutters every few seconds.
        
        **Preferred action**:
        ```javascript
        // Allocate once outside the loop, reuse as scratch
        const _pos = new THREE.Vector3();
        const _dir = new THREE.Vector3(0, 1, 0);
        const _target = new THREE.Vector3();
        
        renderer.setAnimationLoop(() => {
          _pos.copy(mesh.position);
          _target.addVectors(_pos, _dir);
        });
        ```
        
        ---
        
        ### Use composer.render() When EffectComposer Is Active
        
        **Detection**:
        ```bash
        grep -rn "renderer\.render\|composer\.render" --include="*.js" --include="*.ts"
        ```
        
        **Signal**:
        ```javascript
        const composer = new EffectComposer(renderer);
        // ...
        
        renderer.setAnimationLoop(() => {
          renderer.render(scene, camera); // BAD when composer is active — skips postprocessing
        });
        ```
        
        **Why this matters**: `renderer.render()` writes directly to the canvas, bypassing the composer's pass chain. Postprocessing effects are invisible.
        
        **Preferred action**: Replace `renderer.render(scene, camera)` with `composer.render()` once EffectComposer is set up.
        
        ---
        
        ## Detection Commands Reference
        
        ```bash
        # Find geometry creation inside animation loops
        grep -rn "new THREE\." --include="*.js" --include="*.ts" | grep -v "//\|const \|let \|var "
        
        # Check if dispose() is called for materials
        grep -rn "\.dispose()" --include="*.js" --include="*.ts"
        
        # Monitor draw calls at runtime (add to dev mode)
        grep -rn "renderer.info.render.calls" --include="*.js" --include="*.ts"
        
        # Find InstancedMesh usage
        grep -rn "InstancedMesh\|instanceMatrix" --include="*.js" --include="*.ts"
        ```
        
        ---
        
        ## See Also
        
        - `references/visual-polish.md` — Material recipes and HDR environments
        - `references/shader-patterns.md` — Custom GLSL shader patterns
        - `references/advanced-topics.md` — GLTF loading, TypeScript patterns
        
      • react-three-fiber.md 17.8 KB
        # React Three Fiber (R3F) Reference
        
        Paradigm-specific reference for building 3D applications with React Three Fiber.
        Load this file when the project uses `@react-three/fiber`, Drei, or any React-based 3D setup.
        
        ---
        
        ## Core Architecture
        
        R3F wraps Three.js in a declarative React layer. Every Three.js object becomes a JSX element.
        The mental model shift: you don't call `new THREE.Mesh()` — you render `<mesh>`.
        
        ### Project Setup
        
        ```bash
        npm create vite@latest my-3d-app -- --template react-ts
        cd my-3d-app
        npm install three @react-three/fiber @react-three/drei
        npm install -D @types/three
        ```
        
        **Key packages and when to use them:**
        
        | Package | Purpose | Always Install? |
        |---------|---------|-----------------|
        | `three` | Core Three.js | Yes |
        | `@react-three/fiber` | React renderer for Three.js | Yes |
        | `@react-three/drei` | Helper components (OrbitControls, Text, Environment, etc.) | Yes — almost always needed |
        | `@react-three/postprocessing` | Post-processing effects (Bloom, SSAO, etc.) | When effects needed |
        | `zustand` | State management for game/interactive state | When complex state needed |
        | `leva` | Debug GUI controls | During development |
        | `@react-three/rapier` | Physics (Rapier WASM) | When physics needed |
        
        ### Canvas Component
        
        Every R3F app starts with `<Canvas>`, which creates the WebGL renderer, scene, and default camera:
        
        ```tsx
        import { Canvas } from '@react-three/fiber'
        
        function App() {
          return (
            <Canvas
              camera={{ position: [0, 2, 5], fov: 50 }}
              gl={{ antialias: true, toneMapping: THREE.ACESFilmicToneMapping }}
              shadows
            >
              <Scene />
            </Canvas>
          )
        }
        ```
        
        **Canvas props that matter:**
        - `camera` — set position and fov here, not inside the scene (avoids re-creation on re-render)
        - `gl` — renderer options; `antialias: true` and tone mapping are common defaults
        - `shadows` — enables shadow maps (still need `castShadow`/`receiveShadow` on objects)
        - `dpr` — device pixel ratio; `[1, 2]` caps at 2x for performance
        - `frameloop="demand"` — only render when state changes (for static scenes or manual invalidation)
        
        ---
        
        ## Component Patterns
        
        ### Basic Mesh
        
        ```tsx
        function Box({ position }: { position: [number, number, number] }) {
          const meshRef = useRef<THREE.Mesh>(null!)
        
          useFrame((state, delta) => {
            meshRef.current.rotation.x += delta
          })
        
          return (
            <mesh ref={meshRef} position={position}>
              <boxGeometry args={[1, 1, 1]} />
              <meshStandardMaterial color="orange" />
            </mesh>
          )
        }
        ```
        
        **Key pattern**: geometry and material are children of `<mesh>`, not props. The `args` prop maps to constructor arguments. `<boxGeometry args={[1, 1, 1]} />` = `new THREE.BoxGeometry(1, 1, 1)`.
        
        ### Lighting Setup
        
        ```tsx
        function Lighting() {
          return (
            <>
              <ambientLight intensity={0.4} />
              <directionalLight
                position={[10, 10, 5]}
                intensity={1}
                castShadow
                shadow-mapSize={[2048, 2048]}
              />
            </>
          )
        }
        ```
        
        **R3F dash notation**: `shadow-mapSize` sets `light.shadow.mapSize`. This works for any nested property: `position-x={5}` sets `mesh.position.x = 5`.
        
        ---
        
        ## Camera Controls — The #1 R3F Pitfall
        
        This is the most common source of bugs in R3F projects. OrbitControls and custom camera controllers **conflict silently** — both try to update the camera every frame, causing jitter, locked cameras, or unexpected behavior.
        
        ### Decision Tree
        
        ```
        Do you need the user to orbit/pan/zoom freely?
          YES → Use OrbitControls from drei
          NO → Does the camera follow a target or path?
            YES → Use useFrame + refs for custom camera
            NO → Set camera position on <Canvas> and leave it static
        ```
        
        ### OrbitControls (user-controlled camera)
        
        ```tsx
        import { OrbitControls } from '@react-three/drei'
        
        // Inside Canvas:
        <OrbitControls
          enableDamping
          dampingFactor={0.05}
          minDistance={2}
          maxDistance={20}
          maxPolarAngle={Math.PI / 2}  // Prevent looking below ground
        />
        ```
        
        **When to use**: Product viewers, 3D model inspectors, sandbox scenes where the user explores freely.
        
        ### Custom Camera Controller (app-controlled camera)
        
        ```tsx
        function CameraController({ target }: { target: THREE.Vector3 }) {
          useFrame((state) => {
            // Smoothly move camera to follow target
            state.camera.position.lerp(
              new THREE.Vector3(target.x, target.y + 5, target.z + 10),
              0.05
            )
            state.camera.lookAt(target)
          })
          return null
        }
        ```
        
        **When to use**: Games, guided experiences, cinematic sequences, any scene where the app decides camera position.
        
        ### Single Camera Controller Per Scene
        
        ```tsx
        // BAD: OrbitControls + custom camera = conflict
        <OrbitControls />
        <CameraController target={playerPos} />
        ```
        
        Both components write to the camera every frame. OrbitControls overwrites the custom position, or vice versa, depending on execution order. The result is a frozen or jittering camera. **Pick one approach per scene.**
        
        If you need both (e.g., orbit in menu, follow in gameplay), conditionally render only one:
        
        ```tsx
        {gameState === 'menu' ? <OrbitControls /> : <CameraController target={playerPos} />}
        ```
        
        ---
        
        ## useFrame — The Animation Loop
        
        `useFrame` is R3F's equivalent of `requestAnimationFrame`. It runs every frame inside the React reconciler.
        
        ```tsx
        useFrame((state, delta) => {
          // state.clock — THREE.Clock
          // state.camera — the active camera
          // state.mouse — normalized mouse position [-1, 1]
          // state.scene — the THREE.Scene
          // delta — time since last frame in seconds
          meshRef.current.rotation.y += delta * 0.5
        })
        ```
        
        ### useFrame Rules
        
        | Do | Don't | Why |
        |----|-------|-----|
        | Mutate refs (`meshRef.current.rotation.y += delta`) | Set React state (`setPosition(...)`) | State triggers re-render; refs don't. Re-renders in useFrame = 60 re-renders/sec = frozen app |
        | Use `delta` for time-based animation | Use fixed increments (`+= 0.01`) | Fixed increments run faster on high-refresh displays |
        | Create vectors/objects outside useFrame | Create `new THREE.Vector3()` inside useFrame | Allocations inside the loop cause GC pauses every few seconds |
        | Access `state.clock.elapsedTime` for absolute time | Track your own timer | The clock is already there and synchronized |
        
        ### Pre-allocating Vectors
        
        ```tsx
        // GOOD: allocate once, reuse in frame loop
        const tempVec = useMemo(() => new THREE.Vector3(), [])
        
        useFrame(() => {
          tempVec.set(target.x, target.y + 2, target.z + 5)
          meshRef.current.position.lerp(tempVec, 0.1)
        })
        ```
        
        ```tsx
        // BAD: allocates every frame (60 allocations/sec)
        useFrame(() => {
          const targetPos = new THREE.Vector3(target.x, target.y + 2, target.z + 5)
          meshRef.current.position.lerp(targetPos, 0.1)
        })
        ```
        
        ---
        
        ## Post-Processing
        
        R3F post-processing uses `@react-three/postprocessing`, which wraps the `postprocessing` library (not Three.js's built-in EffectComposer — the `postprocessing` npm package is significantly more performant).
        
        ### Setup
        
        ```bash
        npm install @react-three/postprocessing postprocessing
        ```
        
        ### Bloom (Makes Emissive Materials Glow)
        
        Bloom is what makes emissive materials actually visible as glowing. Without bloom, `emissiveIntensity` just makes the surface brighter — it doesn't produce the glow halo effect.
        
        ```tsx
        import { EffectComposer, Bloom } from '@react-three/postprocessing'
        
        function Effects() {
          return (
            <EffectComposer>
              <Bloom
                luminanceThreshold={0.9}
                luminanceSmoothing={0.025}
                intensity={1.5}
                mipmapBlur
              />
            </EffectComposer>
          )
        }
        
        // Material that glows:
        <meshStandardMaterial
          color="#ff6600"
          emissive="#ff6600"
          emissiveIntensity={2}
          toneMapped={false}  // Required for bloom to pick up the HDR value
        />
        ```
        
        **Critical**: `toneMapped={false}` on the material is required for bloom. Without it, the renderer clamps emissive values to [0,1] before the bloom pass sees them, and nothing glows.
        
        ### Common Effects
        
        ```tsx
        import { EffectComposer, Bloom, ChromaticAberration, Vignette, SSAO } from '@react-three/postprocessing'
        
        <EffectComposer>
          <Bloom luminanceThreshold={0.9} intensity={1.5} mipmapBlur />
          <ChromaticAberration offset={[0.002, 0.002]} />
          <Vignette eskil={false} offset={0.1} darkness={1.1} />
          <SSAO radius={0.05} intensity={15} />
        </EffectComposer>
        ```
        
        **Effect order matters** — effects run in the order they appear in the JSX. Bloom should generally come first (operates on HDR values), then color effects, then vignette (screen-space overlay).
        
        ---
        
        ## Drei Helper Components
        
        `@react-three/drei` provides 100+ helper components. Here are the most commonly needed:
        
        ### Environment and Lighting
        
        ```tsx
        import { Environment, ContactShadows, Sky } from '@react-three/drei'
        
        // HDR environment for reflections + ambient light
        <Environment preset="city" />  // presets: apartment, city, dawn, forest, lobby, night, park, studio, sunset, warehouse
        
        // Soft contact shadows on a ground plane
        <ContactShadows position={[0, -0.5, 0]} opacity={0.4} blur={2} />
        
        // Procedural sky
        <Sky sunPosition={[100, 20, 100]} />
        ```
        
        ### Text
        
        ```tsx
        import { Text, Text3D } from '@react-three/drei'
        
        // 2D billboard text (always faces camera)
        <Text fontSize={0.5} color="white" anchorX="center" anchorY="middle">
          Hello World
        </Text>
        
        // 3D extruded text (needs a font JSON)
        <Text3D font="/fonts/helvetiker_regular.typeface.json" size={0.75} height={0.2}>
          Hello
          <meshStandardMaterial color="gold" />
        </Text3D>
        ```
        
        ### Loading Models
        
        ```tsx
        import { useGLTF, Clone } from '@react-three/drei'
        
        function Model({ url }: { url: string }) {
          const { scene } = useGLTF(url)
          return <Clone object={scene} />
        }
        
        // Preload for no loading flash:
        useGLTF.preload('/models/robot.glb')
        ```
        
        **Use `<Clone>`** instead of `<primitive object={scene} />` when you need multiple instances — `primitive` reuses the same Three.js object (can only appear once in the scene graph), while `Clone` creates a deep copy.
        
        ### Other Essential Drei Components
        
        | Component | Purpose |
        |-----------|---------|
        | `<Html>` | Embed HTML/CSS inside the 3D scene (follows 3D position) |
        | `<Float>` | Gentle floating animation on children |
        | `<PresentationControls>` | Drag-to-rotate for product viewers (alternative to OrbitControls) |
        | `<RoundedBox>` | Box with rounded edges |
        | `<Center>` | Auto-center children in the scene |
        | `<useTexture>` | Load textures with suspense |
        | `<Instances>` / `<Instance>` | Declarative instanced meshes |
        | `<Sparkles>` | Particle sparkle effect |
        | `<Stars>` | Starfield background |
        | `<Grid>` | Configurable ground grid |
        
        ---
        
        ## State Management with Zustand
        
        For interactive or game-like R3F apps, use Zustand for state. It works seamlessly with R3F because both use refs and subscriptions, not React re-renders.
        
        ```tsx
        import { create } from 'zustand'
        
        interface GameState {
          score: number
          playerPosition: [number, number, number]
          incrementScore: () => void
          setPlayerPosition: (pos: [number, number, number]) => void
        }
        
        const useGameStore = create<GameState>((set) => ({
          score: 0,
          playerPosition: [0, 0, 0],
          incrementScore: () => set((state) => ({ score: state.score + 1 })),
          setPlayerPosition: (pos) => set({ playerPosition: pos }),
        }))
        ```
        
        ### Reading State in useFrame Without Re-renders
        
        ```tsx
        function Player() {
          const meshRef = useRef<THREE.Mesh>(null!)
        
          useFrame(() => {
            // Direct subscription — no re-render
            const pos = useGameStore.getState().playerPosition
            meshRef.current.position.set(...pos)
          })
        
          return <mesh ref={meshRef}><boxGeometry /><meshStandardMaterial /></mesh>
        }
        ```
        
        **Key pattern**: Use `getState()` inside `useFrame`, not the hook's return value. The hook triggers re-renders; `getState()` reads the latest value without triggering React's reconciler.
        
        ### UI Overlay Reading State
        
        ```tsx
        function HUD() {
          const score = useGameStore((state) => state.score)
          return <div className="hud">Score: {score}</div>
        }
        ```
        
        For UI components that render outside `<Canvas>`, use the normal Zustand hook — re-renders are fine for DOM elements.
        
        ---
        
        ## Performance Patterns
        
        ### Instanced Meshes
        
        When rendering 100+ identical objects, use instancing:
        
        ```tsx
        import { Instances, Instance } from '@react-three/drei'
        
        function Particles({ count = 1000 }) {
          const particles = useMemo(() =>
            Array.from({ length: count }, () => ({
              position: [
                (Math.random() - 0.5) * 20,
                (Math.random() - 0.5) * 20,
                (Math.random() - 0.5) * 20,
              ] as [number, number, number],
              scale: Math.random() * 0.5 + 0.1,
            })),
            [count]
          )
        
          return (
            <Instances limit={count}>
              <sphereGeometry args={[1, 8, 8]} />
              <meshStandardMaterial color="#88ccff" />
              {particles.map((p, i) => (
                <Instance key={i} position={p.position} scale={p.scale} />
              ))}
            </Instances>
          )
        }
        ```
        
        ### Geometry and Material Reuse
        
        ```tsx
        // GOOD: Define geometry/material once, use in multiple meshes
        const sharedGeo = useMemo(() => new THREE.BoxGeometry(1, 1, 1), [])
        const sharedMat = useMemo(() => new THREE.MeshStandardMaterial({ color: 'orange' }), [])
        
        return (
          <>
            <mesh geometry={sharedGeo} material={sharedMat} position={[0, 0, 0]} />
            <mesh geometry={sharedGeo} material={sharedMat} position={[2, 0, 0]} />
            <mesh geometry={sharedGeo} material={sharedMat} position={[4, 0, 0]} />
          </>
        )
        ```
        
        ### Performance Monitoring
        
        ```tsx
        import { Perf } from 'r3f-perf'
        
        // Inside Canvas, during development only:
        <Perf position="top-left" />
        ```
        
        ### Common Performance Killers
        
        | Problem | Detection | Fix |
        |---------|-----------|-----|
        | State updates in useFrame | Search for `useState` or `set` calls inside `useFrame` | Use refs instead of state for per-frame mutations |
        | Geometry allocation in render | Search for `new THREE.` inside `useFrame` or component body without `useMemo` | Allocate once with `useMemo`, mutate in `useFrame` |
        | Too many draw calls | r3f-perf shows draw call count > 200 | Use `<Instances>`, merge static geometries, use LOD |
        | Large textures | GPU memory > 500MB | Compress textures (KTX2), use `useTexture` with responsive sizes |
        | Shadow map too large | Frame time spikes | Cap `shadow-mapSize` at `[1024, 1024]` unless close-up detail needed |
        | Re-rendering entire scene | React DevTools shows frequent renders | Memoize components, use refs for animation, isolate state consumers |
        
        ---
        
        ## Common Patterns to Detect and Fix
        
        ### 1. Mixing Imperative and Declarative
        
        ```tsx
        // BAD: Imperative Three.js inside R3F
        useEffect(() => {
          const geometry = new THREE.BoxGeometry(1, 1, 1)
          const material = new THREE.MeshStandardMaterial({ color: 'red' })
          const mesh = new THREE.Mesh(geometry, material)
          scene.add(mesh)  // Manual scene manipulation
          return () => { scene.remove(mesh) }
        }, [])
        
        // GOOD: Declarative R3F
        <mesh>
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial color="red" />
        </mesh>
        ```
        
        R3F manages the scene graph. Manual `scene.add()` bypasses React's reconciler and causes orphaned objects, missing cleanup, and state desync.
        
        ### 2. useEffect for Animation
        
        ```tsx
        // BAD: useEffect + requestAnimationFrame
        useEffect(() => {
          let id: number
          function animate() {
            id = requestAnimationFrame(animate)
            meshRef.current.rotation.y += 0.01
          }
          animate()
          return () => cancelAnimationFrame(id)
        }, [])
        
        // GOOD: useFrame
        useFrame((_, delta) => {
          meshRef.current.rotation.y += delta
        })
        ```
        
        `useFrame` is managed by R3F's render loop. Using `requestAnimationFrame` directly creates a separate loop that's not synchronized with R3F's renderer, causing double-renders and timing issues.
        
        ### 3. Heavy Computation in Components
        
        ```tsx
        // BAD: Recalculates on every render
        function Terrain() {
          const vertices = generateTerrain(1000, 1000)  // Expensive!
          return <mesh><bufferGeometry>...</bufferGeometry></mesh>
        }
        
        // GOOD: Memoize expensive computations
        function Terrain() {
          const vertices = useMemo(() => generateTerrain(1000, 1000), [])
          return <mesh><bufferGeometry>...</bufferGeometry></mesh>
        }
        ```
        
        ### 4. Forgetting Suspense for Asset Loading
        
        ```tsx
        // BAD: No loading state — white flash or error
        function App() {
          return (
            <Canvas>
              <Model url="/heavy-model.glb" />
            </Canvas>
          )
        }
        
        // GOOD: Suspense boundary with fallback
        function App() {
          return (
            <Canvas>
              <Suspense fallback={<LoadingIndicator />}>
                <Model url="/heavy-model.glb" />
              </Suspense>
            </Canvas>
          )
        }
        
        function LoadingIndicator() {
          return (
            <mesh>
              <sphereGeometry args={[0.5, 16, 16]} />
              <meshBasicMaterial color="#666" wireframe />
            </mesh>
          )
        }
        ```
        
        `useGLTF`, `useTexture`, and other loaders throw promises (React Suspense). Without a `<Suspense>` boundary, the app crashes.
        
        ---
        
        ## Error Handling
        
        ### Error: "R3F: Canvas is not allowed to have children that aren't THREE elements"
        Cause: Regular HTML elements inside `<Canvas>` (e.g., `<div>`, `<p>`)
        Solution: Use `<Html>` from drei to embed HTML, or place HTML outside the Canvas.
        
        ### Error: "Cannot read properties of null (reading 'rotation')"
        Cause: Ref accessed before mount, or conditional rendering removed the mesh
        Solution: Add null check in useFrame: `if (!meshRef.current) return`
        
        ### Error: Objects appear but no lighting/all black
        Cause: Missing lights in the scene, or using `MeshStandardMaterial` without any light source
        Solution: Add `<ambientLight intensity={0.5} />` and a directional light. Or use `<Environment>` from drei.
        
        ### Error: Model loads but is invisible
        Cause: Model scale is extremely small or large relative to camera, or model is at a distant position
        Solution: Use drei's `<Center>` component, or compute bounding box and adjust camera.
        
        ### Error: Controls feel laggy or jittery
        Cause: OrbitControls + custom camera controller both active, or state updates causing re-renders during orbit
        Solution: Use only one camera control method. Check for useState calls triggered by mouse events.
        
        ### Error: Post-processing bloom has no visible effect
        Cause: `toneMapped` is not set to `false` on emissive materials, or `emissiveIntensity` is too low
        Solution: Set `toneMapped={false}` on the material AND increase `emissiveIntensity` above 1.0.
        
      • shader-patterns.md 14.9 KB
        ---
        description: Custom GLSL shaders in Three.js — ShaderMaterial, vertex displacement, fragment effects, and EffectComposer postprocessing pipeline
        agent: threejs-builder
        category: visual-techniques
        version_range: "Three.js r150+"
        ---
        
        # Shader Patterns Reference
        
        > **Scope**: Writing custom GLSL for Three.js — ShaderMaterial vs RawShaderMaterial, vertex displacement, fragment effects, postprocessing with EffectComposer. Does NOT cover TSL/WebGPU node materials (see webgpu.md).
        > **Version range**: Three.js r150+
        > **Generated**: 2026-04-08
        
        ---
        
        ## ShaderMaterial vs RawShaderMaterial
        
        | Class | Preprocessor Includes | Built-in Uniforms | Use When |
        |-------|----------------------|-------------------|----------|
        | `ShaderMaterial` | Yes — Three.js injects `#include <common>`, fog, lights | `projectionMatrix`, `modelViewMatrix`, `normalMatrix` auto-bound | Custom effects that still need Three.js lighting chunks |
        | `RawShaderMaterial` | None — full GLSL control | Must declare ALL uniforms manually, including matrices | Full shader control, when Three.js chunks cause conflicts |
        
        The most common mistake: using `RawShaderMaterial` and forgetting to declare `projectionMatrix` and `modelViewMatrix`, then wondering why geometry doesn't render.
        
        ```javascript
        // ShaderMaterial — Three.js injects precision, matrices, and chunk definitions
        const mat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0.0 },
            uColor: { value: new THREE.Color(0x00ffcc) },
          },
          vertexShader: /* glsl */`
            uniform float uTime;
            varying vec2 vUv;
        
            void main() {
              vUv = uv;
              // projectionMatrix and modelViewMatrix are injected by Three.js
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: /* glsl */`
            uniform vec3 uColor;
            varying vec2 vUv;
        
            void main() {
              gl_FragColor = vec4(uColor * vUv.x, 1.0);
            }
          `,
        });
        
        // In animation loop:
        mat.uniforms.uTime.value = clock.getElapsedTime();
        ```
        
        ```javascript
        // RawShaderMaterial — must declare ALL uniforms and precision
        const mat = new THREE.RawShaderMaterial({
          uniforms: {
            uTime: { value: 0.0 },
          },
          vertexShader: /* glsl */`
            precision mediump float;
        
            uniform mat4 projectionMatrix;
            uniform mat4 modelViewMatrix;
            uniform float uTime;
            attribute vec3 position;
            attribute vec2 uv;
            varying vec2 vUv;
        
            void main() {
              vUv = uv;
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: /* glsl */`
            precision mediump float;
            varying vec2 vUv;
        
            void main() {
              gl_FragColor = vec4(vUv, 0.5, 1.0);
            }
          `,
        });
        ```
        
        ---
        
        ## Vertex Displacement Shaders
        
        ### Noise-Based Terrain
        
        ```javascript
        // Simplex noise via glsl-noise (CDN) or inline implementation
        // Three.js r150+ includes ShaderChunk — use #include <common> for noise utilities
        
        const terrainMat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0 },
            uStrength: { value: 2.0 },
            uFrequency: { value: 0.5 },
          },
          vertexShader: /* glsl */`
            #include <common>  // includes noise utilities in Three.js r150+
        
            uniform float uTime;
            uniform float uStrength;
            uniform float uFrequency;
            varying float vElevation;
        
            // Classic Perlin noise — available after #include <common>
            void main() {
              vec4 modelPosition = modelMatrix * vec4(position, 1.0);
        
              float elevation =
                sin(modelPosition.x * uFrequency + uTime) *
                sin(modelPosition.z * uFrequency + uTime) *
                uStrength;
        
              modelPosition.y += elevation;
              vElevation = elevation;
        
              gl_Position = projectionMatrix * viewMatrix * modelPosition;
            }
          `,
          fragmentShader: /* glsl */`
            varying float vElevation;
        
            void main() {
              // Map elevation to color gradient
              float t = (vElevation + 2.0) / 4.0; // normalize to 0-1
              vec3 low  = vec3(0.04, 0.08, 0.2);  // deep water
              vec3 high = vec3(0.8, 0.95, 0.9);   // snow cap
              gl_FragColor = vec4(mix(low, high, t), 1.0);
            }
          `,
          side: THREE.DoubleSide,
          wireframe: false,
        });
        ```
        
        ### Water Wave Displacement
        
        ```javascript
        // Key pattern: multi-octave wave displacement with layered cnoise ripples
        const waterMat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0 },
            uBigWavesElevation: { value: 0.2 },
            uBigWavesFrequency: { value: new THREE.Vector2(4, 1.5) },
            uSmallWavesElevation: { value: 0.15 },
            uDepthColor: { value: new THREE.Color('#186691') },
            uSurfaceColor: { value: new THREE.Color('#9bd8ff') },
          },
          vertexShader: /* glsl */`
            #include <common>
            uniform float uTime;
            uniform vec2 uBigWavesFrequency;
            uniform float uBigWavesElevation;
            uniform float uSmallWavesElevation;
            varying float vElevation;
            void main() {
              vec4 modelPosition = modelMatrix * vec4(position, 1.0);
              float elevation =
                sin(modelPosition.x * uBigWavesFrequency.x + uTime) *
                sin(modelPosition.z * uBigWavesFrequency.y + uTime) *
                uBigWavesElevation;
              // Layer 3 octaves of cnoise for organic ripples
              for(float i = 1.0; i <= 3.0; i++) {
                elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0 * i, uTime * 0.2))) * uSmallWavesElevation / i;
              }
              modelPosition.y += elevation;
              vElevation = elevation;
              gl_Position = projectionMatrix * viewMatrix * modelPosition;
            }
          `,
          fragmentShader: /* glsl */`
            uniform vec3 uDepthColor;
            uniform vec3 uSurfaceColor;
            varying float vElevation;
            void main() {
              gl_FragColor = vec4(mix(uDepthColor, uSurfaceColor, (vElevation + 0.1) * 5.0), 1.0);
            }
          `,
        });
        ```
        
        ---
        
        ## Fragment Shader Effects
        
        ### Holographic Effect
        
        ```javascript
        const holoMat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0 },
            uColor: { value: new THREE.Color(0x70c5e8) },
          },
          transparent: true,
          side: THREE.DoubleSide,
          depthWrite: false,
          blending: THREE.AdditiveBlending,
          vertexShader: /* glsl */`
            varying vec2 vUv;
            varying float vNormal;
            void main() {
              vUv = uv;
              vec4 normalModelView = normalMatrix * vec4(normal, 0.0);
              // Fresnel-like rim lighting
              vNormal = abs(dot(normalize(normalModelView.xyz), vec3(0,0,1)));
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: /* glsl */`
            uniform float uTime;
            uniform vec3 uColor;
            varying vec2 vUv;
            varying float vNormal;
        
            void main() {
              // Horizontal scan lines
              float scanLine = step(0.5, fract(vUv.y * 50.0 - uTime * 0.5));
              // Rim glow (edges = bright)
              float rim = 1.0 - vNormal;
              float alpha = (scanLine * 0.1 + rim * 0.5) * 0.85;
              gl_FragColor = vec4(uColor, alpha);
            }
          `,
        });
        ```
        
        ### Dissolve Shader
        
        ```javascript
        const dissolveMat = new THREE.ShaderMaterial({
          uniforms: {
            uTime: { value: 0 },
            uProgress: { value: 0 },   // 0 = solid, 1 = fully dissolved
            uEdgeColor: { value: new THREE.Color(0xff6600) },
            uNoiseScale: { value: 3.0 },
          },
          transparent: true,
          side: THREE.DoubleSide,
          vertexShader: /* glsl */`
            varying vec2 vUv;
            void main() {
              vUv = uv;
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: /* glsl */`
            #include <common>
            uniform float uProgress;
            uniform vec3 uEdgeColor;
            uniform float uNoiseScale;
            varying vec2 vUv;
        
            void main() {
              float noise = cnoise(vec3(vUv * uNoiseScale, 0.0)) * 0.5 + 0.5;
              float edge = 0.05; // edge band width
        
              if (noise < uProgress) discard; // dissolve away
        
              float edgeMix = smoothstep(uProgress, uProgress + edge, noise);
              vec3 color = mix(uEdgeColor, vec3(1.0), edgeMix);
              float alpha = step(uProgress + edge, noise) * 1.0 + (1.0 - edgeMix) * 0.8;
        
              gl_FragColor = vec4(color, alpha);
            }
          `,
        });
        ```
        
        ### Chromatic Aberration (Postprocessing Shader)
        
        ```javascript
        // Use as EffectComposer ShaderPass
        const ChromaticAberrationShader = {
          uniforms: {
            tDiffuse: { value: null },
            uOffset: { value: new THREE.Vector2(0.003, 0.003) },
          },
          vertexShader: /* glsl */`
            varying vec2 vUv;
            void main() {
              vUv = uv;
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            }
          `,
          fragmentShader: /* glsl */`
            uniform sampler2D tDiffuse;
            uniform vec2 uOffset;
            varying vec2 vUv;
            void main() {
              float r = texture2D(tDiffuse, vUv + uOffset).r;
              float g = texture2D(tDiffuse, vUv).g;
              float b = texture2D(tDiffuse, vUv - uOffset).b;
              gl_FragColor = vec4(r, g, b, 1.0);
            }
          `,
        };
        ```
        
        ---
        
        ## Postprocessing Pipeline with EffectComposer
        
        ```javascript
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
        import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
        import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
        
        const composer = new EffectComposer(renderer);
        
        // 1. Base render pass — always first
        composer.addPass(new RenderPass(scene, camera));
        
        // 2. SSAO — ambient occlusion adds depth (r150+)
        const ssaoPass = new SSAOPass(scene, camera, width, height);
        ssaoPass.kernelRadius = 16;
        ssaoPass.minDistance = 0.005;
        ssaoPass.maxDistance = 0.1;
        composer.addPass(ssaoPass);
        
        // 3. Bloom — selective bloom via layers (r150+)
        const bloomPass = new UnrealBloomPass(
          new THREE.Vector2(width, height),
          1.5,   // strength: 0-3, start at 1.5
          0.4,   // radius: 0-1
          0.85   // threshold: 0-1 (luminance required to bloom)
        );
        composer.addPass(bloomPass);
        
        // 4. Custom shader passes
        composer.addPass(new ShaderPass(ChromaticAberrationShader));
        
        // 5. Output — color space correction (required in r150+, replaces GammaCorrectionShader)
        composer.addPass(new OutputPass());
        
        // In animation loop — use composer.render() instead of renderer.render()
        renderer.setAnimationLoop((time) => {
          controls.update();
          composer.render();  // NOT renderer.render(scene, camera)
        });
        
        // Resize handler
        window.addEventListener('resize', () => {
          composer.setSize(window.innerWidth, window.innerHeight);
          bloomPass.resolution.set(window.innerWidth, window.innerHeight);
        });
        ```
        
        ---
        
        ## Uniform Patterns and Texture Sampling
        
        ### Passing Textures as Uniforms
        
        ```javascript
        const textureLoader = new THREE.TextureLoader();
        const texture = textureLoader.load('/textures/diffuse.jpg');
        
        const mat = new THREE.ShaderMaterial({
          uniforms: {
            uTexture: { value: texture },       // sampler2D
            uTime: { value: 0 },                // float
            uResolution: { value: new THREE.Vector2(width, height) }, // vec2
            uMouse: { value: new THREE.Vector2() },  // vec2
          },
          fragmentShader: /* glsl */`
            uniform sampler2D uTexture;
            uniform float uTime;
            uniform vec2 uResolution;
            varying vec2 vUv;
        
            void main() {
              // Animated UV distortion using texture
              vec2 distortedUv = vUv + vec2(
                sin(vUv.y * 10.0 + uTime) * 0.02,
                cos(vUv.x * 10.0 + uTime) * 0.02
              );
              vec4 texColor = texture2D(uTexture, distortedUv);
              gl_FragColor = texColor;
            }
          `,
        });
        ```
        
        ---
        
        ## Pattern Catalog
        
        ### Create ShaderMaterial Once, Update Uniforms Per Frame
        
        **Detection**:
        ```bash
        grep -rn "new THREE.ShaderMaterial" --include="*.js" --include="*.ts"
        rg "new THREE\.ShaderMaterial" --type js
        ```
        
        **Signal**:
        ```javascript
        renderer.setAnimationLoop((time) => {
          // BAD: creates new material every frame — leaks GPU memory
          mesh.material = new THREE.ShaderMaterial({
            uniforms: { uTime: { value: time } },
            ...
          });
          renderer.render(scene, camera);
        });
        ```
        
        **Why this matters**: GPU memory leak. Each new `ShaderMaterial` allocates shader programs on the GPU. After 60 seconds at 60fps, you've allocated 3600 programs that are never freed.
        
        **Preferred action**:
        ```javascript
        // Create once, update uniform
        const mat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 } }, ... });
        mesh.material = mat;
        
        renderer.setAnimationLoop((time) => {
          mat.uniforms.uTime.value = time * 0.001; // update, don't recreate
          renderer.render(scene, camera);
        });
        ```
        
        ---
        
        ### Add OutputPass as Final Composer Pass
        
        **Detection**:
        ```bash
        grep -rn "EffectComposer" --include="*.js" --include="*.ts" -l
        rg "GammaCorrectionShader" --type js
        ```
        
        **Signal**:
        ```javascript
        // r150+ — GammaCorrectionShader is deprecated, OutputPass is required
        import { GammaCorrectionShader } from 'three/addons/shaders/GammaCorrectionShader.js'; // deprecated r154+
        ```
        
        **Why this matters**: In Three.js r150+, `renderer.outputColorSpace` defaults to `SRGBColorSpace`. Without `OutputPass` at the end of the composer chain, colors appear washed out or double-gamma-corrected.
        
        **Preferred action**:
        ```javascript
        import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
        composer.addPass(new OutputPass()); // always last pass
        ```
        
        **Version note**: `OutputPass` introduced in r152. For r148-r151, use `ShaderPass(GammaCorrectionShader)` as last pass.
        
        ---
        
        ### Prefer ShaderMaterial Over onBeforeCompile Patching
        
        **Detection**:
        ```bash
        grep -rn "onBeforeCompile" --include="*.js" --include="*.ts"
        rg "shader\.vertexShader\.replace" --type js
        ```
        
        **Signal**:
        ```javascript
        material.onBeforeCompile = (shader) => {
          shader.vertexShader = shader.vertexShader.replace(
            '#include <begin_vertex>',
            `vec3 transformed = position + vec3(sin(uTime), 0.0, 0.0);`
          );
        };
        ```
        
        **Why this matters**: `#include` tokens change between Three.js versions. Replacing `<begin_vertex>` in r155 may not match the chunk in r160. The material silently renders incorrectly with no error.
        
        **Preferred action**: Use `ShaderMaterial` for custom vertex displacement instead of patching `onBeforeCompile`. Reserve `onBeforeCompile` only for lightweight additions to standard materials (fog, lights) where a custom `ShaderMaterial` would be too verbose.
        
        ---
        
        ## Error-Fix Mappings
        
        | Error | Root Cause | Fix |
        |-------|-----------|-----|
        | `WebGL: INVALID_OPERATION: uniform not set` | Uniform declared in GLSL but not in `uniforms: {}` object | Add missing uniform key with initial value |
        | `THREE.WebGLProgram: Shader error — 'cnoise' undefined` | Using `cnoise` without `#include <common>` in ShaderMaterial | Add `#include <common>` at top of vertex shader |
        | Black screen with EffectComposer | Missing `OutputPass` or `GammaCorrectionShader` | Add `composer.addPass(new OutputPass())` as final pass |
        | `Cannot read 'value' of undefined` | Accessing `mat.uniforms.uTime` before material created | Create material before animation loop, guard `if (!mat.uniforms)` |
        | Bloom affecting UI elements | No bloom layer separation | Set emissive objects to `mesh.layers.enable(BLOOM_LAYER)`, use selective bloom technique |
        
        ---
        
        ## See Also
        
        - `references/visual-polish.md` — HDR environments, PBR material recipes, lighting setups
        - `references/webgpu.md` — TSL node materials for WebGPU renderer (different system)
        - `references/performance-patterns.md` — InstancedMesh, BufferGeometry optimization
        
      • visual-polish.md 16.3 KB
        # Visual Polish Reference
        
        Cross-paradigm techniques that make Three.js scenes look impressive rather than basic.
        Load this file alongside the paradigm-specific reference when the user wants high visual
        quality, or when the context is a portfolio, game, showcase, or anything meant to impress.
        
        These techniques apply to imperative Three.js, React Three Fiber, and WebGPU — the code
        examples use imperative syntax but the principles and parameter values transfer directly.
        For R3F equivalents, wrap in JSX components (e.g., `<directionalLight intensity={3.6} />`).
        
        ---
        
        ## The Biggest Single Quality Jump: HDR Environment
        
        Adding an HDR environment map is the single most impactful change you can make. It provides
        realistic reflections AND ambient lighting in one step. A scene with HDR environment looks
        professional; without it, metallic and glossy surfaces look flat and lifeless.
        
        ```javascript
        import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'
        
        const rgbeLoader = new RGBELoader()
        rgbeLoader.load('environment.hdr', (texture) => {
          texture.mapping = THREE.EquirectangularReflectionMapping
          scene.background = texture       // Visible background
          scene.environment = texture      // Reflection source for all PBR materials
        })
        
        // Fine-tune without changing the HDR file:
        scene.backgroundBlurriness = 0.3   // 0-1: soften background without killing reflections
        scene.backgroundIntensity = 0.8    // 0-3: dim/brighten background independently
        // Per-material override:
        material.envMapIntensity = 1.5     // Boost reflections on specific objects
        ```
        
        **R3F equivalent** (drei):
        ```tsx
        import { Environment } from '@react-three/drei'
        <Environment preset="city" background backgroundBlurriness={0.3} />
        // Available presets: apartment, city, dawn, forest, lobby, night, park, studio, sunset, warehouse
        ```
        
        **Free HDR sources**: Poly Haven (polyhaven.com/hdris) — CC0, production quality, multiple resolutions.
        
        ---
        
        ## PBR Material Recipes
        
        Specific parameter values that produce convincing real-world materials. These are tested
        combinations — don't guess at roughness/metalness values.
        
        ### Metal Surfaces
        
        ```javascript
        // Gold
        const gold = new THREE.MeshStandardMaterial({
          color: 0xfbbf24,
          roughness: 0.15,
          metalness: 0.95,
        })
        
        // Brushed steel
        const steel = new THREE.MeshStandardMaterial({
          color: 0x888888,
          roughness: 0.35,
          metalness: 0.9,
        })
        
        // Chrome (mirror)
        const chrome = new THREE.MeshStandardMaterial({
          color: 0xffffff,
          roughness: 0.05,
          metalness: 1.0,
        })
        ```
        
        ### Glass and Transparent Materials
        
        ```javascript
        // Glass (MeshPhysicalMaterial for transmission)
        const glass = new THREE.MeshPhysicalMaterial({
          transmission: 0.95,    // 0-1: how see-through
          ior: 1.45,             // Index of refraction (glass ~1.5, water ~1.33, diamond ~2.42)
          thickness: 0.5,        // Simulated thickness for refraction
          roughness: 0.05,
          clearcoat: 1.0,
          clearcoatRoughness: 0.1,
          attenuationColor: new THREE.Color(0xa855f7),  // Tinted glass color
          attenuationDistance: 0.5,
        })
        ```
        
        ### Special Surface Effects
        
        ```javascript
        // Iridescent (oil slick, soap bubble, beetle shell)
        const iridescent = new THREE.MeshPhysicalMaterial({
          iridescence: 1.0,
          iridescenceIOR: 1.8,
          iridescenceThicknessRange: [100, 800],  // nm — controls color shift range
          metalness: 0.8,
          roughness: 0.15,
        })
        
        // Fabric/velvet (sheen)
        const fabric = new THREE.MeshPhysicalMaterial({
          color: 0x2244aa,
          sheen: 1.0,
          sheenRoughness: 0.3,
          sheenColor: new THREE.Color(0xff88cc),
          roughness: 0.8,
        })
        
        // Toon/cel-shading
        const toon = new THREE.MeshToonMaterial({
          color: 0x44aa88,
          // gradientMap: fiveToneTexture  // Load from threejs.org's fiveTone.jpg with NearestFilter
        })
        ```
        
        ---
        
        ## Dramatic Lighting Setup
        
        Default Three.js lighting (single ambient + directional) produces flat, boring results.
        Professional-looking scenes use a three-point lighting setup with specific intensity values.
        
        ### Three-Point Lighting Recipe
        
        ```javascript
        // 1. Key light (warm, main shadow caster) — the dominant light
        const keyLight = new THREE.DirectionalLight(0xfff0dd, 3.6)
        keyLight.position.set(3.8, 5.6, 3.5)
        keyLight.castShadow = true
        keyLight.shadow.mapSize.set(2048, 2048)
        keyLight.shadow.bias = -0.0008  // Prevents shadow acne without detaching
        scene.add(keyLight)
        
        // 2. Fill light (cool, opposite side) — softens harsh shadows
        const fillLight = new THREE.DirectionalLight(0xaaccff, 0.8)
        fillLight.position.set(-4, 3, -2)
        scene.add(fillLight)
        
        // 3. Rim/back light — separates objects from background
        const rimLight = new THREE.SpotLight(0xff8844, 42)  // SpotLights need higher intensity
        rimLight.position.set(-2, 4, -5)
        rimLight.penumbra = 0.4   // Soft edge falloff
        rimLight.decay = 1.5
        rimLight.angle = Math.PI / 6
        scene.add(rimLight)
        
        // 4. Ambient fill — prevents pure black in shadow areas
        const ambientLight = new THREE.AmbientLight(0x404060, 0.3)
        scene.add(ambientLight)
        ```
        
        **Intensity calibration** (physically-based, r155+):
        - DirectionalLight: 1-5 range for primary lights
        - PointLight: ~26 to match equivalent directional
        - SpotLight: ~42 to match equivalent directional
        - AmbientLight: 0.1-0.5 (too high flattens everything)
        
        ### Tone Mapping
        
        Always set tone mapping — it compresses HDR values into displayable range and adds cinematic feel:
        
        ```javascript
        renderer.toneMapping = THREE.ACESFilmicToneMapping  // Best general-purpose
        renderer.toneMappingExposure = 1.0  // Adjust brightness
        // Other options: ReinhardToneMapping (softer), CineonToneMapping (film-like)
        ```
        
        ---
        
        ## Post-Processing Stack
        
        Post-processing is what separates "tech demo" from "polished product." The pass ordering matters.
        
        ### Required Pass Order
        
        ```
        RenderPass → SSAO → UnrealBloomPass → Color Grading → Film Grain → OutputPass
        ```
        
        Earlier passes operate on HDR data. Later passes operate on the combined image. Getting the order
        wrong produces subtle visual artifacts (bloom applied to SSAO halos, grain affecting bloom, etc.).
        
        ### Imperative Setup
        
        ```javascript
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
        import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
        
        const composer = new EffectComposer(renderer)
        composer.addPass(new RenderPass(scene, camera))
        
        // Bloom — sweet spot values for subtle, not blown-out glow
        const bloomPass = new UnrealBloomPass(
          new THREE.Vector2(window.innerWidth, window.innerHeight),
          0.15,  // strength — keep LOW for subtlety (0.1-0.3 for accents, 1.0+ for neon)
          0.4,   // radius — blur spread
          0.8    // threshold — only bloom values above this (0.8 = only bright things glow)
        )
        composer.addPass(bloomPass)
        
        composer.addPass(new OutputPass())  // Always last — handles tone mapping + color space
        
        // In animation loop: replace renderer.render() with composer.render()
        ```
        
        ### Selective Bloom (Advanced)
        
        The default bloom haloes everything bright. For selective bloom (only specific objects glow):
        
        1. Create a second scene with only emissive objects
        2. Render and bloom that scene separately
        3. Composite the bloom layer back onto the main render
        
        This prevents UI elements, white surfaces, and bright materials from getting unwanted halos.
        
        ### Bloom + Emissive Materials
        
        For bloom to work on emissive materials, two things must be true:
        1. `emissiveIntensity` must be > 1.0 (HDR value, above the bloom threshold)
        2. `toneMapped: false` on the material — otherwise the renderer clamps values to [0,1] before bloom sees them
        
        ```javascript
        const glowMaterial = new THREE.MeshStandardMaterial({
          color: 0xff6600,
          emissive: 0xff6600,
          emissiveIntensity: 3.0,  // Must exceed bloom threshold
          toneMapped: false,        // CRITICAL for bloom pickup
        })
        ```
        
        ---
        
        ## Shadow Quality
        
        ### Soft Shadows
        
        ```javascript
        renderer.shadowMap.enabled = true
        renderer.shadowMap.type = THREE.VSMShadowMap  // Variance Shadow Map — softest edges
        
        // On the shadow-casting light:
        light.shadow.mapSize.set(2048, 2048)  // 2048 for quality, 1024 for performance
        light.shadow.blurSamples = 25         // VSM blur quality
        light.shadow.radius = 15              // VSM blur spread
        light.shadow.normalBias = 0.02        // Prevents acne without detaching shadows
        ```
        
        ### Contact Shadows (R3F)
        
        ```tsx
        import { ContactShadows } from '@react-three/drei'
        <ContactShadows
          position={[0, -0.5, 0]}
          opacity={0.4}
          blur={2}
          far={4}
          resolution={256}
        />
        ```
        
        ---
        
        ## Camera Systems for Games
        
        | Control Type | Use Case | Import |
        |-------------|----------|--------|
        | `OrbitControls` | Model viewers, inspection | `three/addons/controls/OrbitControls.js` |
        | `PointerLockControls` | First-person games | `three/addons/controls/PointerLockControls.js` |
        | `FlyControls` | Free flight, level editors | `three/addons/controls/FlyControls.js` |
        | Cinematic path | Cutscenes, tours | `THREE.CatmullRomCurve3` + `getPointAt(t)` |
        
        ### Cinematic Camera Path
        
        ```javascript
        const path = new THREE.CatmullRomCurve3([
          new THREE.Vector3(-10, 5, 10),
          new THREE.Vector3(0, 8, 0),
          new THREE.Vector3(10, 3, -10),
          new THREE.Vector3(0, 5, 0),
        ], true)  // true = closed loop
        
        // In animation loop:
        const t = (time * 0.0001) % 1  // 0-1 along path
        const pos = path.getPointAt(t)
        const lookAt = path.getPointAt((t + 0.01) % 1)
        camera.position.copy(pos)
        camera.lookAt(lookAt)
        ```
        
        ---
        
        ## Particle Effects
        
        ### Simple Particle System (Imperative)
        
        ```javascript
        const count = 5000
        const geometry = new THREE.BufferGeometry()
        const positions = new Float32Array(count * 3)
        const colors = new Float32Array(count * 3)
        
        for (let i = 0; i < count; i++) {
          positions[i * 3] = (Math.random() - 0.5) * 20
          positions[i * 3 + 1] = Math.random() * 10
          positions[i * 3 + 2] = (Math.random() - 0.5) * 20
          colors[i * 3] = Math.random()
          colors[i * 3 + 1] = Math.random() * 0.5 + 0.5
          colors[i * 3 + 2] = 1.0
        }
        
        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
        geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
        
        const material = new THREE.PointsMaterial({
          size: 0.05,
          vertexColors: true,
          transparent: true,
          opacity: 0.8,
          blending: THREE.AdditiveBlending,  // Particles add light — key for glow effect
          depthWrite: false,                 // Prevents sorting artifacts
        })
        
        const particles = new THREE.Points(geometry, material)
        scene.add(particles)
        ```
        
        ### R3F Shortcut (Drei)
        
        ```tsx
        import { Sparkles, Stars } from '@react-three/drei'
        
        <Sparkles count={200} scale={10} size={2} speed={0.4} />
        <Stars radius={100} depth={50} count={5000} factor={4} fade speed={1} />
        ```
        
        ---
        
        ## Animation Polish
        
        ### AnimationMixer with Crossfade
        
        For character animation (walk cycles, idle, run), blend between clips smoothly:
        
        ```javascript
        const mixer = new THREE.AnimationMixer(model)
        const idleAction = mixer.clipAction(idleClip)
        const walkAction = mixer.clipAction(walkClip)
        
        // Crossfade from idle to walk over 0.3 seconds
        idleAction.crossFadeTo(walkAction, 0.3, true)
        walkAction.play()
        
        // In animation loop:
        mixer.update(delta)
        ```
        
        **Morph targets** for facial expressions or shape deformations:
        ```javascript
        mesh.morphTargetInfluences[0] = Math.sin(time) * 0.5 + 0.5  // Blend 0-1
        ```
        
        **Additive animation layers** for breathing on top of other animations:
        ```javascript
        breathAction.setEffectiveWeight(0.3)
        breathAction.blendMode = THREE.AdditiveAnimationBlendMode
        breathAction.play()
        ```
        
        ---
        
        ## Additional Post-Processing Effects
        
        Beyond bloom, these passes add cinematic quality:
        
        ```javascript
        import { FilmPass } from 'three/addons/postprocessing/FilmPass.js'
        import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
        import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'
        import { FXAAShader } from 'three/addons/shaders/FXAAShader.js'
        
        // Film grain — adds texture, reduces "CG look"
        const filmPass = new FilmPass(0.35, false)  // intensity, grayscale
        composer.addPass(filmPass)
        
        // Outline — highlight selected/hovered objects
        const outlinePass = new OutlinePass(
          new THREE.Vector2(window.innerWidth, window.innerHeight),
          scene, camera
        )
        outlinePass.edgeStrength = 3
        outlinePass.edgeGlow = 0.5
        outlinePass.edgeThickness = 1
        outlinePass.selectedObjects = [highlightedMesh]
        composer.addPass(outlinePass)
        
        // FXAA anti-aliasing (cheaper than MSAA, applied as post-pass)
        const fxaaPass = new ShaderPass(FXAAShader)
        fxaaPass.uniforms['resolution'].value.set(1 / window.innerWidth, 1 / window.innerHeight)
        composer.addPass(fxaaPass)
        ```
        
        **Pass ordering reminder**: `RenderPass` → `SSAO` → `UnrealBloomPass` → `OutlinePass` → color grading → `FilmPass` → `FXAA` → `OutputPass`
        
        ---
        
        ## Performance Optimization Techniques
        
        ### BatchedMesh (Varied Geometries, One Draw Call)
        
        Unlike `InstancedMesh` (identical objects), `BatchedMesh` batches different geometries:
        
        ```javascript
        const batchedMesh = new THREE.BatchedMesh(100, 5000, 10000, material)
        const boxId = batchedMesh.addGeometry(new THREE.BoxGeometry(1, 1, 1))
        const sphereId = batchedMesh.addGeometry(new THREE.SphereGeometry(0.5))
        
        // Add instances of different geometries
        for (let i = 0; i < 50; i++) {
          const instanceId = batchedMesh.addInstance(i % 2 === 0 ? boxId : sphereId)
          batchedMesh.setMatrixAt(instanceId, matrix)
        }
        scene.add(batchedMesh)
        ```
        
        ### LOD (Level of Detail)
        
        Switch to simpler geometry at distance — critical for large scenes:
        
        ```javascript
        const lod = new THREE.LOD()
        lod.addLevel(highDetailMesh, 0)    // Full detail when close
        lod.addLevel(mediumDetailMesh, 50) // Simpler at 50 units
        lod.addLevel(lowDetailMesh, 200)   // Minimal at 200 units
        scene.add(lod)
        ```
        
        ### Asset Compression
        
        | Technique | What It Does | Import |
        |-----------|-------------|--------|
        | KTX2/Basis | GPU-compressed textures (4-8x smaller, decoded on GPU) | `KTX2Loader` from `three/addons/loaders/KTX2Loader.js` |
        | Draco | Compressed GLTF geometry (60-90% smaller meshes) | `DRACOLoader` from `three/addons/loaders/DRACOLoader.js` |
        | meshopt | Alternative mesh compression (better for animation) | `MeshoptDecoder` |
        
        ```javascript
        import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'
        import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
        
        // Draco for GLTF geometry
        const dracoLoader = new DRACOLoader()
        dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/')
        gltfLoader.setDRACOLoader(dracoLoader)
        
        // KTX2 for textures
        const ktx2Loader = new KTX2Loader()
        ktx2Loader.setTranscoderPath('https://unpkg.com/three@0.160.0/examples/jsm/libs/basis/')
        ktx2Loader.detectSupport(renderer)
        ```
        
        ---
        
        ## Spatial Audio
        
        For immersive 3D scenes and games:
        
        ```javascript
        const listener = new THREE.AudioListener()
        camera.add(listener)
        
        const sound = new THREE.PositionalAudio(listener)
        const audioLoader = new THREE.AudioLoader()
        audioLoader.load('sound.mp3', (buffer) => {
          sound.setBuffer(buffer)
          sound.setRefDistance(10)    // Distance at which volume starts falling off
          sound.setRolloffFactor(1)  // How quickly volume decreases with distance
          sound.setLoop(true)
          sound.play()
        })
        
        // Attach to an object — sound follows it in 3D space
        soundEmitter.add(sound)
        ```
        
        ---
        
        ## Special Effects
        
        ### Clipping Planes (Portals, Cutaways, Reveals)
        
        ```javascript
        const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 1)  // Normal + distance
        
        const material = new THREE.MeshStandardMaterial({
          color: 0x44aa88,
          clippingPlanes: [clipPlane],  // Per-material clipping
          clipShadows: true,
        })
        
        renderer.clippingPlanes = [clipPlane]  // Or global clipping
        renderer.localClippingEnabled = true   // Required for per-material clipping
        
        // Animate the clip plane for a reveal effect:
        // clipPlane.constant += delta * 2
        ```
        
        ---
        
        ## Quick Polish Checklist
        
        Before delivering any Three.js scene, verify these in order:
        
        1. **Tone mapping set** — `ACESFilmicToneMapping` prevents washed-out colors
        2. **DPR capped** — `Math.min(devicePixelRatio, 2)` prevents 4K mobile GPU meltdown
        3. **At least 2 light sources** — single light = flat; 3-point = dramatic
        4. **Shadows enabled** if ground plane exists — floating objects look wrong
        5. **Resize handler** — test at 3 different viewport sizes
        6. **Background is not default** — black `#000` or gradient or HDR environment
        7. **Antialiasing on** — `{ antialias: true }` on renderer
        8. **Post-processing** if emissive materials — bloom makes glow visible
        9. **No console errors** — check browser devtools
        10. **Animation uses delta/time** — not fixed increments (breaks on high-refresh displays)
        
      • webgpu.md 20.4 KB
        # WebGPU Three.js Reference
        
        Paradigm-specific reference for building 3D applications with Three.js WebGPU renderer.
        Load this file when the project uses `WebGPURenderer`, TSL (Three Shading Language),
        node materials, or compute shaders.
        
        **Status**: WebGPU support in Three.js requires r171+ minimum for stable TSL. Target r183+
        for full feature set. The API surface is larger than WebGL and requires understanding
        node-based materials.
        
        ---
        
        ## Core Architecture
        
        Three.js WebGPU uses the same scene graph as WebGL but replaces the rendering backend.
        The key differences:
        
        | Aspect | WebGL (default) | WebGPU |
        |--------|----------------|--------|
        | Renderer | `WebGLRenderer` | `WebGPURenderer` |
        | Materials | `MeshStandardMaterial` etc. | `MeshStandardNodeMaterial` (node-based) |
        | Shaders | GLSL (vertex + fragment) | TSL (Three Shading Language) — JS-authored |
        | Compute | Not supported | Compute shaders via `compute()` |
        | Performance | Mature, wide support | Better batching, GPU compute, less CPU overhead |
        | Browser support | Universal | Chrome 113+, Edge 113+, Firefox (behind flag), Safari (partial) |
        
        ### Project Setup
        
        ```bash
        npm create vite@latest my-webgpu-app -- --template vanilla-ts
        cd my-webgpu-app
        npm install three
        ```
        
        ### WebGPU Renderer Initialization
        
        ```typescript
        // IMPORTANT: Import from 'three/webgpu', NOT 'three' — this is the r171+ pattern
        import * as THREE from 'three/webgpu'
        import { color, time, oscSine } from 'three/tsl'
        
        const renderer = new THREE.WebGPURenderer({ antialias: true })
        renderer.setSize(window.innerWidth, window.innerHeight)
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
        document.body.appendChild(renderer.domElement)
        
        // CRITICAL: WebGPU requires async initialization
        await renderer.init()
        
        // Then proceed with scene setup as normal
        const scene = new THREE.Scene()
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
        ```
        
        **Two critical differences from WebGL**:
        1. Import from `three/webgpu` not `three` — this gives you `WebGPURenderer` on `THREE` namespace
        2. `renderer.init()` is async — the renderer is not ready until the promise resolves. Always `await` it before rendering or adding objects
        
        ### Fallback Pattern
        
        ```typescript
        async function createRenderer() {
          if (navigator.gpu) {
            const renderer = new WebGPURenderer({ antialias: true })
            await renderer.init()
            return renderer
          }
          // Fallback to WebGL
          console.warn('WebGPU not available, falling back to WebGL')
          return new THREE.WebGLRenderer({ antialias: true })
        }
        ```
        
        ---
        
        ## TSL (Three Shading Language)
        
        TSL replaces GLSL for WebGPU shaders. Instead of writing shader text, you write JavaScript functions that build a shader node graph. This is the biggest paradigm shift.
        
        ### Why TSL Over GLSL
        
        - **Cross-backend**: TSL compiles to both WGSL (WebGPU) and GLSL (WebGL) — write once, run on both
        - **Type-safe**: Shader logic is JavaScript — you get IDE autocomplete and type checking
        - **Composable**: Node functions compose like regular functions, no string concatenation
        - **Debuggable**: Errors are JavaScript errors with stack traces, not opaque GLSL compilation errors
        
        ### Basic TSL Patterns
        
        ```typescript
        import {
          uniform, attribute, varying, vec3, vec4, float,
          sin, cos, mul, add, mix, normalize, dot,
          positionLocal, positionWorld, normalLocal, normalWorld,
          uv, time, cameraPosition,
          MeshStandardNodeMaterial
        } from 'three/tsl'
        
        // Time-based color animation
        const material = new MeshStandardNodeMaterial()
        material.colorNode = mix(
          vec3(1.0, 0.0, 0.0),  // red
          vec3(0.0, 0.0, 1.0),  // blue
          sin(mul(time, 0.5)).mul(0.5).add(0.5)  // oscillate 0-1
        )
        ```
        
        ### TSL Method Chaining (No Operator Syntax)
        
        TSL uses method chaining, not JavaScript operators. There is no `+`, `-`, `*` syntax for nodes:
        
        ```typescript
        // CORRECT: method chaining
        time.mul(2.0).add(offset).sin().mul(0.5).add(0.5)
        
        // WRONG: JavaScript operators don't work on TSL nodes
        time * 2.0 + offset  // Returns NaN or type error
        ```
        
        ### Built-in Oscillators
        
        TSL provides oscillators that return 0-1 range, saving common boilerplate:
        
        ```typescript
        import { oscSine, oscSquare, oscTriangle, oscSawtooth } from 'three/tsl'
        
        material.colorNode = mix(colorA, colorB, oscSine())  // Smooth sine wave 0-1
        // oscSquare — hard on/off toggle
        // oscTriangle — linear ramp up and down
        // oscSawtooth — linear ramp up, hard reset
        ```
        
        ### TSL Node Types
        
        | Node | Purpose | Example |
        |------|---------|---------|
        | `uniform(value)` | CPU-controlled value updated per frame | `uniform(0.0)` |
        | `attribute(name)` | Vertex attribute access | `attribute('position')` |
        | `positionLocal` | Object-space vertex position | Vertex displacement |
        | `positionWorld` | World-space vertex position | Distance-based effects |
        | `normalLocal` / `normalWorld` | Surface normals (r178+: renamed from `transformedNormal*`) | Lighting calculations |
        | `normalView` | View-space normal (r178+: renamed from `transformedNormalView`) | Rim/fresnel effects |
        | `uv()` | UV coordinates | Texture mapping |
        | `screenUV` | Screen-space UV coordinates | Full-screen effects |
        | `screenSize` | Screen dimensions in pixels | Resolution-dependent effects |
        | `time` | Elapsed time in seconds | Animation |
        | `cameraPosition` | Camera world position | View-dependent effects |
        | `instanceIndex` | Current instance ID in compute | Per-instance logic |
        
        ### Built-in Effect Functions
        
        TSL includes pre-built effect functions that save significant boilerplate:
        
        ```typescript
        import { fresnel, triplanarTexture } from 'three/tsl'
        
        // Rim glow — one-liner instead of manual dot/pow calculation
        material.emissiveNode = fresnel()  // Bright at edges, dark at center
        
        // Triplanar texture mapping — no UV unwrap needed for organic/terrain shapes
        material.colorNode = triplanarTexture(texture(myTexture), null, null, float(1.0))
        ```
        
        ### Auto-Updating Uniforms
        
        Control when uniforms update — avoids unnecessary per-frame updates:
        
        ```typescript
        const u = uniform(0.0)
        u.onFrameUpdate(() => performance.now() * 0.001)   // Update every frame
        u.onObjectUpdate((obj) => obj.position.y)            // Update per-object render
        u.onRenderUpdate((obj, cam) => cam.position.length()) // Update per render call
        ```
        
        ### CRITICAL: TSL Variable Reassignment Footgun
        
        **This is the #1 source of silent bugs in TSL.** TSL intercepts property assignments on node
        objects but CANNOT intercept JavaScript variable reassignment. The result is silently wrong output
        with no error message.
        
        ```typescript
        // BROKEN — TSL can't see JS variable reassignment
        let value = buffer.element(index).toFloat()
        If(condition, () => {
          value = value.add(1.0)  // Creates new node, JS var reassigns — TSL lost the connection
        })
        // 'value' now points to an orphaned node. The buffer was never updated.
        
        // CORRECT — Pattern 1: select() for simple conditionals
        const result = select(condition, trueVal, falseVal)
        
        // CORRECT — Pattern 2: .toVar() + .assign() for mutable state
        const value = buffer.element(index).toFloat().toVar()
        If(condition, () => {
          value.assign(value.add(1.0))  // TSL tracks .assign() — buffer IS updated
        })
        
        // CORRECT — Pattern 3: Direct .assign() on buffer element
        If(condition, () => {
          element.assign(element.add(1.0))
        })
        ```
        
        ### TSL Control Flow
        
        ```typescript
        import { If, Switch, Loop, select } from 'three/tsl'
        
        // Conditional (simple)
        const result = select(condition, trueValue, falseValue)
        
        // Conditional (complex, with side effects)
        If(condition, () => {
          // true branch — use .assign() for mutations
        }).ElseIf(otherCondition, () => {
          // else-if branch
        }).Else(() => {
          // else branch
        })
        
        // Switch
        Switch(integerNode)
          .Case(0, () => { /* handle 0 */ })
          .Case(1, () => { /* handle 1 */ })
          .Default(() => { /* fallback */ })
        
        // Loop
        Loop(count, ({ i }) => {
          // i is the iteration index node
        })
        ```
        
        ### Noise Functions (MaterialX)
        
        ```typescript
        import { mx_noise_float, mx_worley_noise, mx_fractal_noise } from 'three/tsl'
        
        // Perlin noise
        material.colorNode = mx_noise_float(positionWorld.mul(2.0))
        
        // Cellular/Voronoi noise
        material.colorNode = mx_worley_noise(positionWorld.mul(3.0))
        
        // Fractal Brownian Motion (layered noise)
        material.colorNode = mx_fractal_noise(positionWorld.mul(1.5))
        ```
        
        ### Raw WGSL Integration
        
        When TSL math is insufficient (complex PBR terms, optimized noise), embed raw WGSL:
        
        ```typescript
        import { wgslFn, vec3 } from 'three/tsl'
        
        const simplexNoise = wgslFn(`
          fn simplex3d(v: vec3f) -> f32 {
            // Full WGSL implementation here
            return noise_value;
          }
        `)
        
        material.colorNode = vec3(simplexNoise(positionWorld), 0.5, 0.5)
        ```
        
        ### Vertex Displacement
        
        ```typescript
        import { positionLocal, sin, mul, time, normalLocal, float } from 'three/tsl'
        
        const material = new MeshStandardNodeMaterial()
        
        // Wave displacement along normals
        const displacement = sin(
          mul(positionLocal.y, float(4.0)).add(mul(time, float(2.0)))
        ).mul(float(0.2))
        
        material.positionNode = positionLocal.add(
          normalLocal.mul(displacement)
        )
        ```
        
        ### Custom Fresnel Effect
        
        ```typescript
        import {
          normalWorld, cameraPosition, positionWorld,
          normalize, dot, pow, sub, float, vec3, mix
        } from 'three/tsl'
        
        const viewDir = normalize(sub(cameraPosition, positionWorld))
        const fresnel = pow(sub(float(1.0), dot(normalWorld, viewDir)), float(3.0))
        
        const material = new MeshStandardNodeMaterial()
        material.colorNode = mix(
          vec3(0.1, 0.1, 0.3),  // base color
          vec3(0.3, 0.6, 1.0),  // edge color
          fresnel
        )
        ```
        
        ---
        
        ## Compute Shaders
        
        WebGPU enables GPU compute — parallel processing that runs on the GPU independently from rendering. This is new capability not available in WebGL.
        
        ### Use Cases
        
        | Use Case | Why Compute | Alternative |
        |----------|-------------|-------------|
        | Particle simulation (10k+) | CPU can't update 10k+ positions at 60fps | InstancedMesh with CPU loop (slow at scale) |
        | Terrain generation | Procedural generation is embarrassingly parallel | Pre-generate on CPU (slow, no real-time updates) |
        | Physics (cloth, fluid) | Constraint solving benefits from GPU parallelism | CPU physics library (Cannon, Rapier) |
        | Image processing | Per-pixel operations map perfectly to GPU threads | WebGL shader pass (limited flexibility) |
        
        ### Basic Compute Pattern
        
        ```typescript
        import {
          storageObject, instanceIndex, float, vec3,
          compute, sin, mul, time
        } from 'three/tsl'
        
        // Create storage buffer
        const count = 10000
        const positionBuffer = new THREE.StorageBufferAttribute(
          new Float32Array(count * 3), 3
        )
        
        // Define compute function
        const computePositions = compute(() => {
          const i = instanceIndex
          const t = time
        
          // Each GPU thread updates one particle
          const x = sin(mul(float(i), float(0.01)).add(t))
          const y = sin(mul(float(i), float(0.013)).add(mul(t, float(1.3))))
          const z = sin(mul(float(i), float(0.017)).add(mul(t, float(0.7))))
        
          storageObject(positionBuffer).element(i).assign(vec3(x, y, z))
        }, count)  // Dispatch count threads
        
        // In animation loop (synchronous since r181 — computeAsync() is deprecated):
        renderer.compute(computePositions)
        ```
        
        ### Storage Buffer Types
        
        | Function | Access | Use Case |
        |----------|--------|----------|
        | `instancedArray(count, 'vec3')` | Read-write | Particle positions, mutable state |
        | `attributeArray(data, 'float')` | Read-only | Input data, lookup tables |
        
        This distinction is often missed — using `instancedArray` for read-only data wastes GPU resources,
        and `attributeArray` for write targets silently fails.
        
        ### Atomic Operations
        
        For compute shaders that need thread-safe accumulation (counting, finding min/max across threads):
        
        ```typescript
        import { atomicAdd, atomicMax, atomicMin, atomicStore } from 'three/tsl'
        
        // Thread-safe counter increment
        atomicAdd(counterBuffer.element(0), int(1))
        
        // Find maximum value across all threads
        atomicMax(resultBuffer.element(0), currentValue)
        ```
        
        ### Workgroup Configuration
        
        ```typescript
        // Default workgroup size
        const shader = compute(fn, count)
        
        // Custom workgroup size — critical for shared memory patterns
        const shader = compute(fn, count, [64])  // 64 threads per workgroup
        
        // Workgroup barriers for synchronization
        import { workgroupBarrier, storageBarrier, textureBarrier } from 'three/tsl'
        workgroupBarrier()   // Sync all threads in workgroup
        storageBarrier()     // Ensure storage buffer writes are visible
        textureBarrier()     // Ensure texture writes are visible
        ```
        
        ### Device Limits for Large Compute
        
        WebGPU devices use conservative default limits. Large particle systems or buffers silently fail
        without explicit limit requests:
        
        ```typescript
        const renderer = new THREE.WebGPURenderer({
          requiredLimits: {
            maxBufferSize: 1024 * 1024 * 1024,              // 1 GiB (default: 256 MiB)
            maxStorageBufferBindingSize: 1024 * 1024 * 512,  // 512 MiB (default: 128 MiB)
          },
        })
        
        // Safe pattern — check adapter limits first:
        const adapter = await navigator.gpu.requestAdapter()
        const maxBuffer = adapter.limits.maxBufferSize
        console.log(`GPU supports up to ${maxBuffer / 1024 / 1024} MiB buffers`)
        ```
        
        ---
        
        ## Node Materials Reference
        
        WebGPU node materials extend standard materials with programmable nodes:
        
        ### MeshStandardNodeMaterial
        
        The most commonly used — PBR material with customizable nodes.
        
        ```typescript
        const material = new MeshStandardNodeMaterial({
          color: 0x44aa88,
          metalness: 0.5,
          roughness: 0.3,
        })
        
        // Override specific nodes:
        material.colorNode = /* TSL expression */        // Surface color
        material.normalNode = /* TSL expression */        // Surface normal modification
        material.positionNode = /* TSL expression */      // Vertex displacement
        material.emissiveNode = /* TSL expression */      // Emissive light
        material.opacityNode = /* TSL expression */       // Transparency
        material.metalnessNode = /* TSL expression */     // Per-pixel metalness
        material.roughnessNode = /* TSL expression */     // Per-pixel roughness
        ```
        
        ### MeshBasicNodeMaterial
        
        Unlit material — useful for UI elements, wireframes, post-processing.
        
        ### SpriteNodeMaterial
        
        For particle systems and billboards.
        
        ---
        
        ## Migration from WebGL
        
        ### Drop-in Replacement (Level 1)
        
        Switch renderer only — existing materials continue to work:
        
        ```typescript
        // Before (WebGL)
        const renderer = new THREE.WebGLRenderer({ antialias: true })
        
        // After (WebGPU)
        import WebGPURenderer from 'three/webgpu'
        const renderer = new WebGPURenderer({ antialias: true })
        await renderer.init()
        ```
        
        Standard Three.js materials (`MeshStandardMaterial`, etc.) work with WebGPU renderer. No code changes needed beyond the renderer swap.
        
        ### Node Materials (Level 2)
        
        Replace standard materials with node variants for custom effects:
        
        ```typescript
        // Before (WebGL GLSL shader)
        const material = new THREE.ShaderMaterial({
          vertexShader: '...',
          fragmentShader: '...',
          uniforms: { time: { value: 0 } }
        })
        
        // After (WebGPU TSL)
        import { MeshStandardNodeMaterial, time, sin, vec3 } from 'three/tsl'
        const material = new MeshStandardNodeMaterial()
        material.colorNode = vec3(sin(time), 0.5, 0.5)
        // No uniform management needed — time is a built-in node
        ```
        
        ### Compute Shaders (Level 3)
        
        Add GPU compute for particle systems, procedural generation, physics. This has no WebGL equivalent — it's new capability.
        
        ---
        
        ## Performance Patterns
        
        ### WebGPU-Specific Optimizations
        
        | Pattern | Benefit |
        |---------|---------|
        | Use `StorageBufferAttribute` for dynamic data | GPU reads directly, no CPU → GPU copy per frame |
        | Batch draw calls with same material | WebGPU already batches better than WebGL, but same-material still helps |
        | Use compute shaders for particle updates | 100x faster than CPU loop for 10k+ particles |
        | Avoid frequent uniform changes between draw calls | Uniform buffers have binding cost per change |
        
        ### When NOT to Use WebGPU
        
        - **Wide browser support needed** — Safari/Firefox support is incomplete
        - **Simple scenes** — WebGPU's advantages emerge at scale (many objects, compute, complex shaders)
        - **Existing WebGL codebase with GLSL shaders** — migration cost may exceed benefit unless compute is needed
        - **Mobile targets** — WebGPU mobile support is limited
        
        ---
        
        ## Version-Specific Breaking Changes
        
        | Version | Change | Migration |
        |---------|--------|-----------|
        | r171+ | Minimum for stable TSL | Use `three/webgpu` import path |
        | r178+ | `PI2` renamed to `TWO_PI` | Find/replace |
        | r178+ | `transformedNormalView` → `normalView` | Find/replace |
        | r178+ | `transformedNormalWorld` → `normalWorld` | Find/replace |
        | r181+ | `renderer.compute()` is synchronous | Remove `await` from compute calls |
        | r181+ | `renderer.computeAsync()` deprecated | Use `renderer.compute()` |
        | r183+ | `PostProcessing` replaced by `RenderPipeline` | Use `pass(scene, camera)` + `renderPipeline.outputNode` |
        
        ### r183+ Post-Processing with RenderPipeline
        
        The `PostProcessing` class was replaced by `RenderPipeline` in r183. This is a significant API
        change that most tutorials and examples haven't caught up with yet.
        
        ```typescript
        import { pass, RenderPipeline } from 'three/tsl'
        
        // Create render pass
        const scenePass = pass(scene, camera)
        
        // Create pipeline and set output
        const renderPipeline = new RenderPipeline(renderer)
        renderPipeline.outputNode = scenePass
        
        // In animation loop:
        renderPipeline.render()
        ```
        
        ---
        
        ## Device Loss Recovery
        
        Production apps must handle GPU device loss (driver crash, GPU hang, tab backgrounding):
        
        ```typescript
        async function initWebGPU() {
          const renderer = new THREE.WebGPURenderer({ antialias: true })
          await renderer.init()
        
          // Monitor for device loss
          renderer.backend.device.lost.then((info) => {
            console.error(`GPU device lost: ${info.reason}`, info.message)
        
            if (info.reason === 'destroyed') return  // Intentional, don't recover
        
            // 'unknown' = driver crash or GPU hang — attempt recovery
            renderer.dispose()
            initWebGPU()  // Re-initialize everything
          })
        
          return renderer
        }
        
        // Testing device loss:
        // renderer.backend.device.destroy()  // Programmatic trigger
        // Chrome: about:gpucrash for real GPU crash simulation
        // Chrome flags for repeated testing (disables crash rate limiting):
        //   --disable-domain-blocking-for-3d-apis --disable-gpu-process-crash-limit
        ```
        
        For production apps, preserve state across recovery using `localStorage` before
        `renderer.dispose()`, then restore after re-initialization.
        
        ---
        
        ## Common Patterns to Detect and Fix
        
        ### 1. Forgetting Async Init
        
        ```typescript
        // BAD: Renderer not ready
        const renderer = new WebGPURenderer()
        renderer.render(scene, camera)  // Fails silently or crashes
        
        // GOOD: Always await init
        const renderer = new WebGPURenderer()
        await renderer.init()
        renderer.render(scene, camera)
        ```
        
        ### 2. Importing from 'three' Instead of 'three/webgpu'
        
        ```typescript
        // BAD: Missing WebGPU-specific exports
        import * as THREE from 'three'
        
        // GOOD: Full WebGPU namespace (r171+)
        import * as THREE from 'three/webgpu'
        import { color, time } from 'three/tsl'
        ```
        
        ### 3. TSL Variable Reassignment (Silent Data Corruption)
        
        See the detailed explanation in the TSL section above. This is the single most common
        source of silent bugs. Use `.toVar()` + `.assign()`, never `let x = ...; x = x.add(1)`.
        
        ### 4. Using GLSL ShaderMaterial with WebGPU
        
        ```typescript
        // BAD: GLSL doesn't work with WebGPU renderer
        const material = new THREE.ShaderMaterial({
          vertexShader: '...',  // GLSL — not compatible
          fragmentShader: '...',
        })
        
        // GOOD: Use TSL node materials
        const material = new MeshStandardNodeMaterial()
        material.colorNode = /* TSL expression */
        ```
        
        ### 5. CPU-Side Particle Updates at Scale
        
        ```typescript
        // BAD: Updating 10k particles on CPU every frame
        for (let i = 0; i < 10000; i++) {
          particles[i].position.x += Math.sin(time + i) * 0.01
        }
        instancedMesh.instanceMatrix.needsUpdate = true  // Full CPU→GPU upload
        
        // GOOD: Use compute shader
        renderer.compute(particleComputeFunction)  // GPU-only, no CPU→GPU transfer
        ```
        
        ---
        
        ## Error Handling
        
        ### Error: "navigator.gpu is undefined"
        Cause: Browser doesn't support WebGPU, or not using HTTPS
        Solution: Add fallback to WebGL renderer. WebGPU requires HTTPS in production.
        
        ### Error: "WebGPU: Adapter request failed"
        Cause: GPU hardware or driver not supported
        Solution: Check `navigator.gpu.requestAdapter()` returns non-null before proceeding.
        
        ### Error: TSL node type mismatch
        Cause: Mixing incompatible node types (e.g., `float` + `vec3`)
        Solution: TSL is strictly typed. Ensure operations match: `vec3 * float` works, `vec3 + float` does not — use `vec3.add(float(x))`.
        
        ### Error: Compute shader produces no visible result
        Cause: Storage buffer not connected to rendering, or compute dispatch count is wrong
        Solution: Verify the storage buffer is used as a geometry attribute AND the compute dispatch matches buffer element count.
        
  • scripts-distinctive-frontend-design
    • tests
      • test_css_slop_rules.py 5.2 KB
        """Golden-fixture tests for css_slop_rules.scan_css.
        
        Each rule has one positive fixture (rule fires) and one negative (rule silent).
        The contrast canary covers both the hex and the oklch color paths.
        """
        
        from __future__ import annotations
        
        import sys
        import time
        from importlib import import_module
        from pathlib import Path
        
        sys.path.insert(0, str(Path(__file__).parent.parent))
        slop = import_module("css_slop_rules")
        scan_css = slop.scan_css
        
        
        def ids(css: str) -> set[str]:
            """Return the set of rule_ids fired for a CSS string."""
            return {f.rule_id for f in scan_css(css)}
        
        
        # --- transition-all ---
        
        
        def test_transition_all_fires() -> None:
            css = ".btn { transition: all 0.2s ease; }"
            assert "transition-all" in ids(css)
        
        
        def test_transition_all_silent_on_named_property() -> None:
            css = ".btn { transition: opacity 0.2s ease, transform 0.2s ease; }"
            assert "transition-all" not in ids(css)
        
        
        # --- universal-hover-scale ---
        
        
        def test_universal_hover_scale_fires() -> None:
            css = ".card:hover { transform: scale(1.05); }"
            assert "universal-hover-scale" in ids(css)
        
        
        def test_universal_hover_scale_silent_when_hover_does_more() -> None:
            css = ".card:hover { transform: scale(1.05); box-shadow: 0 4px 12px #0003; }"
            assert "universal-hover-scale" not in ids(css)
        
        
        # --- gradient-text-headline ---
        
        
        def test_gradient_text_headline_fires_webkit() -> None:
            css = "h1 { -webkit-background-clip: text; color: transparent; }"
            assert "gradient-text-headline" in ids(css)
        
        
        def test_gradient_text_headline_fires_standard() -> None:
            css = "h2 { background-clip: text; }"
            assert "gradient-text-headline" in ids(css)
        
        
        def test_gradient_text_headline_silent_on_body() -> None:
            css = ".badge { -webkit-background-clip: text; }"
            assert "gradient-text-headline" not in ids(css)
        
        
        # --- focus-ring-fade ---
        
        
        def test_focus_ring_fade_fires() -> None:
            css = ".input:focus { outline: 2px solid #09f; transition: outline 0.3s ease; }"
            assert "focus-ring-fade" in ids(css)
        
        
        def test_focus_ring_fade_silent_when_instant() -> None:
            css = ".input:focus { outline: 2px solid #09f; }"
            assert "focus-ring-fade" not in ids(css)
        
        
        # --- emoji-feature-icon ---
        
        
        def test_emoji_feature_icon_fires() -> None:
            css = '.feature li::before { content: "\U0001f680"; }'
            assert "emoji-feature-icon" in ids(css)
        
        
        def test_emoji_feature_icon_silent_on_text_content() -> None:
            css = '.tag::before { content: "New"; }'
            assert "emoji-feature-icon" not in ids(css)
        
        
        # --- two-line-cta ---
        
        
        def test_two_line_cta_fires() -> None:
            html = '<button class="cta">Start your free<br>trial today</button>'
            assert "two-line-cta" in ids(html)
        
        
        def test_two_line_cta_silent_on_single_line() -> None:
            html = '<button class="cta">Get started</button>'
            assert "two-line-cta" not in ids(html)
        
        
        # --- contrast-canary (hex path) ---
        
        
        def test_contrast_canary_fires_hex() -> None:
            # dark-gray text on a marginally different dark-gray surface (black-on-black slop)
            css = ".x { color: #1a1a1a; background-color: #1e1e1e; }"
            assert "contrast-canary" in ids(css)
        
        
        def test_contrast_canary_silent_hex_high_contrast() -> None:
            css = ".x { color: #ffffff; background-color: #111111; }"
            assert "contrast-canary" not in ids(css)
        
        
        # --- contrast-canary (oklch path) ---
        
        
        def test_contrast_canary_fires_oklch() -> None:
            css = ".y { color: oklch(0.20 0.02 250); background-color: oklch(0.22 0.03 250); }"
            assert "contrast-canary" in ids(css)
        
        
        def test_contrast_canary_silent_oklch_high_contrast() -> None:
            css = ".y { color: oklch(0.95 0.02 250); background-color: oklch(0.20 0.03 250); }"
            assert "contrast-canary" not in ids(css)
        
        
        # --- Finding shape contract ---
        
        
        def test_finding_has_required_fields() -> None:
            findings = scan_css(".btn { transition: all 0.2s; }")
            assert findings
            f = findings[0]
            assert f.rule_id
            assert f.severity == "warning"
            assert isinstance(f.message, str) and f.message
            assert isinstance(f.line, int) and f.line >= 1
        
        
        def test_clean_css_silent() -> None:
            css = ".btn { transition: opacity 0.2s ease; color: #1a1a1a; background: #fafafa; }"
            assert scan_css(css) == []
        
        
        # --- ReDoS regression: large single-line input must scan fast ---
        
        
        def test_scan_css_fast_on_large_single_line() -> None:
            """A ~1MB single-line artifact must scan in well under a second.
        
            Guards the block-iterating scanners against the O(n^2) backtracking that
            hung scan_css on minified single-line input (the "[^{}]*" pre-brace run).
            """
            unit = '<div class="btn cta" style="color:#111;background:#222">text</div>'
            big = "<html><body>" + unit * 16000 + "</body></html>"
            assert len(big) >= 1_000_000
            start = time.perf_counter()
            scan_css(big)
            assert time.perf_counter() - start < 1.0
        
        
        def test_scan_css_correct_after_large_block_fix() -> None:
            """Fix must not change findings on normal nested CSS (at-rule + blocks)."""
            css = (
                "h1 { -webkit-background-clip: text; } "
                ".card:hover { transform: scale(1.0); } "
                "@media (min-width: 600px) { .x { color: #1a1a1a; background-color: #1e1e1e; } }"
            )
            assert ids(css) == {"gradient-text-headline", "universal-hover-scale", "contrast-canary"}
        
      • test_validate_design_macro.py 4.3 KB
        """Tests for the macrostructure + emitted-CSS additions to validate_design.py.
        
        Covers: variety step-down on repeated macrostructure, back-compat with old
        history entries (no macrostructure field), history write of the new field,
        stamp parsing, and the emitted-CSS slop scan helper.
        """
        
        from __future__ import annotations
        
        import json
        import sys
        from importlib import import_module
        from pathlib import Path
        
        sys.path.insert(0, str(Path(__file__).parent.parent))
        vd = import_module("validate_design")
        
        
        def _patch_history(monkeypatch, tmp_path, projects):
            """Point validate_design's history lookups at a real temp file.
        
            skill_dir = Path(__file__).parent.parent, so faking __file__ to
            tmp/scripts/validate_design.py makes the history land at tmp/references/.
            """
            (tmp_path / "references").mkdir(exist_ok=True)
            hist = tmp_path / "references" / "project-history.json"
            hist.write_text(json.dumps({"projects": projects}), encoding="utf-8")
            fake_script = tmp_path / "scripts" / "validate_design.py"
            fake_script.parent.mkdir(parents=True, exist_ok=True)
            monkeypatch.setattr(vd, "__file__", str(fake_script))
            return hist
        
        
        # --- variety penalty on repeated macrostructure ---
        
        
        def test_macro_repeat_penalizes(monkeypatch, tmp_path):
            _patch_history(
                monkeypatch,
                tmp_path,
                [{"name": "prev", "fonts": ["A", "B"], "palette_name": "X", "macrostructure": "macro:bento"}],
            )
            score, details = vd.calculate_variety_score("new", ["C", "D"], "Y", "macro:bento")
            assert score == 70
            assert "macro:bento" in details
        
        
        def test_macro_distinct_no_penalty(monkeypatch, tmp_path):
            _patch_history(
                monkeypatch,
                tmp_path,
                [{"name": "prev", "fonts": ["A", "B"], "palette_name": "X", "macrostructure": "macro:bento"}],
            )
            score, _ = vd.calculate_variety_score("new", ["C", "D"], "Y", "macro:timeline")
            assert score == 90
        
        
        # --- back-compat: old entries lack the macrostructure field ---
        
        
        def test_old_entry_without_macro_loads(monkeypatch, tmp_path):
            _patch_history(
                monkeypatch,
                tmp_path,
                [{"name": "prev", "fonts": ["A", "B"], "palette_name": "X", "timestamp": "2025-01-01"}],
            )
            # No KeyError; a new macro is simply not a repeat.
            score, _ = vd.calculate_variety_score("new", ["C", "D"], "Y", "macro:bento")
            assert score == 90
        
        
        def test_empty_macro_never_penalizes(monkeypatch, tmp_path):
            _patch_history(
                monkeypatch,
                tmp_path,
                [{"name": "prev", "fonts": ["A", "B"], "palette_name": "X", "macrostructure": "macro:bento"}],
            )
            score, _ = vd.calculate_variety_score("new", ["C", "D"], "Y", "")
            assert score == 90
        
        
        # --- history write includes the field ---
        
        
        def test_update_history_writes_macro(tmp_path, monkeypatch):
            # skill_dir = Path(__file__).parent.parent, so faking __file__ at tmp/scripts/x.py
            # makes skill_dir == tmp and the history file land in tmp/references/.
            (tmp_path / "references").mkdir()
            hist = tmp_path / "references" / "project-history.json"
            fake_script = tmp_path / "scripts" / "validate_design.py"
            fake_script.parent.mkdir(parents=True)
        
            orig_file = vd.__file__
            try:
                monkeypatch.setattr(vd, "__file__", str(fake_script))
                vd.update_project_history("proj", ["F1", "F2"], "Pal", "macro:gallery-grid")
                rec = json.loads(hist.read_text(encoding="utf-8"))["projects"][-1]
                assert rec["macrostructure"] == "macro:gallery-grid"
                assert rec["name"] == "proj"
                assert "timestamp" in rec
            finally:
                vd.__file__ = orig_file
        
        
        # --- stamp parsing ---
        
        
        def test_read_macro_from_stamp():
            css = "/* vexjoy-design: macro=macro:split-hero theme=Dusk contrast=pass nav=top footer=slim mobile=pass */\nbody{}"
            assert vd.read_macro_from_stamp(css) == "macro:split-hero"
        
        
        def test_read_macro_from_stamp_absent():
            assert vd.read_macro_from_stamp("body { color: #111; }") == ""
        
        
        # --- emitted-css slop scan helper ---
        
        
        def test_scan_emitted_css(tmp_path):
            f = tmp_path / "out.css"
            f.write_text(".btn { transition: all 0.2s; }", encoding="utf-8")
            findings = vd.scan_emitted_css(f)
            assert any(x["rule_id"] == "transition-all" for x in findings)
        
        
        def test_scan_emitted_css_clean(tmp_path):
            f = tmp_path / "out.css"
            f.write_text(".btn { transition: opacity 0.2s; }", encoding="utf-8")
            assert vd.scan_emitted_css(f) == []
        
    • css_slop_rules.py 12.6 KB
      #!/usr/bin/env python3
      """Canonical rendered-CSS slop rules. Self-contained, dependency-free.
      
      Public surface:
          scan_css(css_text: str) -> list[Finding]
          Finding(rule_id, severity, message, line)
      
      Deterministic regex/parse only — no LLM. Designed to be vendored as-is into
      other skills (e.g. html-artifact). Findings start at "warning" severity; the
      promote-to-error path is documented per rule below and gated by the caller.
      
      Rules:
          transition-all          `transition: all` (shorthand spanning all properties)
          universal-hover-scale   :hover whose only effect is a broad transform: scale()
          gradient-text-headline  background-clip:text / -webkit-background-clip:text on h1/h2
          focus-ring-fade         focus outline/ring animated via transition (fades in)
          emoji-feature-icon      emoji codepoint as a feature/list icon (CSS content or markup)
          two-line-cta            clickable/button text that wraps to two lines (heuristic)
          contrast-canary         adjacent fg/bg within delta-L <= 0.05 AND delta-chroma <= 0.05
      """
      
      from __future__ import annotations
      
      import math
      import re
      from dataclasses import dataclass
      
      
      @dataclass(frozen=True)
      class Finding:
          """One slop-rule hit.
      
          rule_id:  stable identifier (see module docstring table).
          severity: "warning" for every rule today; callers promote to "error".
          message:  human-readable explanation and fix direction.
          line:     1-based best-effort source line.
          """
      
          rule_id: str
          severity: str
          message: str
          line: int
      
      
      # Contrast-canary thresholds (oklch space): treat as too-low-contrast when BOTH hold.
      _CONTRAST_DELTA_L = 0.05  # lightness L is 0..1; <=5% apart
      _CONTRAST_DELTA_C = 0.05  # chroma
      
      _EMOJI_RE = re.compile(
          "["
          "\U0001f300-\U0001faff"  # symbols, pictographs, emoji
          "\U00002600-\U000027bf"  # misc symbols + dingbats
          "\U0001f000-\U0001f0ff"  # mahjong/dominoes/cards
          "\U00002190-\U000021ff"  # arrows (often used as icons)
          "\U00002b00-\U00002bff"  # misc symbols and arrows (stars, checks)
          "\U0000fe0f"  # variation selector-16
          "]"
      )
      
      
      def _line_of(text: str, index: int) -> int:
          """Return the 1-based line number for a character offset."""
          return text.count("\n", 0, index) + 1
      
      
      # --- color parsing (hex + oklch) → (L, chroma) in oklch space ---
      
      
      def _parse_hex(token: str) -> tuple[float, float, float] | None:
          """Parse #rgb / #rrggbb (alpha ignored) → sRGB 0..1 triple."""
          h = token.lstrip("#")
          if len(h) in (3, 4):
              h = "".join(c * 2 for c in h[:3])
          elif len(h) in (6, 8):
              h = h[:6]
          else:
              return None
          try:
              r = int(h[0:2], 16) / 255.0
              g = int(h[2:4], 16) / 255.0
              b = int(h[4:6], 16) / 255.0
          except ValueError:
              return None
          return (r, g, b)
      
      
      def _srgb_to_linear(c: float) -> float:
          return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
      
      
      def _srgb_to_oklch_lc(rgb: tuple[float, float, float]) -> tuple[float, float]:
          """sRGB triple → (L, chroma) in OKLCH. Hue is dropped (not needed here)."""
          r, g, b = (_srgb_to_linear(c) for c in rgb)
          lm = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
          mm = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
          sm = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
          l_ = lm ** (1 / 3)
          m_ = mm ** (1 / 3)
          s_ = sm ** (1 / 3)
          big_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
          a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
          b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
          chroma = math.hypot(a, b2)
          return (big_l, chroma)
      
      
      _OKLCH_RE = re.compile(r"oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+", re.IGNORECASE)
      
      
      def _parse_oklch(token: str) -> tuple[float, float] | None:
          """Parse oklch(L C H ...) → (L, chroma). L may be a percentage."""
          m = _OKLCH_RE.search(token)
          if not m:
              return None
          raw_l, raw_c = m.group(1), m.group(2)
          try:
              big_l = float(raw_l[:-1]) / 100.0 if raw_l.endswith("%") else float(raw_l)
              chroma = float(raw_c)
          except ValueError:
              return None
          return (big_l, chroma)
      
      
      def _color_to_lc(value: str) -> tuple[float, float] | None:
          """Resolve a single CSS color value to (L, chroma) in oklch space."""
          value = value.strip()
          if value.lower().startswith("oklch("):
              return _parse_oklch(value)
          hexmatch = re.search(r"#[0-9a-fA-F]{3,8}\b", value)
          if hexmatch:
              rgb = _parse_hex(hexmatch.group(0))
              if rgb is not None:
                  return _srgb_to_oklch_lc(rgb)
          return None
      
      
      # --- rule scanners ---
      
      # Anchored on the literal "{" so finditer scans directly to each brace instead
      # of trying "[^{}]*" from every offset. The old shape r"([^{}]*)\{([^{}]*)\}"
      # was O(n^2) on large brace-free input (e.g. a ~500KB minified single line): the
      # pre-brace run succeeded to end-of-string, the "\{" failed, and the engine
      # retried from the next start position. This form is linear. The selector (the
      # pre-brace text) is recovered by slicing from the previous block's close brace.
      _BLOCK_RE = re.compile(r"\{([^{}]*)\}", re.DOTALL)
      
      
      def _iter_blocks(css: str):
          """Yield (selector, body, body_offset) for each top-level rule block.
      
          Behavior matches the prior "([^{}]*)\\{([^{}]*)\\}" form: the selector is the
          run of non-brace text immediately preceding the block.
          """
          last_close = 0
          for m in _BLOCK_RE.finditer(css):
              gap = css[last_close : m.start()]
              cut = max(gap.rfind("{"), gap.rfind("}"))
              selector = (gap[cut + 1 :] if cut != -1 else gap).strip()
              yield selector, m.group(1), m.start(1)
              last_close = m.end()
      
      
      def _decls(body: str) -> list[tuple[str, str]]:
          """Split a declaration block into (property, value) pairs (lowercased prop)."""
          out: list[tuple[str, str]] = []
          for chunk in body.split(";"):
              if ":" in chunk:
                  prop, _, val = chunk.partition(":")
                  out.append((prop.strip().lower(), val.strip()))
          return out
      
      
      def _scan_transition_all(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for m in re.finditer(r"transition(?:-property)?\s*:\s*([^;}]*)", css, re.IGNORECASE):
              if re.match(r"all\b", m.group(1).strip(), re.IGNORECASE):
                  findings.append(
                      Finding(
                          "transition-all",
                          "warning",
                          "transition: all animates every property; name the properties you "
                          "actually change (e.g. transition: opacity, transform).",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_universal_hover_scale(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              if ":hover" not in selector.lower():
                  continue
              decls = _decls(body)
              effective = [(p, v) for p, v in decls if p and v]
              if len(effective) != 1:
                  continue
              prop, val = effective[0]
              if prop == "transform" and re.match(r"scale\(\s*1(\.0\d*)?\s*\)", val, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "universal-hover-scale",
                          "warning",
                          "the only hover effect is a small transform: scale(); a uniform "
                          "scale-on-hover reads as templated. Give hover a purposeful change.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_gradient_text_headline(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              sel = selector.lower()
              if not re.search(r"\bh[12]\b", sel):
                  continue
              if re.search(r"(?:-webkit-)?background-clip\s*:\s*text", body, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "gradient-text-headline",
                          "warning",
                          "gradient-clipped headline text (background-clip: text on h1/h2) is a "
                          "signature template look; use a solid headline color.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_focus_ring_fade(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              if ":focus" not in selector.lower():
                  continue
              decls = _decls(body)
              has_ring = any(p in ("outline", "box-shadow", "border", "outline-color") for p, _ in decls)
              trans = next((v for p, v in decls if p in ("transition", "transition-property")), "")
              if has_ring and re.search(r"\b(all|outline|box-shadow|border)\b", trans, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "focus-ring-fade",
                          "warning",
                          "focus ring is animated via transition; focus indicators must appear "
                          "instantly for accessibility. Remove the transition on the ring.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_emoji_feature_icon(css: str) -> list[Finding]:
          findings: list[Finding] = []
          # CSS: content property carrying an emoji (pseudo-element icon).
          for m in re.finditer(r"content\s*:\s*([\"'])(.*?)\1", css, re.IGNORECASE | re.DOTALL):
              if _EMOJI_RE.search(m.group(2)):
                  findings.append(
                      Finding(
                          "emoji-feature-icon",
                          "warning",
                          "emoji used as a feature/list icon (CSS content); use an inline SVG "
                          "icon set for a deliberate look.",
                          _line_of(css, m.start()),
                      )
                  )
          # Markup hook: emoji directly inside a list item / feature element.
          for m in re.finditer(r"<li\b[^>]*>(.*?)</li>", css, re.IGNORECASE | re.DOTALL):
              if _EMOJI_RE.search(m.group(1)):
                  findings.append(
                      Finding(
                          "emoji-feature-icon",
                          "warning",
                          "emoji used as a feature/list icon in markup; use an inline SVG icon set.",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_two_line_cta(css: str) -> list[Finding]:
          findings: list[Finding] = []
          # Heuristic: a button/CTA-classed clickable whose text contains a forced break.
          pattern = re.compile(
              r"<(?:button|a)\b[^>]*(?:class\s*=\s*\"[^\"]*(?:btn|button|cta)[^\"]*\"|role\s*=\s*\"button\")[^>]*>(.*?)</(?:button|a)>",
              re.IGNORECASE | re.DOTALL,
          )
          for m in pattern.finditer(css):
              inner = m.group(1)
              if re.search(r"<br\s*/?>", inner, re.IGNORECASE) or "\n" in inner.strip():
                  findings.append(
                      Finding(
                          "two-line-cta",
                          "warning",
                          "call-to-action text wraps to two lines; keep CTA labels to one short "
                          "line so the action stays scannable.",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_contrast_canary(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for _selector, body, offset in _iter_blocks(css):
              fg = bg = None
              for prop, val in _decls(body):
                  if prop == "color":
                      fg = _color_to_lc(val)
                  elif prop in ("background-color", "background"):
                      cand = _color_to_lc(val)
                      if cand is not None:
                          bg = cand
              if fg is None or bg is None:
                  continue
              if abs(fg[0] - bg[0]) <= _CONTRAST_DELTA_L and abs(fg[1] - bg[1]) <= _CONTRAST_DELTA_C:
                  findings.append(
                      Finding(
                          "contrast-canary",
                          "warning",
                          "foreground and background are nearly identical "
                          f"(delta-L<={_CONTRAST_DELTA_L}, delta-chroma<={_CONTRAST_DELTA_C}); text "
                          "will be unreadable. Widen the lightness gap.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      _SCANNERS = (
          _scan_transition_all,
          _scan_universal_hover_scale,
          _scan_gradient_text_headline,
          _scan_focus_ring_fade,
          _scan_emoji_feature_icon,
          _scan_two_line_cta,
          _scan_contrast_canary,
      )
      
      
      def scan_css(css_text: str) -> list[Finding]:
          """Scan CSS (or HTML containing CSS/markup) for slop patterns.
      
          Returns findings sorted by line, then rule_id. Deterministic.
          """
          findings: list[Finding] = []
          for scanner in _SCANNERS:
              findings.extend(scanner(css_text))
          findings.sort(key=lambda f: (f.line, f.rule_id))
          return findings
      
    • validate_design.py 21.9 KB
      #!/usr/bin/env python3
      """
      Comprehensive design validation script.
      Checks for anti-patterns, clichés, and scores overall distinctiveness.
      """
      
      import argparse
      import json
      import sys
      from pathlib import Path
      from typing import Any, Dict, List, Tuple
      
      
      class DesignValidationError(Exception):
          """Custom exception for validation errors."""
      
          pass
      
      
      def load_json_file(file_path: Path) -> Dict[str, Any]:
          """Load JSON file with error handling."""
          if not file_path.exists():
              raise DesignValidationError(f"File not found: {file_path}")
      
          try:
              with open(file_path, "r", encoding="utf-8") as f:
                  return json.load(f)
          except json.JSONDecodeError as e:
              raise DesignValidationError(f"Invalid JSON in {file_path}: {e}")
      
      
      def load_anti_patterns() -> Dict[str, Any]:
          """Load anti-patterns database."""
          skill_dir = Path(__file__).parent.parent
          anti_patterns_path = skill_dir / "references" / "preferred-patterns.json"
      
          if not anti_patterns_path.exists():
              # Return default anti-patterns if file doesn't exist
              return {
                  "banned_fonts": [
                      "Inter",
                      "Roboto",
                      "Arial",
                      "Helvetica",
                      "System",
                      "-apple-system",
                      "BlinkMacSystemFont",
                      "Segoe UI",
                      "Space Grotesk",
                      "sans-serif",
                  ],
                  "cliche_colors": [
                      {"name": "Purple gradient on white", "colors": ["#8B5CF6", "#A855F7", "#667eea", "#764ba2"]},
                      {"name": "Generic blue", "colors": ["#3B82F6", "#2563EB"]},
                      {"name": "Pure black/white", "colors": ["#000000", "#FFFFFF"]},
                  ],
              }
      
          return load_json_file(anti_patterns_path)
      
      
      def validate_fonts(fonts: List[str], project_name: str) -> Tuple[int, bool, str, List[str]]:
          """
          Validate font selections against banned list and project history.
      
          Returns: (score, passed, details, warnings)
          """
          anti_patterns = load_anti_patterns()
          banned_fonts = [f.lower() for f in anti_patterns.get("banned_fonts", [])]
      
          warnings = []
      
          # Check for banned fonts
          for font in fonts:
              font_lower = font.lower().strip()
              for banned in banned_fonts:
                  if banned in font_lower:
                      return (
                          0,
                          False,
                          f"❌ BANNED FONT DETECTED: '{font}' contains '{banned}'. This font is overused and creates generic aesthetics.",
                          [],
                      )
      
          # Check project history for repetition
          skill_dir = Path(__file__).parent.parent
          history_path = skill_dir / "references" / "project-history.json"
      
          if history_path.exists():
              history = load_json_file(history_path)
              projects = history.get("projects", [])
      
              # Look for same font pairing in recent projects
              font_pair = ",".join(sorted(fonts))
              recent_projects = [p for p in projects if p.get("name") != project_name][-5:]  # Last 5 projects
      
              for project in recent_projects:
                  project_fonts = project.get("fonts", [])
                  project_pair = ",".join(sorted(project_fonts))
      
                  if font_pair == project_pair:
                      warnings.append(
                          f"⚠️  Font pairing '{font_pair}' was used in recent project '{project['name']}'. "
                          f"Consider selecting different fonts for variety."
                      )
                      return (70, True, f"Font pairing is valid but lacks variety. Used in '{project['name']}'.", warnings)
      
          # All checks passed
          score = 95
          details = f"✅ Excellent font selection. {', '.join(fonts)} pairing is distinctive and unused in recent projects."
      
          return (score, True, details, warnings)
      
      
      def validate_palette(palette_path: Path) -> Tuple[int, bool, str, List[str]]:
          """
          Validate color palette for clichés and proper dominance.
      
          Returns: (score, passed, details, warnings)
          """
          palette = load_json_file(palette_path)
          anti_patterns = load_anti_patterns()
          cliche_colors = anti_patterns.get("cliche_colors", [])
      
          warnings = []
      
          # Extract all colors from palette
          all_colors = []
          for category in ["dominant", "secondary", "accent", "functional"]:
              if category in palette:
                  cat_colors = palette[category]
                  if isinstance(cat_colors, dict):
                      all_colors.extend([v for v in cat_colors.values() if isinstance(v, str) and v.startswith("#")])
      
          # Check for cliché color combinations
          for cliche in cliche_colors:
              cliche_set = set(c.upper() for c in cliche["colors"])
              palette_set = set(c.upper() for c in all_colors)
      
              # If 50% or more of cliché colors are present
              overlap = len(cliche_set & palette_set)
              if overlap >= len(cliche_set) * 0.5:
                  return (
                      0,
                      False,
                      f"❌ CLICHÉ DETECTED: '{cliche['name']}' color scheme. "
                      f"Found {overlap}/{len(cliche_set)} cliché colors in palette.",
                      [],
                  )
      
          # Check for color dominance structure
          has_dominant = "dominant" in palette and palette["dominant"]
          has_secondary = "secondary" in palette and palette["secondary"]
          has_accent = "accent" in palette and palette["accent"]
      
          if not (has_dominant and has_secondary and has_accent):
              warnings.append("⚠️  Palette should have clear dominant/secondary/accent structure (60/30/10 rule).")
              return (60, True, "Palette is valid but lacks clear hierarchical structure.", warnings)
      
          # Check for pure black/white as dominant
          dominant_colors = palette.get("dominant", {})
          if isinstance(dominant_colors, dict):
              for color in dominant_colors.values():
                  if isinstance(color, str):
                      if color.upper() in ["#000000", "#FFFFFF"]:
                          warnings.append(
                              f"⚠️  Dominant color {color} is pure black/white. "
                              f"Consider using off-black/off-white for more sophisticated aesthetic."
                          )
      
          # Check for inspiration/context
          has_inspiration = "inspiration" in palette and palette["inspiration"]
          has_rationale = any("rationale" in palette.get(cat, {}) for cat in ["dominant", "secondary", "accent"])
      
          score = 85
          if has_inspiration and has_rationale:
              score = 90
              details = (
                  f"✅ Strong palette with clear dominance and contextual inspiration. "
                  f"'{palette.get('palette_name', 'Unnamed')}' theme avoids clichés."
              )
          else:
              details = (
                  "✅ Valid palette structure, but consider adding inspiration source and rationale for each color group."
              )
      
          # Check accent colors for text accessibility
          accent_colors = palette.get("accent", {})
          if isinstance(accent_colors, dict):
              if "primary" in accent_colors and "primary_dark" not in accent_colors:
                  warnings.append("⚠️  Consider adding darker accent variant for text on light backgrounds (accessibility).")
      
          return (score, True, details, warnings)
      
      
      def calculate_variety_score(
          project_name: str, fonts: List[str], palette_name: str, macrostructure: str = ""
      ) -> Tuple[int, str]:
          """
          Calculate variety score based on project history.
      
          Penalizes reuse of fonts, palette theme, or page macrostructure versus recent
          projects. Macrostructure mirrors the font/palette step-down: a match against the
          last 1-2 projects drops the score to the same 70 threshold, because structural
          sameness reads as templated just as font/palette sameness does.
      
          Returns: (score, details)
          """
          skill_dir = Path(__file__).parent.parent
          history_path = skill_dir / "references" / "project-history.json"
      
          if not history_path.exists():
              return (100, "✅ First project tracked. Excellent start!")
      
          history = load_json_file(history_path)
          projects = history.get("projects", [])
      
          if len(projects) == 0:
              return (100, "✅ First project tracked. Excellent start!")
      
          # Compare with recent projects (last 3)
          recent_projects = [p for p in projects if p.get("name") != project_name][-3:]
      
          font_pair = ",".join(sorted(fonts))
          similar_fonts = 0
          similar_palette = 0
      
          for project in recent_projects:
              project_fonts = ",".join(sorted(project.get("fonts", [])))
              if font_pair == project_fonts:
                  similar_fonts += 1
      
              project_palette = project.get("palette_name", "")
              if palette_name and project_palette and palette_name.lower() in project_palette.lower():
                  similar_palette += 1
      
          # Macrostructure repetition: penalize a match against the last 1-2 projects.
          # Back-compat: old entries lack the field; .get("macrostructure", "") yields "".
          similar_macro = 0
          if macrostructure:
              for project in recent_projects[-2:]:
                  if project.get("macrostructure", "") == macrostructure:
                      similar_macro += 1
      
          if similar_fonts > 0:
              return (70, f"⚠️  Font pairing matches {similar_fonts} recent project(s). Aim for more variety.")
      
          if similar_macro > 0:
              return (
                  70,
                  f"⚠️  Macrostructure '{macrostructure}' matches {similar_macro} recent project(s). "
                  f"Pick a different macro:* page structure for variety.",
              )
      
          if similar_palette > 0:
              return (80, f"⚠️  Similar palette theme used in {similar_palette} recent project(s).")
      
          score = 90
          details = f"✅ Aesthetic is distinct across the {len(recent_projects)} most recent project(s)."
      
          return (score, details)
      
      
      def calculate_distinctiveness_score(
          palette: Dict, has_animation: bool, has_background: bool
      ) -> Tuple[int, str, List[str]]:
          """
          Calculate overall distinctiveness score.
      
          Returns: (score, details, suggestions)
          """
          suggestions = []
          score = 70  # Base score
      
          # Check for strong aesthetic commitment
          has_inspiration = "inspiration" in palette and palette["inspiration"]
          has_rationale = any("rationale" in palette.get(cat, {}) for cat in ["dominant", "secondary", "accent"])
      
          if has_inspiration and has_rationale:
              score += 10
          else:
              suggestions.append("Add clear inspiration source and rationale for design decisions")
      
          # Check for animation strategy
          if has_animation:
              score += 5
          else:
              suggestions.append("Define animation strategy for at least one high-impact moment")
      
          # Check for atmospheric background
          if has_background:
              score += 5
          else:
              suggestions.append("Create atmospheric background (layered gradients, patterns, textures)")
      
          # Check for unique palette name
          if palette.get("palette_name"):
              score += 5
      
          # Provide contextual feedback
          if score >= 90:
              details = "✅ Exceptional distinctiveness. Design has strong personality and clear direction."
          elif score >= 80:
              details = "✅ Good distinctiveness. Design avoids generic patterns with commitment to aesthetic."
          elif score >= 70:
              details = "⚠️  Adequate distinctiveness, but could be strengthened with more unique elements."
          else:
              details = "❌ Low distinctiveness. Design needs stronger commitment to unique aesthetic direction."
      
          return (score, details, suggestions)
      
      
      def run_validation(
          fonts: List[str],
          palette_path: Path,
          project_name: str,
          has_animation: bool = False,
          has_background: bool = False,
          macrostructure: str = "",
      ) -> Dict[str, Any]:
          """Run comprehensive validation and return results."""
      
          results = {"project_name": project_name, "overall_score": 0, "grade": "F", "checks": {}, "recommendations": []}
      
          # Typography validation
          font_score, font_passed, font_details, font_warnings = validate_fonts(fonts, project_name)
          results["checks"]["typography"] = {
              "score": font_score,
              "passed": font_passed,
              "details": font_details,
              "warnings": font_warnings,
          }
      
          # Palette validation
          palette = load_json_file(palette_path)
          palette_score, palette_passed, palette_details, palette_warnings = validate_palette(palette_path)
          results["checks"]["color_palette"] = {
              "score": palette_score,
              "passed": palette_passed,
              "details": palette_details,
              "warnings": palette_warnings,
          }
      
          # Variety check
          palette_name = palette.get("palette_name", "")
          variety_score, variety_details = calculate_variety_score(project_name, fonts, palette_name, macrostructure)
          results["checks"]["variety"] = {"score": variety_score, "passed": variety_score >= 70, "details": variety_details}
      
          # Distinctiveness check
          distinct_score, distinct_details, distinct_suggestions = calculate_distinctiveness_score(
              palette, has_animation, has_background
          )
          results["checks"]["distinctiveness"] = {
              "score": distinct_score,
              "passed": distinct_score >= 70,
              "details": distinct_details,
              "suggestions": distinct_suggestions,
          }
      
          # Anti-patterns check (always 100 if we got here, since banned items would fail earlier)
          if font_passed and palette_passed:
              results["checks"]["anti_patterns"] = {
                  "score": 100,
                  "passed": True,
                  "details": "✅ No anti-patterns detected. Design avoids all identified clichés.",
              }
      
          # Calculate overall score
          scores = [check["score"] for check in results["checks"].values() if "score" in check]
          results["overall_score"] = sum(scores) // len(scores) if scores else 0
      
          # Assign grade
          if results["overall_score"] >= 90:
              results["grade"] = "A"
          elif results["overall_score"] >= 80:
              results["grade"] = "B"
          elif results["overall_score"] >= 70:
              results["grade"] = "C"
          elif results["overall_score"] >= 60:
              results["grade"] = "D"
          else:
              results["grade"] = "F"
      
          # Collect all recommendations
          for check in results["checks"].values():
              if "warnings" in check:
                  results["recommendations"].extend(check["warnings"])
              if "suggestions" in check:
                  results["recommendations"].extend(check["suggestions"])
      
          return results
      
      
      def update_project_history(project_name: str, fonts: List[str], palette_name: str, macrostructure: str = ""):
          """Update project history with new project.
      
          Writes the macrostructure axis alongside fonts/palette so the next run's variety
          check can penalize structural repetition. Old entries lacking the field still load.
          """
          skill_dir = Path(__file__).parent.parent
          history_path = skill_dir / "references" / "project-history.json"
      
          # Load existing history
          if history_path.exists():
              history = load_json_file(history_path)
          else:
              history = {"projects": []}
      
          # Remove existing entry for this project if present
          history["projects"] = [p for p in history["projects"] if p.get("name") != project_name]
      
          # Add new entry
          from datetime import datetime
      
          history["projects"].append(
              {
                  "name": project_name,
                  "fonts": fonts,
                  "palette_name": palette_name,
                  "macrostructure": macrostructure,
                  "timestamp": datetime.now().isoformat(),
              }
          )
      
          # Save updated history
          history_path.parent.mkdir(parents=True, exist_ok=True)
          with open(history_path, "w", encoding="utf-8") as f:
              json.dump(history, f, indent=2)
      
      
      def print_validation_report(results: Dict[str, Any]):
          """Print formatted validation report."""
          print("\n" + "=" * 70)
          print("DESIGN VALIDATION REPORT")
          print("=" * 70)
          print(f"\nProject: {results['project_name']}")
          print(f"Overall Score: {results['overall_score']}/100")
          print(f"Grade: {results['grade']}")
          print("\n" + "-" * 70)
      
          for check_name, check_data in results["checks"].items():
              print(f"\n{check_name.upper().replace('_', ' ')}:")
              print(f"  Score: {check_data['score']}/100")
              print(f"  Status: {'✅ PASS' if check_data['passed'] else '❌ FAIL'}")
              print(f"  {check_data['details']}")
      
              if check_data.get("warnings"):
                  for warning in check_data["warnings"]:
                      print(f"  {warning}")
      
              if check_data.get("suggestions"):
                  for suggestion in check_data["suggestions"]:
                      print(f"  • {suggestion}")
      
          if results["recommendations"]:
              print("\n" + "-" * 70)
              print("RECOMMENDATIONS:")
              for i, rec in enumerate(results["recommendations"], 1):
                  print(f"  {i}. {rec}")
      
          print("\n" + "=" * 70)
      
          if results["overall_score"] >= 80:
              print("✅ VALIDATION PASSED - Design is ready for implementation")
          elif results["overall_score"] >= 70:
              print("⚠️  VALIDATION WARNING - Consider addressing recommendations before implementation")
          else:
              print("❌ VALIDATION FAILED - Address critical issues before proceeding")
      
          print("=" * 70 + "\n")
      
      
      def read_macro_from_stamp(css_text: str) -> str:
          """Recover the macro id from a vexjoy-design stamp comment, or "" if absent.
      
          The stamp is the first CSS comment in generated output:
              /* vexjoy-design: macro=<id> theme=<name> contrast=<pass|fail> ... */
          Never trusted blindly — the slop rules verify the rendered CSS independently.
          """
          import re
      
          m = re.search(r"vexjoy-design:.*?\bmacro=(\S+)", css_text)
          return m.group(1) if m else ""
      
      
      def scan_emitted_css(css_path: Path) -> List[Dict[str, Any]]:
          """Route emitted CSS/HTML through the slop rules. Returns finding dicts.
      
          Warnings do not fail the build yet. Promote-to-error path: a caller raises the
          exit code once a rule_id graduates from "warning" to "error" in css_slop_rules.
          """
          import sys as _sys
          from importlib import import_module
      
          _sys.path.insert(0, str(Path(__file__).parent))
          slop = import_module("css_slop_rules")
      
          text = css_path.read_text(encoding="utf-8")
          findings = slop.scan_css(text)
          return [{"rule_id": f.rule_id, "severity": f.severity, "message": f.message, "line": f.line} for f in findings]
      
      
      def main():
          """Main entry point."""
          parser = argparse.ArgumentParser(
              description="Validate frontend design for distinctiveness and anti-patterns",
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          parser.add_argument(
              "--fonts", required=True, help='Comma-separated list of font families (e.g., "Unbounded,Crimson Pro")'
          )
          parser.add_argument("--palette", type=Path, required=True, help="Path to palette JSON file")
          parser.add_argument("--project", required=True, help="Project name for tracking variety")
          parser.add_argument("--output", type=Path, help="Output path for validation report JSON (optional)")
          parser.add_argument("--animation", action="store_true", help="Flag indicating animation strategy is defined")
          parser.add_argument(
              "--background", action="store_true", help="Flag indicating atmospheric background is implemented"
          )
          parser.add_argument(
              "--macrostructure",
              default="",
              help="Chosen macro:* page structure id (e.g. macro:stat-led) for structural variety tracking",
          )
          parser.add_argument(
              "--emitted-css",
              type=Path,
              help="Path to generated CSS/HTML to scan for rendered-CSS slop (warnings, non-blocking)",
          )
      
          args = parser.parse_args()
      
          try:
              # Parse fonts
              fonts = [f.strip() for f in args.fonts.split(",")]
      
              # If a macro id was not passed but emitted CSS carries a stamp, recover it.
              macrostructure = args.macrostructure
              if not macrostructure and args.emitted_css and args.emitted_css.exists():
                  macrostructure = read_macro_from_stamp(args.emitted_css.read_text(encoding="utf-8"))
      
              # Run validation
              results = run_validation(
                  fonts=fonts,
                  palette_path=args.palette,
                  project_name=args.project,
                  has_animation=args.animation,
                  has_background=args.background,
                  macrostructure=macrostructure,
              )
      
              # Print report
              print_validation_report(results)
      
              # Scan emitted CSS for rendered-CSS slop (advisory warnings, do not fail build).
              if args.emitted_css:
                  if not args.emitted_css.exists():
                      raise DesignValidationError(f"Emitted CSS file not found: {args.emitted_css}")
                  slop_findings = scan_emitted_css(args.emitted_css)
                  results["css_slop"] = slop_findings
                  print("-" * 70)
                  print(f"RENDERED-CSS SLOP SCAN: {args.emitted_css}")
                  if slop_findings:
                      for f in slop_findings:
                          print(f"  ⚠️  [{f['rule_id']}] line {f['line']}: {f['message']}")
                      print("  (warnings are advisory; promote a rule to error to fail the build)")
                  else:
                      print("  ✅ No slop patterns detected.")
                  print("-" * 70 + "\n")
      
              # Save JSON output if requested
              if args.output:
                  args.output.parent.mkdir(parents=True, exist_ok=True)
                  with open(args.output, "w", encoding="utf-8") as f:
                      json.dump(results, f, indent=2)
                  print(f"Validation report saved to: {args.output}\n")
      
              # Update project history if validation passed
              if results["overall_score"] >= 70:
                  palette = load_json_file(args.palette)
                  palette_name = palette.get("palette_name", "")
                  update_project_history(args.project, fonts, palette_name, macrostructure)
      
              # Exit with appropriate code
              sys.exit(0 if results["overall_score"] >= 80 else 1)
      
          except DesignValidationError as e:
              print(
                  json.dumps({"status": "error", "error_type": "DesignValidationError", "message": str(e)}, indent=2),
                  file=sys.stderr,
              )
              sys.exit(2)
          except Exception as e:
              print(
                  json.dumps({"status": "error", "error_type": type(e).__name__, "message": str(e)}, indent=2),
                  file=sys.stderr,
              )
              sys.exit(3)
      
      
      if __name__ == "__main__":
          main()
      
  • scripts-html-artifact
    • pptx-bridge
      • extract_slides.py 13.5 KB
        #!/usr/bin/env python3
        """HTML deck -> slide-map JSON extractor.
        
        Parses an html-artifact deck-shape HTML file. Each `<section class="slide">`
        becomes one slide-map entry. Detects layout family from class hints and
        inner-element signatures.
        
        Output schema (extended from pptx-generator's slide-map):
        
            {
                "type": "title" | "content" | "metric_grid" | "layer_rows" |
                        "pipeline" | "code_block" | "compare_table_2col" |
                        "compare_table_3col" | "outcome_grid" | "split_narrow" |
                        "closing",
                "eyebrow": "...",
                "title":   "...",
                "subtitle": "...",
                "lead":    "...",
                "callout": "...",
                "bullets": [ {"text": "...", "bold_prefix": "Agents"} , ...],
                "metrics": [ {"value": "...", "label": "...", "desc": "..."}, ...],
                "layers":  [ {"name": "...", "count": "...", "desc": "..."}, ...],
                "pipeline_steps": ["ROUTE", "PLAN", ...],
                "code":    "$ claude\\n\\n> /do ...",
                "table":   {"headers": [...], "rows": [[...], ...]},
                "outcomes":[ {"heading": "...", "body": "..."}, ...],
                "split":   {"left": {...}, "right_card_rows": [{"name": "...", "trigger": "..."}]},
            }
        
        Usage:
            python3 extract_slides.py --input deck.html --output slides.json
        """
        
        from __future__ import annotations
        
        import argparse
        import json
        import re
        import sys
        from html.parser import HTMLParser
        from pathlib import Path
        from typing import ClassVar
        
        # ---------------------------------------------------------------------------
        # Stdlib HTML walker -> tree of dicts
        # ---------------------------------------------------------------------------
        
        
        class _DOMBuilder(HTMLParser):
            """Build a lightweight tree of {tag, attrs, children, text} dicts."""
        
            VOID: ClassVar[set[str]] = {"br", "hr", "img", "meta", "link", "input"}
        
            def __init__(self):
                super().__init__(convert_charrefs=True)
                self.root = {"tag": "_root", "attrs": {}, "children": []}
                self.stack = [self.root]
        
            def handle_starttag(self, tag, attrs):
                node = {"tag": tag, "attrs": dict(attrs), "children": []}
                self.stack[-1]["children"].append(node)
                if tag not in self.VOID:
                    self.stack.append(node)
        
            def handle_startendtag(self, tag, attrs):
                node = {"tag": tag, "attrs": dict(attrs), "children": []}
                self.stack[-1]["children"].append(node)
        
            def handle_endtag(self, tag):
                # Pop until we close the matching tag (tolerant to malformed input).
                for i in range(len(self.stack) - 1, 0, -1):
                    if self.stack[i]["tag"] == tag:
                        del self.stack[i:]
                        return
        
            def handle_data(self, data):
                if not data:
                    return
                # Attach as text-node child to current parent.
                self.stack[-1]["children"].append({"tag": "_text", "text": data})
        
        
        def _has_class(node: dict, cls: str) -> bool:
            classes = (node.get("attrs", {}).get("class") or "").split()
            return cls in classes
        
        
        def _classes(node: dict) -> list[str]:
            return (node.get("attrs", {}).get("class") or "").split()
        
        
        def _walk(node: dict):
            yield node
            for c in node.get("children", []):
                yield from _walk(c)
        
        
        def _find_all(node: dict, tag: str | None = None, cls: str | None = None) -> list[dict]:
            out = []
            for n in _walk(node):
                if n.get("tag") == "_text":
                    continue
                if tag is not None and n["tag"] != tag:
                    continue
                if cls is not None and not _has_class(n, cls):
                    continue
                out.append(n)
            return out
        
        
        def _find(node, tag=None, cls=None):
            res = _find_all(node, tag, cls)
            return res[0] if res else None
        
        
        def _text(node: dict) -> str:
            """Recursively concatenate visible text; collapse internal whitespace."""
            if node is None:
                return ""
            if node.get("tag") == "_text":
                return node.get("text", "")
            out = []
            for c in node.get("children", []):
                out.append(_text(c))
            s = "".join(out)
            # Normalize whitespace inside a single block.
            s = re.sub(r"[ \t\r\f\v]+", " ", s)
            return s.strip()
        
        
        def _raw_inner(node: dict) -> str:
            """Concatenate text including \n exactly (for code blocks)."""
            if node is None:
                return ""
            if node.get("tag") == "_text":
                return node.get("text", "")
            return "".join(_raw_inner(c) for c in node.get("children", []))
        
        
        # ---------------------------------------------------------------------------
        # Layout detection + extraction
        # ---------------------------------------------------------------------------
        
        
        def detect_layout(section: dict) -> str:
            classes = _classes(section)
            aria = (section.get("attrs", {}) or {}).get("aria-label", "").lower()
        
            if "slide-title" in classes and "active" in classes:
                return "title"
            # Closing slide is also slide-title without "active"
            if "slide-title" in classes:
                return "closing"
        
            # Structural detectors (order matters: most specific first).
            if _find(section, cls="metric-grid"):
                return "metric_grid"
            if _find(section, cls="layer-row"):
                return "layer_rows"
            if _find(section, cls="pipeline"):
                return "pipeline"
            if _find(section, cls="code-block"):
                return "code_block"
            if _find(section, cls="compare-table"):
                # 2-col vs 3-col
                thead = _find(section, tag="thead")
                if thead:
                    ths = _find_all(thead, tag="th")
                    return "compare_table_3col" if len(ths) >= 3 else "compare_table_2col"
                return "compare_table_2col"
            if _find(section, cls="outcome-grid"):
                return "outcome_grid"
            if _find(section, cls="split-narrow"):
                return "split_narrow"
        
            # Generic prose with bullets or callout.
            return "content"
        
        
        def _extract_eyebrow(section: dict) -> str:
            n = _find(section, cls="eyebrow") or _find(section, cls="eyebrow-h2")
            return _text(n) if n else ""
        
        
        def _extract_title(section: dict) -> str:
            h1 = _find(section, tag="h1")
            if h1:
                return _text(h1)
            h2 = _find(section, tag="h2")
            return _text(h2) if h2 else ""
        
        
        def _extract_subtitle(section: dict) -> str:
            n = _find(section, cls="subtitle")
            return _text(n) if n else ""
        
        
        def _extract_lead(section: dict) -> str:
            n = _find(section, cls="lead")
            return _text(n) if n else ""
        
        
        def _extract_callout(section: dict) -> str:
            n = _find(section, cls="callout")
            return _text(n) if n else ""
        
        
        def _extract_bullets(section: dict) -> list[dict]:
            ul = _find(section, tag="ul")
            if not ul:
                return []
            out = []
            for li in _find_all(ul, tag="li"):
                # Detect "<strong>X</strong> rest" pattern.
                strong = _find(li, tag="strong")
                if strong:
                    bold_prefix = _text(strong)
                    full = _text(li)
                    # Tail = everything after the strong text.
                    tail = full[len(bold_prefix) :].lstrip(" -—:")
                    out.append({"text": tail, "bold_prefix": bold_prefix})
                else:
                    out.append({"text": _text(li), "bold_prefix": ""})
            return out
        
        
        def _extract_metrics(section: dict) -> list[dict]:
            grid = _find(section, cls="metric-grid")
            if not grid:
                return []
            out = []
            for m in _find_all(grid, cls="metric"):
                out.append(
                    {
                        "value": _text(_find(m, cls="metric-value")),
                        "label": _text(_find(m, cls="metric-label")),
                        "desc": _text(_find(m, cls="metric-desc")),
                    }
                )
            return out
        
        
        def _extract_layers(section: dict) -> list[dict]:
            out = []
            for row in _find_all(section, cls="layer-row"):
                out.append(
                    {
                        "name": _text(_find(row, cls="layer-name")),
                        "count": _text(_find(row, cls="layer-count")),
                        "desc": _text(_find(row, cls="layer-desc")),
                    }
                )
            return out
        
        
        def _extract_pipeline(section: dict) -> list[dict]:
            out = []
            for step in _find_all(section, cls="pipeline-step"):
                out.append(
                    {
                        "label": _text(_find(step, cls="step-label")),
                        "name": _text(_find(step, cls="step-name")),
                    }
                )
            return out
        
        
        def _extract_pipeline_caption(section: dict) -> str:
            # The <p> that follows the .pipeline div.
            paragraphs = _find_all(section, tag="p")
            return _text(paragraphs[-1]) if paragraphs else ""
        
        
        def _extract_code(section: dict) -> str:
            code_node = _find(section, cls="code-block")
            if not code_node:
                return ""
            # Preserve newlines; flatten span tags but keep their text.
            return _raw_inner(code_node).strip("\n")
        
        
        def _extract_table(section: dict) -> dict:
            table = _find(section, cls="compare-table")
            if not table:
                return {"headers": [], "rows": []}
            headers = []
            thead = _find(table, tag="thead")
            if thead:
                headers = [_text(th) for th in _find_all(thead, tag="th")]
            rows = []
            tbody = _find(table, tag="tbody")
            if tbody:
                for tr in _find_all(tbody, tag="tr"):
                    cells = []
                    for td in _find_all(tr, tag="td"):
                        role = "label"
                        cls = _classes(td)
                        if "danger" in cls:
                            role = "danger"
                        elif "success" in cls:
                            role = "success"
                        cells.append({"text": _text(td), "role": role})
                    rows.append(cells)
            return {"headers": headers, "rows": rows}
        
        
        def _extract_outcomes(section: dict) -> list[dict]:
            grid = _find(section, cls="outcome-grid")
            if not grid:
                return []
            out = []
            for card in _find_all(grid, cls="outcome"):
                out.append(
                    {
                        "heading": _text(_find(card, tag="h3")),
                        "body": _text(_find(card, tag="p")),
                    }
                )
            return out
        
        
        def _extract_split(section: dict) -> dict:
            split = _find(section, cls="split-narrow")
            if not split:
                return {}
            # First child div is the left column (eyebrow, h2, p, callout).
            cols = [c for c in split.get("children", []) if c.get("tag") == "div"]
            left, right = cols[0] if cols else None, cols[1] if len(cols) > 1 else None
            rows = []
            if right:
                for r in _find_all(right, cls="cli-row"):
                    rows.append(
                        {
                            "name": _text(_find(r, cls="cli-name")),
                            "trigger": _text(_find(r, cls="cli-trigger")),
                        }
                    )
            return {
                "left": {
                    "eyebrow": _text(_find(left, cls="eyebrow-h2")) if left else "",
                    "title": _text(_find(left, tag="h2")) if left else "",
                    "lead": _text(_find(left, tag="p")) if left else "",
                    "callout": _text(_find(left, cls="callout")) if left else "",
                },
                "rows": rows,
            }
        
        
        # ---------------------------------------------------------------------------
        # Top-level pipeline
        # ---------------------------------------------------------------------------
        
        
        def extract_slides(html: str) -> list[dict]:
            parser = _DOMBuilder()
            parser.feed(html)
            sections = _find_all(parser.root, tag="section", cls="slide")
            out = []
            for section in sections:
                layout = detect_layout(section)
                slide: dict = {
                    "type": layout,
                    "eyebrow": _extract_eyebrow(section),
                    "title": _extract_title(section),
                    "subtitle": _extract_subtitle(section),
                    "lead": _extract_lead(section),
                    "callout": _extract_callout(section),
                }
                if layout == "content":
                    slide["bullets"] = _extract_bullets(section)
                elif layout == "metric_grid":
                    slide["metrics"] = _extract_metrics(section)
                elif layout == "layer_rows":
                    slide["layers"] = _extract_layers(section)
                elif layout == "pipeline":
                    slide["pipeline_steps"] = _extract_pipeline(section)
                    slide["pipeline_caption"] = _extract_pipeline_caption(section)
                elif layout == "code_block":
                    slide["code"] = _extract_code(section)
                elif layout in ("compare_table_2col", "compare_table_3col"):
                    slide["table"] = _extract_table(section)
                    # Optional p-tag intro.
                    ps = _find_all(section, tag="p")
                    slide["intro"] = _text(ps[0]) if ps else ""
                elif layout == "outcome_grid":
                    slide["outcomes"] = _extract_outcomes(section)
                elif layout == "split_narrow":
                    slide["split"] = _extract_split(section)
                elif layout == "closing":
                    # h2 may contain an inline accent span; capture both halves.
                    h2 = _find(section, tag="h2")
                    spans = _find_all(h2, tag="span") if h2 else []
                    slide["title"] = _text(h2)
                    slide["accent_text"] = _text(spans[0]) if spans else ""
                out.append(slide)
            return out
        
        
        def main():
            ap = argparse.ArgumentParser(description="Extract slide map from html-artifact deck HTML.")
            ap.add_argument("--input", required=True, help="Input HTML deck file")
            ap.add_argument("--output", required=True, help="Output JSON slide-map path")
            args = ap.parse_args()
        
            src = Path(args.input)
            if not src.exists():
                print(f"ERROR: input not found: {src}", file=sys.stderr)
                sys.exit(2)
        
            slides = extract_slides(src.read_text(encoding="utf-8"))
            if not slides:
                print("ERROR: no <section class='slide'> blocks found", file=sys.stderr)
                sys.exit(3)
        
            out = Path(args.output)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(json.dumps(slides, indent=2, ensure_ascii=False))
            print(f"Extracted {len(slides)} slides -> {out}")
            return 0
        
        
        if __name__ == "__main__":
            sys.exit(main())
        
      • render_pptx.py 6.9 KB
        #!/usr/bin/env python3
        """
        Convert PPTX to PDF and then to per-slide PNG images for visual QA.
        
        Uses LibreOffice (soffice) for PPTX-to-PDF conversion and either
        pdftoppm (poppler-utils) or LibreOffice for PDF-to-PNG conversion.
        
        Usage:
            python3 convert_slides.py --input deck.pptx --output-dir ./qa_images/
        
        Exit codes:
            0 = success
            1 = missing dependencies (LibreOffice not installed)
            2 = conversion failed
            3 = invalid input
        """
        
        import argparse
        import os
        import shutil
        import subprocess
        import sys
        import tempfile
        from pathlib import Path
        
        
        def check_soffice() -> str | None:
            """Return path to soffice binary, or None if not found."""
            path = shutil.which("soffice")
            if path:
                return path
            # Check common install locations
            common_paths = [
                "/usr/bin/soffice",
                "/usr/local/bin/soffice",
                "/snap/bin/soffice",
                "/Applications/LibreOffice.app/Contents/MacOS/soffice",
            ]
            for p in common_paths:
                if os.path.isfile(p):
                    return p
            return None
        
        
        def check_pdftoppm() -> str | None:
            """Return path to pdftoppm binary, or None if not found."""
            return shutil.which("pdftoppm")
        
        
        def pptx_to_pdf(soffice_path: str, pptx_path: str, output_dir: str) -> str:
            """Convert PPTX to PDF using LibreOffice headless mode.
        
            Returns path to generated PDF.
            """
            cmd = [
                soffice_path,
                "--headless",
                "--convert-to",
                "pdf",
                "--outdir",
                output_dir,
                pptx_path,
            ]
        
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=120,  # 2 minute timeout for large presentations
            )
        
            if result.returncode != 0:
                raise RuntimeError(f"LibreOffice PDF conversion failed:\nstdout: {result.stdout}\nstderr: {result.stderr}")
        
            # Find the generated PDF
            pptx_name = Path(pptx_path).stem
            pdf_path = Path(output_dir) / f"{pptx_name}.pdf"
        
            if not pdf_path.exists():
                # Sometimes LibreOffice outputs to a slightly different name
                pdfs = list(Path(output_dir).glob("*.pdf"))
                if pdfs:
                    pdf_path = pdfs[0]
                else:
                    raise RuntimeError(
                        f"PDF not found after conversion. Expected: {pdf_path}\nLibreOffice output: {result.stdout}"
                    )
        
            return str(pdf_path)
        
        
        def pdf_to_pngs_pdftoppm(pdftoppm_path: str, pdf_path: str, output_dir: str, dpi: int = 150) -> list[str]:
            """Convert PDF pages to PNGs using pdftoppm.
        
            Returns list of PNG paths sorted by page number.
            """
            prefix = str(Path(output_dir) / "slide")
        
            cmd = [
                pdftoppm_path,
                "-png",
                "-r",
                str(dpi),
                pdf_path,
                prefix,
            ]
        
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
        
            if result.returncode != 0:
                raise RuntimeError(f"pdftoppm conversion failed:\nstderr: {result.stderr}")
        
            # Find generated PNGs
            pngs = sorted(Path(output_dir).glob("slide-*.png"))
            return [str(p) for p in pngs]
        
        
        def pdf_to_pngs_soffice(soffice_path: str, pdf_path: str, output_dir: str) -> list[str]:
            """Convert PDF pages to PNGs using LibreOffice (fallback).
        
            Note: This produces one image per page but quality may vary.
            Returns list of PNG paths.
            """
            cmd = [
                soffice_path,
                "--headless",
                "--convert-to",
                "png",
                "--outdir",
                output_dir,
                pdf_path,
            ]
        
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
        
            if result.returncode != 0:
                raise RuntimeError(f"LibreOffice PNG conversion failed:\nstderr: {result.stderr}")
        
            pngs = sorted(Path(output_dir).glob("*.png"))
            return [str(p) for p in pngs]
        
        
        def convert(pptx_path: str, output_dir: str, dpi: int = 150, keep_pdf: bool = False) -> dict:
            """Full conversion pipeline: PPTX -> PDF -> PNGs.
        
            Returns dict with 'pdf_path', 'png_paths', and 'slide_count'.
            """
            # Validate input
            if not Path(pptx_path).exists():
                raise FileNotFoundError(f"PPTX file not found: {pptx_path}")
        
            if not Path(pptx_path).suffix.lower() == ".pptx":
                raise ValueError(f"File is not a .pptx: {pptx_path}")
        
            # Check dependencies
            soffice_path = check_soffice()
            if not soffice_path:
                raise EnvironmentError(
                    "LibreOffice (soffice) not found. Install it:\n"
                    "  Ubuntu/Debian: sudo apt install libreoffice-impress\n"
                    "  macOS: brew install --cask libreoffice\n"
                    "  Snap: sudo snap install libreoffice"
                )
        
            pdftoppm_path = check_pdftoppm()
        
            # Create output directory
            Path(output_dir).mkdir(parents=True, exist_ok=True)
        
            # Step 1: PPTX -> PDF
            print("Converting PPTX to PDF...")
            with tempfile.TemporaryDirectory() as tmpdir:
                pdf_path = pptx_to_pdf(soffice_path, pptx_path, tmpdir)
        
                # Move PDF to output dir if keeping
                final_pdf = str(Path(output_dir) / Path(pdf_path).name)
                shutil.copy2(pdf_path, final_pdf)
        
            # Step 2: PDF -> PNGs
            print(f"Converting PDF to slide images (DPI={dpi})...")
            if pdftoppm_path:
                print("  Using pdftoppm for high-quality conversion")
                png_paths = pdf_to_pngs_pdftoppm(pdftoppm_path, final_pdf, output_dir, dpi)
            else:
                print("  Using LibreOffice for PNG conversion (pdftoppm not available)")
                png_paths = pdf_to_pngs_soffice(soffice_path, final_pdf, output_dir)
        
            # Clean up PDF if not keeping
            if not keep_pdf and Path(final_pdf).exists():
                Path(final_pdf).unlink()
        
            return {
                "pdf_path": final_pdf if keep_pdf else None,
                "png_paths": png_paths,
                "slide_count": len(png_paths),
            }
        
        
        def main():
            parser = argparse.ArgumentParser(description="Convert PPTX to PDF and per-slide PNG images.")
            parser.add_argument(
                "--input",
                required=True,
                help="Path to input .pptx file",
            )
            parser.add_argument(
                "--output-dir",
                required=True,
                help="Directory for output PNG files",
            )
            parser.add_argument(
                "--dpi",
                type=int,
                default=150,
                help="PNG resolution in DPI (default: 150)",
            )
            parser.add_argument(
                "--keep-pdf",
                action="store_true",
                help="Keep intermediate PDF file",
            )
            args = parser.parse_args()
        
            try:
                result = convert(args.input, args.output_dir, args.dpi, args.keep_pdf)
                print(f"\nSUCCESS: Converted {result['slide_count']} slides to PNG")
                for png in result["png_paths"]:
                    print(f"  {png}")
                if result["pdf_path"]:
                    print(f"  PDF: {result['pdf_path']}")
            except FileNotFoundError as e:
                print(f"ERROR: {e}", file=sys.stderr)
                sys.exit(3)
            except EnvironmentError as e:
                print(f"ERROR: {e}", file=sys.stderr)
                sys.exit(1)
            except Exception as e:
                print(f"ERROR: Conversion failed: {e}", file=sys.stderr)
                sys.exit(2)
        
        
        if __name__ == "__main__":
            main()
        
      • run-unified.py 11.6 KB
        #!/usr/bin/env python3
        """Unified deck pipeline entry point.
        
        Single-command CLI that runs:
            HTML deck  ->  slide-map JSON  ->  .pptx (and optional QA PNGs)
        
        Usage:
            python3 run-unified.py --input deck.html --format pptx --out ./out/
            python3 run-unified.py --input deck.html --format pptx --out deck.pptx
        
        `--out` accepts either a directory (sibling artifacts written next to the
        .pptx) or a `.pptx` file path (single-file mode; no sibling JSON/report).
        
        Format support:
            pptx  -> implemented (this script)
            pdf   -> NOT WIRED here; use scripts/to-pdf.py for HTML->PDF.
        
        Exit codes:
            0  ok
            2  bad input / missing tool
            3  conversion failure
        """
        
        from __future__ import annotations
        
        import argparse
        import json
        import shutil
        import subprocess
        import sys
        from pathlib import Path
        
        HERE = Path(__file__).resolve().parent
        sys.path.insert(0, str(HERE))
        
        # Local imports from this directory
        import _pptx_engine as pptx_engine
        import extract_slides
        
        # ---------------------------------------------------------------------------
        # Steps
        # ---------------------------------------------------------------------------
        
        
        def step_extract(html_path: Path, json_out: Path) -> list[dict]:
            """Step 1: HTML -> slide-map JSON."""
            html = html_path.read_text(encoding="utf-8")
            slides = extract_slides.extract_slides(html)
            json_out.parent.mkdir(parents=True, exist_ok=True)
            json_out.write_text(json.dumps(slides, indent=2), encoding="utf-8")
            return slides
        
        
        def step_build_pptx(slides: list[dict], pptx_out: Path) -> Path:
            """Step 2: slide-map JSON -> .pptx via copied pptx engine."""
            design = {"palette": "minimal"}
            pptx_out.parent.mkdir(parents=True, exist_ok=True)
            pptx_engine.build_presentation(slides, design, str(pptx_out))
            return pptx_out
        
        
        def step_render_qa(pptx_path: Path, render_dir: Path) -> tuple[bool, str]:
            """Step 3 (optional): .pptx -> per-slide PNGs via LibreOffice.
        
            Returns (rendered, message). Skips silently if soffice is unavailable.
            """
            soffice = shutil.which("soffice") or shutil.which("libreoffice")
            if not soffice:
                return False, "skipped (soffice not on PATH)"
            render_dir.mkdir(parents=True, exist_ok=True)
            try:
                subprocess.run(
                    [soffice, "--headless", "--convert-to", "pdf", "--outdir", str(render_dir), str(pptx_path)],
                    check=True,
                    capture_output=True,
                    timeout=120,
                )
                pdfs = list(render_dir.glob("*.pdf"))
                if not pdfs:
                    return False, "soffice produced no PDF"
                return True, f"PDF at {pdfs[0]}"
            except Exception as exc:  # pragma: no cover - host-dependent
                return False, f"soffice failed: {exc}"
        
        
        # ---------------------------------------------------------------------------
        # Fidelity report
        # ---------------------------------------------------------------------------
        
        
        def step_report(
            pptx_path: Path,
            slides: list[dict],
            report_path: Path,
            rendered_msg: str,
        ) -> dict:
            """Open the generated .pptx with python-pptx, compare to baseline, write report.md."""
            from pptx import Presentation
        
            prs = Presentation(str(pptx_path))
            slide_count = len(prs.slides)
            text_frames = sum(1 for sl in prs.slides for sh in sl.shapes if sh.has_text_frame)
            width_in = prs.slide_width / 914400.0
            height_in = prs.slide_height / 914400.0
            aspect = width_in / height_in
            is_widescreen = abs(aspect - 16 / 9) < 0.02
            file_size = pptx_path.stat().st_size
        
            # Type distribution
            type_counts: dict[str, int] = {}
            for s in slides:
                t = s.get("type", "?")
                type_counts[t] = type_counts.get(t, 0) + 1
        
            # Baseline comparison (optional)
            baseline_dir = Path(".audit/pptx-test/render")
            baseline_pngs = sorted(baseline_dir.glob("original-*.png")) if baseline_dir.exists() else []
        
            # ---------- Score ----------
            # Axes (each 0-2):
            #   slide_count_match      (12 expected)
            #   text_frame_density     (>=120 strong, 60-119 partial, <60 weak)
            #   aspect_ratio_widescreen
            #   no_unrecognized_types  (all extracted types present)
            #   build_succeeded        (file exists, parsable)
            score = 0
            worst_axis = None
            worst_score = 99
            axes = []
        
            def add_axis(name: str, points: int, reason: str) -> None:
                nonlocal score, worst_axis, worst_score
                score += points
                axes.append((name, points, reason))
                if points < worst_score:
                    worst_score = points
                    worst_axis = name
        
            add_axis(
                "slide_count_match",
                2 if slide_count == 12 else (1 if abs(slide_count - 12) <= 2 else 0),
                f"got {slide_count}, expected 12",
            )
            add_axis(
                "text_frame_density",
                2 if text_frames >= 120 else (1 if text_frames >= 60 else 0),
                f"got {text_frames}, expected >=120",
            )
            add_axis(
                "aspect_ratio_widescreen",
                2 if is_widescreen else 0,
                f"{width_in:.2f}x{height_in:.2f}in (aspect {aspect:.3f})",
            )
            # Types in extractor that fall back to default in engine = layout fidelity loss.
            # Pulled from the engine's registry so the two stay in sync.
            supported = set(getattr(pptx_engine, "SUPPORTED_LAYOUTS", set()))
            if not supported:  # pragma: no cover - belt-and-suspenders
                supported = {"title", "content", "closing"}
        
            # Normalize extractor types the same way the engine does before lookup.
            def _norm(t: str) -> str:
                return str(t).lower().strip().replace("-", "_").replace(" ", "_")
        
            fallback_types = [t for t in type_counts if _norm(t) not in supported]
            fallback_slides = sum(type_counts[t] for t in fallback_types)
            add_axis(
                "layout_coverage",
                2 if not fallback_types else (1 if fallback_slides <= 6 else 0),
                f"{fallback_slides}/{slide_count} slides fall back to default layout: {fallback_types}",
            )
            add_axis(
                "build_succeeded",
                2 if file_size > 10_000 else 0,
                f"{file_size:,} bytes",
            )
        
            # ---------- Write report ----------
            lines = [
                "# Unified Deck Prototype — V1 Fidelity Report",
                "",
                f"- **Source HTML**: {pptx_path.name.replace('.pptx', '.html')}",
                f"- **Generated PPTX**: `{pptx_path}`",
                f"- **Size**: {file_size:,} bytes",
                f"- **QA render**: {rendered_msg}",
                "",
                "## Slide-level metrics",
                "",
                "| Metric | Value | Expected |",
                "|---|---|---|",
                f"| Slide count | {slide_count} | 12 |",
                f"| Text frames (total) | {text_frames} | >=120 |",
                f"| Slide dimensions | {width_in:.2f} x {height_in:.2f} in | 13.33 x 7.50 in (16:9) |",
                f"| Aspect ratio | {aspect:.3f} | 1.778 |",
                "",
                "## Type distribution (from extractor)",
                "",
                "| Type | Count | Engine handling |",
                "|---|---|---|",
            ]
            for t, c in sorted(type_counts.items(), key=lambda kv: -kv[1]):
                handling = "native" if _norm(t) in supported else "FALLBACK -> content"
                lines.append(f"| `{t}` | {c} | {handling} |")
            lines += [
                "",
                "## Fidelity score",
                "",
                "| Axis | Points (/2) | Detail |",
                "|---|---|---|",
            ]
            for n, p, r in axes:
                lines.append(f"| {n} | {p} | {r} |")
            total_max = 2 * len(axes)
            lines += [
                "",
                f"**Total: {score} / {total_max}** (worst axis: `{worst_axis}` at {worst_score}/2)",
                "",
                "## Baseline comparison",
                "",
            ]
            if baseline_pngs:
                lines.append(f"Baseline PNGs found: {len(baseline_pngs)} files in `{baseline_dir}/`.")
                lines.append("Per-slide perceptual diff is not implemented in V1; visual comparison")
                lines.append("requires manual side-by-side review or a future image-diff axis.")
            else:
                lines.append("No baseline PNGs found at `.audit/pptx-test/render/` — comparison skipped.")
            lines += [
                "",
                "## Known gaps (Phase 2 scope)",
                "",
                "- PDF output (`--format pdf`) NOT wired; would require LibreOffice headless or weasyprint.",
                "- Perceptual diff vs baseline PNGs not implemented (would require pixelmatch / SSIM).",
                "- LibreOffice render step skipped on hosts without `soffice`.",
                "- Font embedding not implemented; relies on Aptos / Cascadia Code being present",
                "  on the viewer machine (PowerPoint 2023+ ships them).",
            ]
            report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
        
            # Normalize fidelity to a 0-10 score
            fidelity_10 = round(score / total_max * 10, 1)
            return {
                "score": score,
                "score_max": total_max,
                "fidelity_10": fidelity_10,
                "worst_axis": worst_axis,
                "worst_score": worst_score,
                "slide_count": slide_count,
                "text_frames": text_frames,
                "fallback_types": fallback_types,
            }
        
        
        # ---------------------------------------------------------------------------
        # Main
        # ---------------------------------------------------------------------------
        
        
        def main() -> int:
            parser = argparse.ArgumentParser(description="Unified deck pipeline (HTML -> PPTX)")
            parser.add_argument("--input", required=True, help="HTML deck file")
            parser.add_argument(
                "--format",
                choices=["pptx", "pdf"],
                default="pptx",
                help="Output format (pdf NOT wired here; use to-pdf.py)",
            )
            parser.add_argument("--out", required=True, help="Output directory or .pptx file path")
            parser.add_argument(
                "--no-render",
                action="store_true",
                help="Skip optional LibreOffice QA render step",
            )
            args = parser.parse_args()
        
            if args.format == "pdf":
                print("FAIL: --format pdf is not wired here; use scripts/to-pdf.py", file=sys.stderr)
                return 2
        
            html_path = Path(args.input).resolve()
            if not html_path.exists():
                print(f"FAIL: input HTML not found: {html_path}", file=sys.stderr)
                return 2
        
            out_arg = Path(args.out).resolve()
            # Single-file mode: --out points at a .pptx (parent dir is the working dir).
            # Directory mode: --out is (or will become) a directory; sibling artifacts
            # land next to the .pptx.
            if out_arg.suffix.lower() == ".pptx":
                out_dir = out_arg.parent
                pptx_path = out_arg
                single_file_mode = True
            else:
                out_dir = out_arg
                pptx_path = out_dir / (html_path.stem + ".pptx")
                single_file_mode = False
            out_dir.mkdir(parents=True, exist_ok=True)
        
            json_path = out_dir / "slides.json"
            report_path = out_dir / "report.md"
            render_dir = out_dir / "render"
        
            try:
                # Step 1
                slides = step_extract(html_path, json_path)
                if not slides:
                    print("FAIL: extractor produced 0 slides", file=sys.stderr)
                    return 3
                # Step 2
                step_build_pptx(slides, pptx_path)
                if not pptx_path.exists() or pptx_path.stat().st_size < 1000:
                    print(f"FAIL: pptx not produced at {pptx_path}", file=sys.stderr)
                    return 3
                # Step 3 (optional)
                if args.no_render:
                    rendered_msg = "skipped (--no-render)"
                else:
                    ok, msg = step_render_qa(pptx_path, render_dir)
                    rendered_msg = msg if ok else f"skipped ({msg})"
                # Step 4
                result = step_report(pptx_path, slides, report_path, rendered_msg)
            except Exception as exc:
                print(f"FAIL: {type(exc).__name__}: {exc}", file=sys.stderr)
                return 3
        
            print(
                f"OK: wrote {pptx_path} "
                f"({result['slide_count']} slides, {result['text_frames']} text frames, "
                f"fidelity ~{result['fidelity_10']}/10)"
            )
            if not single_file_mode:
                print(f"  report: {report_path}")
                print(f"  json:   {json_path}")
            return 0
        
        
        if __name__ == "__main__":
            sys.exit(main())
        
      • _pptx_engine.py 26.4 KB
        #!/usr/bin/env python3
        """Deterministic PPTX generator (v2: dark theme + full layout coverage).
        
        Reads a slide-map JSON and produces a .pptx file. No LLM calls -- pure
        mechanical slide construction using python-pptx.
        
        Layout coverage (matches `extract_slides.py` output):
            title, content, metric_grid, layer_rows, pipeline, code_block,
            compare_table_2col, compare_table_3col, outcome_grid, split_narrow,
            closing, section/section_divider (legacy passthrough).
        
        Theme: matches `vexjoy-agent-management-deck.html` -- dark navy background,
        Aptos body font, Cascadia Code mono, sky-blue accent, semantic colors.
        """
        
        from __future__ import annotations
        
        import argparse
        import json
        import sys
        from pathlib import Path
        
        try:
            from pptx import Presentation
            from pptx.dml.color import RGBColor
            from pptx.enum.shapes import MSO_SHAPE
            from pptx.enum.text import PP_ALIGN
            from pptx.util import Inches, Pt
        except ImportError:
            print("ERROR: python-pptx not installed. Run: pip install python-pptx", file=sys.stderr)
            sys.exit(1)
        
        
        # ---------------------------------------------------------------------------
        # THEME — single source of truth for v2 visual identity.
        # Matches the dark navy HTML deck. Keep colors here; never inline hex codes
        # in builders.
        # ---------------------------------------------------------------------------
        THEME = {
            # Backgrounds
            "bg": RGBColor(0x1A, 0x1A, 0x2E),  # slide background
            "card_bg": RGBColor(0x23, 0x23, 0x40),  # surface / card
            "code_bg": RGBColor(0x16, 0x16, 0x2A),  # code panel
            "border": RGBColor(0x3A, 0x3A, 0x5C),
            # Text
            "fg": RGBColor(0xE8, 0xE8, 0xF0),  # primary text (off-white)
            "fg_sec": RGBColor(0xA0, 0xA0, 0xB8),  # secondary
            "muted": RGBColor(0x6E, 0x6E, 0x8A),  # muted / eyebrow
            # Accents
            "accent": RGBColor(0x64, 0xB5, 0xF6),  # sky blue (brand)
            "success": RGBColor(0x81, 0xC7, 0x84),  # softer green for dark bg
            "danger": RGBColor(0xEF, 0x53, 0x50),  # red
            # Fonts (chained fallbacks aren't supported by python-pptx in a single
            # field; PowerPoint 2023+ ships Aptos and Cascadia Code by default. If
            # absent, PowerPoint falls back to its own substitution table — which
            # gives Segoe UI / Calibri on Windows and Helvetica Neue on macOS.)
            "font_body": "Aptos",
            "font_mono": "Cascadia Code",
        }
        
        
        # ---------------------------------------------------------------------------
        # Legacy palette support (kept so old callers don't break, but builders all
        # read THEME).
        # ---------------------------------------------------------------------------
        PALETTES = {
            "vexjoy-dark": {
                "primary": "#1A1A2E",
                "secondary": "#23233D",
                "accent": "#64B5F6",
                "background": "#1A1A2E",
                "text": "#E8E8F0",
                "muted": "#6E6E8A",
            },
        }
        
        
        def hex_to_rgb(hex_str: str) -> RGBColor:
            h = hex_str.lstrip("#")
            return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
        
        
        def get_palette(name: str) -> dict:
            raw = PALETTES.get(name.lower(), PALETTES["vexjoy-dark"])
            return {role: hex_to_rgb(color) for role, color in raw.items()}
        
        
        # ---------------------------------------------------------------------------
        # Low-level primitives (theme-aware)
        # ---------------------------------------------------------------------------
        
        
        def fill_bg(slide, color: RGBColor | None = None) -> None:
            bg = slide.background
            fill = bg.fill
            fill.solid()
            fill.fore_color.rgb = color or THEME["bg"]
        
        
        def add_text(
            slide,
            left,
            top,
            width,
            height,
            text: str,
            *,
            size: int = 18,
            bold: bool = False,
            italic: bool = False,
            color: RGBColor | None = None,
            align=PP_ALIGN.LEFT,
            font: str | None = None,
            word_wrap: bool = True,
        ):
            """Add a single-run text box."""
            tb = slide.shapes.add_textbox(left, top, width, height)
            tf = tb.text_frame
            tf.word_wrap = word_wrap
            p = tf.paragraphs[0]
            p.alignment = align
            r = p.add_run()
            r.text = text
            r.font.name = font or THEME["font_body"]
            r.font.size = Pt(size)
            r.font.bold = bold
            r.font.italic = italic
            r.font.color.rgb = color if color is not None else THEME["fg"]
            return tb
        
        
        def add_rect(
            slide,
            left,
            top,
            width,
            height,
            *,
            fill_color: RGBColor | None = None,
            line_color: RGBColor | None = None,
            line_width_pt: float = 0.75,
        ):
            shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
            shape.fill.solid()
            shape.fill.fore_color.rgb = fill_color or THEME["card_bg"]
            shape.line.color.rgb = line_color or THEME["border"]
            shape.line.width = Pt(line_width_pt)
            shape.shadow.inherit = False
            shape.text_frame.text = ""
            return shape
        
        
        def add_eyebrow_and_title(slide, eyebrow: str, title: str, *, title_size: int = 32):
            """Standard top-of-slide pattern: eyebrow label + bold title."""
            if eyebrow:
                add_text(
                    slide,
                    Inches(0.6),
                    Inches(0.5),
                    Inches(12),
                    Inches(0.4),
                    eyebrow.upper(),
                    size=12,
                    bold=True,
                    color=THEME["accent"],
                )
            if title:
                add_text(
                    slide,
                    Inches(0.6),
                    Inches(1.0),
                    Inches(12),
                    Inches(1.0),
                    title,
                    size=title_size,
                    bold=True,
                    color=THEME["fg"],
                )
        
        
        # ---------------------------------------------------------------------------
        # Slide builders. Each takes (prs, slide_data) and returns the slide.
        # ---------------------------------------------------------------------------
        
        
        def _new_slide(prs):
            slide = prs.slides.add_slide(prs.slide_layouts[6])  # blank
            fill_bg(slide)
            return slide
        
        
        def build_title(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            eyebrow = slide_data.get("eyebrow", "")
            title = slide_data.get("title", "Untitled")
            subtitle = slide_data.get("subtitle", "")
        
            if eyebrow:
                add_text(
                    s,
                    Inches(0.5),
                    Inches(0.5),
                    Inches(12.3),
                    Inches(0.5),
                    eyebrow.upper(),
                    size=14,
                    bold=True,
                    color=THEME["accent"],
                    align=PP_ALIGN.CENTER,
                )
            add_text(
                s,
                Inches(0.5),
                Inches(2.8),
                Inches(12.3),
                Inches(1.5),
                title,
                size=56,
                bold=True,
                color=THEME["fg"],
                align=PP_ALIGN.CENTER,
            )
            if subtitle:
                add_text(
                    s,
                    Inches(0.5),
                    Inches(4.6),
                    Inches(12.3),
                    Inches(2.0),
                    subtitle,
                    size=22,
                    color=THEME["fg_sec"],
                    align=PP_ALIGN.CENTER,
                )
            return s
        
        
        def build_content(prs, slide_data, _palette=None):
            """Eyebrow + title + lead paragraph + optional bullets + optional callout."""
            s = _new_slide(prs)
            eyebrow = slide_data.get("eyebrow", "")
            title = slide_data.get("title", "")
            lead = slide_data.get("lead", "")
            callout = slide_data.get("callout", "")
            bullets = slide_data.get("bullets", [])
        
            add_eyebrow_and_title(s, eyebrow, title, title_size=32)
        
            y = 2.3
            if lead:
                add_text(
                    s,
                    Inches(0.6),
                    Inches(y),
                    Inches(12.0),
                    Inches(2.5),
                    lead,
                    size=18,
                    color=THEME["fg_sec"],
                )
                # Approx height per ~110 chars ≈ 0.45in
                y += max(1.0, 0.45 * (len(lead) // 110 + 1))
        
            if bullets:
                y = max(y, 3.4)
                for b in bullets:
                    if isinstance(b, dict):
                        prefix = b.get("bold_prefix", "")
                        txt = b.get("text", "")
                    else:
                        prefix, txt = "", str(b)
                    tb = s.shapes.add_textbox(Inches(0.9), Inches(y), Inches(11.5), Inches(0.6))
                    tf = tb.text_frame
                    tf.word_wrap = True
                    p = tf.paragraphs[0]
                    p.alignment = PP_ALIGN.LEFT
                    # Bullet glyph
                    r0 = p.add_run()
                    r0.text = "• "
                    r0.font.name = THEME["font_body"]
                    r0.font.size = Pt(18)
                    r0.font.color.rgb = THEME["accent"]
                    if prefix:
                        r1 = p.add_run()
                        r1.text = prefix + " "
                        r1.font.name = THEME["font_body"]
                        r1.font.size = Pt(18)
                        r1.font.bold = True
                        r1.font.color.rgb = THEME["fg"]
                    r2 = p.add_run()
                    r2.text = txt
                    r2.font.name = THEME["font_body"]
                    r2.font.size = Pt(18)
                    r2.font.color.rgb = THEME["fg_sec"]
                    y += 0.6
        
            if callout:
                cy = max(y + 0.2, 5.6)
                add_rect(
                    s,
                    Inches(0.6),
                    Inches(cy),
                    Inches(12.2),
                    Inches(1.4),
                    fill_color=THEME["card_bg"],
                    line_color=THEME["accent"],
                )
                add_text(
                    s,
                    Inches(0.85),
                    Inches(cy + 0.2),
                    Inches(11.7),
                    Inches(1.0),
                    callout,
                    size=14,
                    italic=True,
                    color=THEME["fg"],
                )
            return s
        
        
        def build_section_divider(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_text(
                s,
                Inches(0.6),
                Inches(0.5),
                Inches(12),
                Inches(0.5),
                slide_data.get("eyebrow", "SECTION").upper(),
                size=14,
                bold=True,
                color=THEME["accent"],
            )
            add_text(
                s,
                Inches(1.0),
                Inches(2.8),
                Inches(11.3),
                Inches(2.0),
                slide_data.get("title", ""),
                size=44,
                bold=True,
                color=THEME["fg"],
                align=PP_ALIGN.LEFT,
            )
            if slide_data.get("subtitle"):
                add_text(
                    s,
                    Inches(1.0),
                    Inches(4.8),
                    Inches(11.3),
                    Inches(1.5),
                    slide_data.get("subtitle", ""),
                    size=20,
                    color=THEME["fg_sec"],
                )
            return s
        
        
        def build_metric_grid(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
        
            metrics = slide_data.get("metrics", [])
            n = max(1, len(metrics))
            card_w = Inches(2.85) if n >= 4 else Inches(3.5)
            card_h = Inches(2.5)
            gap = Inches(0.18)
            total_w = card_w * n + gap * (n - 1)
            left0 = (prs.slide_width - total_w) / 2
            top0 = Inches(2.6)
        
            for i, m in enumerate(metrics):
                val = m.get("value", "")
                lab = m.get("label", "")
                desc = m.get("desc", "")
                x = left0 + i * (card_w + gap)
                add_rect(s, x, top0, card_w, card_h)
                add_text(
                    s,
                    x,
                    top0 + Inches(0.4),
                    card_w,
                    Inches(1.0),
                    val,
                    size=48,
                    bold=True,
                    color=THEME["accent"],
                    align=PP_ALIGN.CENTER,
                )
                add_text(
                    s,
                    x,
                    top0 + Inches(1.4),
                    card_w,
                    Inches(0.4),
                    lab,
                    size=14,
                    bold=True,
                    color=THEME["muted"],
                    align=PP_ALIGN.CENTER,
                )
                if desc:
                    add_text(
                        s,
                        x,
                        top0 + Inches(1.85),
                        card_w,
                        Inches(0.5),
                        desc,
                        size=12,
                        color=THEME["fg_sec"],
                        align=PP_ALIGN.CENTER,
                    )
        
            callout = slide_data.get("callout", "")
            if callout:
                add_text(
                    s,
                    Inches(0.6),
                    Inches(5.6),
                    Inches(12),
                    Inches(1.5),
                    callout,
                    size=14,
                    color=THEME["fg_sec"],
                    align=PP_ALIGN.CENTER,
                )
            return s
        
        
        def build_layer_rows(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
            layers = slide_data.get("layers", [])
            y = Inches(2.4)
            for layer in layers:
                name = layer.get("name", "")
                count = layer.get("count", "")
                desc = layer.get("desc", "")
                add_rect(s, Inches(0.6), y, Inches(12.2), Inches(0.95))
                add_text(s, Inches(0.9), y + Inches(0.25), Inches(2), Inches(0.5), name, size=22, bold=True, color=THEME["fg"])
                add_text(
                    s,
                    Inches(3.0),
                    y + Inches(0.25),
                    Inches(1.2),
                    Inches(0.5),
                    str(count),
                    size=22,
                    bold=True,
                    color=THEME["accent"],
                )
                add_text(s, Inches(4.5), y + Inches(0.3), Inches(8.2), Inches(0.5), desc, size=14, color=THEME["fg_sec"])
                y += Inches(1.05)
            return s
        
        
        def build_pipeline(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
            steps = slide_data.get("pipeline_steps", [])
            n = max(1, len(steps))
            sw = Inches(1.85) if n >= 6 else Inches(2.1)
            sh = Inches(1.2)
            sgap = Inches(0.15)
            total = sw * n + sgap * (n - 1)
            sl = (prs.slide_width - total) / 2
            sy = Inches(3.0)
            for i, step in enumerate(steps):
                label = step.get("label", f"{i + 1:02d}")
                name = step.get("name", "")
                x = sl + i * (sw + sgap)
                add_rect(s, x, sy, sw, sh, line_color=THEME["accent"])
                add_text(
                    s,
                    x,
                    sy + Inches(0.15),
                    sw,
                    Inches(0.4),
                    label,
                    size=12,
                    bold=True,
                    color=THEME["muted"],
                    align=PP_ALIGN.CENTER,
                )
                add_text(
                    s, x, sy + Inches(0.55), sw, Inches(0.5), name, size=18, bold=True, color=THEME["fg"], align=PP_ALIGN.CENTER
                )
        
            caption = slide_data.get("pipeline_caption", "") or slide_data.get("callout", "")
            if caption:
                add_text(s, Inches(0.6), Inches(5.0), Inches(12), Inches(1.8), caption, size=16, color=THEME["fg_sec"])
            return s
        
        
        def build_code_block(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
            code = slide_data.get("code", "")
            add_rect(
                s,
                Inches(0.8),
                Inches(2.3),
                Inches(11.7),
                Inches(4.6),
                fill_color=THEME["code_bg"],
                line_color=THEME["border"],
            )
            tb = s.shapes.add_textbox(Inches(1.0), Inches(2.5), Inches(11.3), Inches(4.2))
            tf = tb.text_frame
            tf.word_wrap = True
            for i, line in enumerate(code.split("\n")):
                p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
                r = p.add_run()
                r.text = line if line else " "
                r.font.name = THEME["font_mono"]
                r.font.size = Pt(13)
                if line.lstrip().startswith("$"):
                    r.font.color.rgb = THEME["muted"]
                elif "✓" in line or "Delivered" in line:
                    r.font.color.rgb = THEME["success"]
                elif line.lstrip().startswith(">"):
                    r.font.color.rgb = THEME["accent"]
                else:
                    r.font.color.rgb = THEME["fg"]
            return s
        
        
        def _row_color(role: str) -> RGBColor:
            role = (role or "").lower()
            if role == "success":
                return THEME["success"]
            if role == "danger":
                return THEME["danger"]
            if role == "label":
                return THEME["fg"]
            return THEME["fg_sec"]
        
        
        def _cell_text(cell) -> str:
            if isinstance(cell, dict):
                return cell.get("text", "")
            return str(cell)
        
        
        def _cell_role(cell) -> str:
            if isinstance(cell, dict):
                return cell.get("role", "")
            return ""
        
        
        def build_compare_table_2col(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
        
            intro = slide_data.get("intro", "")
            if intro:
                add_text(s, Inches(0.6), Inches(2.0), Inches(12), Inches(0.6), intro, size=16, color=THEME["fg_sec"])
        
            tbl = slide_data.get("table", {})
            headers = tbl.get("headers", [])
            rows = tbl.get("rows", [])
            ty = Inches(2.7) if intro else Inches(2.2)
            rh = Inches(0.55)
        
            # Header row
            if len(headers) >= 2:
                add_text(s, Inches(0.7), ty, Inches(5.5), rh, headers[0], size=14, bold=True, color=THEME["muted"])
                add_text(s, Inches(6.5), ty, Inches(6.3), rh, headers[1], size=14, bold=True, color=THEME["muted"])
                ty += Inches(0.65)
        
            for row in rows:
                if len(row) < 2:
                    continue
                l_text = _cell_text(row[0])
                r_text = _cell_text(row[1])
                l_color = _row_color(_cell_role(row[0]))
                r_color = _row_color(_cell_role(row[1]))
                add_text(s, Inches(0.7), ty, Inches(5.5), rh, l_text, size=14, color=l_color)
                add_text(s, Inches(6.5), ty, Inches(6.3), rh, r_text, size=14, color=r_color)
                ty += Inches(0.65)
            return s
        
        
        def build_compare_table_3col(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
                title_size=26,
            )
        
            intro = slide_data.get("intro", "")
            if intro:
                add_text(s, Inches(0.6), Inches(2.0), Inches(12), Inches(0.6), intro, size=14, color=THEME["fg_sec"])
        
            tbl = slide_data.get("table", {})
            headers = tbl.get("headers", [])
            rows = tbl.get("rows", [])
            ty = Inches(2.4) if not intro else Inches(2.7)
        
            if len(headers) >= 3:
                add_text(s, Inches(0.7), ty, Inches(4.0), Inches(0.5), headers[0], size=13, bold=True, color=THEME["muted"])
                add_text(s, Inches(4.9), ty, Inches(4.0), Inches(0.5), headers[1], size=13, bold=True, color=THEME["muted"])
                add_text(s, Inches(9.1), ty, Inches(4.0), Inches(0.5), headers[2], size=13, bold=True, color=THEME["muted"])
                ty += Inches(0.7)
        
            for row in rows:
                if len(row) < 3:
                    continue
                add_text(
                    s,
                    Inches(0.7),
                    ty,
                    Inches(4.0),
                    Inches(0.5),
                    _cell_text(row[0]),
                    size=13,
                    color=_row_color(_cell_role(row[0]) or "label"),
                )
                add_text(
                    s,
                    Inches(4.9),
                    ty,
                    Inches(4.0),
                    Inches(0.5),
                    _cell_text(row[1]),
                    size=13,
                    color=_row_color(_cell_role(row[1]) or "danger"),
                )
                add_text(
                    s,
                    Inches(9.1),
                    ty,
                    Inches(4.0),
                    Inches(0.5),
                    _cell_text(row[2]),
                    size=13,
                    color=_row_color(_cell_role(row[2]) or "success"),
                )
                ty += Inches(0.7)
            return s
        
        
        def build_outcome_grid(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            add_eyebrow_and_title(
                s,
                slide_data.get("eyebrow", ""),
                slide_data.get("title", ""),
            )
            outcomes = slide_data.get("outcomes", [])
            ow = Inches(4.0)
            oh = Inches(1.85)
            ogap = Inches(0.15)
            ox0 = Inches(0.6)
            oy0 = Inches(2.4)
            for i, o in enumerate(outcomes):
                col = i % 3
                row = i // 3
                x = ox0 + col * (ow + ogap)
                y = oy0 + row * (oh + ogap)
                add_rect(s, x, y, ow, oh)
                add_text(
                    s,
                    x + Inches(0.2),
                    y + Inches(0.2),
                    ow - Inches(0.4),
                    Inches(0.6),
                    o.get("heading", ""),
                    size=16,
                    bold=True,
                    color=THEME["fg"],
                )
                add_text(
                    s,
                    x + Inches(0.2),
                    y + Inches(0.85),
                    ow - Inches(0.4),
                    Inches(1.0),
                    o.get("body", ""),
                    size=12,
                    color=THEME["fg_sec"],
                )
            return s
        
        
        def build_split_narrow(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            split = slide_data.get("split", {})
            left = split.get("left", {})
            rows = split.get("rows", [])
        
            eyebrow = left.get("eyebrow", slide_data.get("eyebrow", ""))
            title = left.get("title", slide_data.get("title", ""))
            lead = left.get("lead", slide_data.get("lead", ""))
            callout = left.get("callout", slide_data.get("callout", ""))
        
            if eyebrow:
                add_text(
                    s,
                    Inches(0.6),
                    Inches(0.5),
                    Inches(8),
                    Inches(0.4),
                    eyebrow.upper(),
                    size=12,
                    bold=True,
                    color=THEME["accent"],
                )
            if title:
                add_text(s, Inches(0.6), Inches(1.0), Inches(7.5), Inches(1.2), title, size=32, bold=True, color=THEME["fg"])
            if lead:
                add_text(s, Inches(0.6), Inches(2.4), Inches(7.5), Inches(2.5), lead, size=16, color=THEME["fg_sec"])
            if callout:
                add_rect(s, Inches(0.7), Inches(5.0), Inches(7.3), Inches(1.4), line_color=THEME["accent"])
                add_text(s, Inches(0.95), Inches(5.3), Inches(6.85), Inches(0.9), callout, size=14, color=THEME["fg"])
        
            # Right rail: card with name/trigger rows
            add_rect(s, Inches(8.4), Inches(1.0), Inches(4.3), Inches(5.4))
            cy = Inches(1.3)
            for r in rows:
                name = r.get("name", "")
                trigger = r.get("trigger", "")
                add_text(s, Inches(8.6), cy, Inches(2.5), Inches(0.6), name, size=16, bold=True, color=THEME["fg"])
                add_text(
                    s,
                    Inches(11.0),
                    cy,
                    Inches(1.6),
                    Inches(0.6),
                    trigger,
                    size=16,
                    color=THEME["accent"],
                    font=THEME["font_mono"],
                )
                cy += Inches(0.95)
            return s
        
        
        def build_closing(prs, slide_data, _palette=None):
            s = _new_slide(prs)
            eyebrow = slide_data.get("eyebrow", "")
            title = slide_data.get("title", "Thank You")
            accent_text = slide_data.get("accent_text", "")
            subtitle = slide_data.get("subtitle", "")
        
            if eyebrow:
                add_text(
                    s,
                    Inches(0.6),
                    Inches(0.5),
                    Inches(12),
                    Inches(0.4),
                    eyebrow.upper(),
                    size=14,
                    bold=True,
                    color=THEME["accent"],
                    align=PP_ALIGN.CENTER,
                )
        
            # Split title from accent_text if accent_text appears within title.
            main_text = title
            if accent_text and accent_text in title:
                main_text = title.replace(accent_text, "").strip()
        
            add_text(
                s,
                Inches(1.0),
                Inches(2.6),
                Inches(11.3),
                Inches(1.5),
                main_text,
                size=32,
                bold=True,
                color=THEME["fg"],
                align=PP_ALIGN.CENTER,
            )
            if accent_text:
                add_text(
                    s,
                    Inches(1.0),
                    Inches(3.9),
                    Inches(11.3),
                    Inches(1.5),
                    accent_text,
                    size=32,
                    bold=True,
                    color=THEME["accent"],
                    align=PP_ALIGN.CENTER,
                )
            if subtitle:
                add_text(
                    s,
                    Inches(1.0),
                    Inches(5.7),
                    Inches(11.3),
                    Inches(1.5),
                    subtitle,
                    size=16,
                    color=THEME["fg_sec"],
                    align=PP_ALIGN.CENTER,
                )
            return s
        
        
        # ---------------------------------------------------------------------------
        # Layout dispatcher
        # ---------------------------------------------------------------------------
        
        LAYOUT_BUILDERS = {
            "title": build_title,
            "section": build_section_divider,
            "section_divider": build_section_divider,
            "content": build_content,
            "bullets": build_content,
            "content_bullets": build_content,
            "metric_grid": build_metric_grid,
            "metrics": build_metric_grid,
            "layer_rows": build_layer_rows,
            "pipeline": build_pipeline,
            "code_block": build_code_block,
            "code": build_code_block,
            "compare_table_2col": build_compare_table_2col,
            "compare_table_3col": build_compare_table_3col,
            "outcome_grid": build_outcome_grid,
            "outcomes": build_outcome_grid,
            "split_narrow": build_split_narrow,
            "closing": build_closing,
        }
        
        
        # Public registry so callers (and run-unified.py's report) can ask
        # "is this type natively supported?".
        SUPPORTED_LAYOUTS = set(LAYOUT_BUILDERS.keys())
        
        
        def build_presentation(slide_map: list, design: dict, output_path: str) -> str:
            palette_name = design.get("palette", "vexjoy-dark")
            palette = get_palette(palette_name)
        
            template_path = design.get("template_path")
            if template_path and Path(template_path).exists():
                prs = Presentation(template_path)
            else:
                prs = Presentation()
        
            prs.slide_width = Inches(13.333)
            prs.slide_height = Inches(7.5)
        
            for slide_data in slide_map:
                raw_type = slide_data.get("type", "content")
                slide_type = str(raw_type).lower().strip().replace("-", "_").replace(" ", "_")
                builder = LAYOUT_BUILDERS.get(slide_type, build_content)
                builder(prs, slide_data, palette)
        
            output = Path(output_path)
            output.parent.mkdir(parents=True, exist_ok=True)
            prs.save(str(output))
            return str(output)
        
        
        # ---------------------------------------------------------------------------
        # CLI
        # ---------------------------------------------------------------------------
        
        
        def main():
            parser = argparse.ArgumentParser(description="Generate a PPTX presentation from a slide map JSON.")
            parser.add_argument("--slide-map", required=True)
            parser.add_argument("--design", required=True)
            parser.add_argument("--output", required=True)
            args = parser.parse_args()
        
            slide_map_path = Path(args.slide_map)
            design_path = Path(args.design)
        
            if not slide_map_path.exists():
                print(f"ERROR: Slide map not found: {slide_map_path}", file=sys.stderr)
                sys.exit(2)
            if not design_path.exists():
                print(f"ERROR: Design config not found: {design_path}", file=sys.stderr)
                sys.exit(2)
        
            try:
                with open(slide_map_path) as f:
                    slide_map = json.load(f)
                with open(design_path) as f:
                    design = json.load(f)
            except json.JSONDecodeError as e:
                print(f"ERROR: Invalid JSON: {e}", file=sys.stderr)
                sys.exit(2)
        
            if not isinstance(slide_map, list) or not slide_map:
                print("ERROR: Slide map must be a non-empty JSON array", file=sys.stderr)
                sys.exit(2)
        
            try:
                result = build_presentation(slide_map, design, args.output)
                size = Path(result).stat().st_size
                print(f"SUCCESS: {len(slide_map)} slides -> {result} ({size:,} bytes)")
            except Exception as e:
                print(f"ERROR: Generation failed: {e}", file=sys.stderr)
                sys.exit(3)
        
        
        if __name__ == "__main__":
            main()
        
      • __init__.py 490 B
        """html-artifact PPTX bridge.
        
        Absorbed from the v2 unified-deck prototype on 2026-05-27 (ADR
        `adr/unified-deck-pipeline.md`, Shape A). Provides HTML deck -> editable
        .pptx via a deterministic THEME dict and structural slide builders.
        
        Modules:
            extract_slides  HTML <section class="slide"> -> slide-map dict list
            _pptx_engine    slide-map dict list -> .pptx via python-pptx
            render_pptx     optional .pptx -> PNG QA via soffice / pdftoppm
            run-unified     CLI entry point
        """
        
    • tests
      • test_assemble_template.py 14 KB
        """Tests for assemble-template.py — deterministic HTML template assembler."""
        
        from __future__ import annotations
        
        import subprocess
        import sys
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "assemble-template.py")
        
        # --- Import module directly for unit tests ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        from importlib import import_module
        
        assemble_mod = import_module("assemble-template")
        assemble_template = assemble_mod.assemble_template
        
        
        class TestAssembleTemplateDirect:
            """Unit tests calling assemble_template() directly."""
        
            def test_title_injected(self) -> None:
                html = assemble_template("spec", "My Title")
                assert "<title>My Title</title>" in html
        
            def test_birchline_theme_tokens(self) -> None:
                html = assemble_template("spec", "Test")
                # Birchline theme should inject its tokens
                assert "--color-primary: #D97757" in html
        
            def test_dark_focus_theme(self) -> None:
                html = assemble_template("code-review", "Test")
                assert "Dark Focus Theme" in html
                assert "--color-primary: #64B5F6" in html
        
            def test_interactive_warm_theme(self) -> None:
                html = assemble_template("prototype", "Test")
                assert "Interactive Warm Theme" in html
                assert "--color-primary: #5B8DEF" in html
        
            def test_minimal_document_theme(self) -> None:
                html = assemble_template("spec", "Test", theme="minimal-document")
                assert "Minimal Document Theme" in html
                assert "Georgia" in html
        
            def test_theme_override(self) -> None:
                # spec defaults to birchline, override to dark-focus
                html = assemble_template("spec", "Test", theme="dark-focus")
                assert "Dark Focus Theme" in html
                assert "--color-primary: #64B5F6" in html
        
            def test_shape_default_themes(self) -> None:
                expected = {
                    "spec": "birchline",
                    "code-review": "dark-focus",
                    "prototype": "interactive-warm",
                    "report": "birchline",
                    "editor": "interactive-warm",
                    "data-viz": "dark-focus",
                    "diagram": "dark-focus",
                    "deck": "dark-focus",
                }
                for shape, theme in expected.items():
                    html = assemble_template(shape, "Test")
                    if theme == "birchline":
                        assert "--color-primary: #D97757" in html, f"{shape} should use birchline"
                    elif theme == "dark-focus":
                        assert "--color-primary: #64B5F6" in html, f"{shape} should use dark-focus"
                    elif theme == "interactive-warm":
                        assert "--color-primary: #5B8DEF" in html, f"{shape} should use interactive-warm"
        
            def test_invalid_shape_raises(self) -> None:
                with pytest.raises(ValueError, match="Invalid shape"):
                    assemble_template("invalid", "Test")
        
            def test_invalid_theme_raises(self) -> None:
                with pytest.raises(ValueError, match="Invalid theme"):
                    assemble_template("spec", "Test", theme="neon")
        
            def test_output_is_valid_html_structure(self) -> None:
                html = assemble_template("report", "Report Title")
                assert "<!DOCTYPE html>" in html
                assert "<html" in html
                assert "<head>" in html
                assert "<body" in html
                assert "</body>" in html
        
            def test_html_entities_in_title(self) -> None:
                html = assemble_template("spec", "A & B <comparison>")
                assert "<title>A & B <comparison></title>" in html
        
            def test_deterministic_same_input_same_output(self) -> None:
                results = [assemble_template("data-viz", "Dashboard") for _ in range(5)]
                assert all(r == results[0] for r in results)
        
            # --- New shape tests ---
        
            def test_diagram_shape_valid(self) -> None:
                html = assemble_template("diagram", "Architecture")
                assert "<title>Architecture</title>" in html
                assert "Diagram Shape" in html
        
            def test_deck_shape_valid(self) -> None:
                html = assemble_template("deck", "Presentation")
                assert "<title>Presentation</title>" in html
                assert "Slide Deck Shape" in html
        
            # --- Component injection tests ---
        
            def test_components_tabs(self) -> None:
                html = assemble_template("spec", "Test", components=["tabs"])
                assert "Tabs Component" in html
                assert ".tab-bar" in html
                assert "classList.add('active')" in html  # JS injected
        
            def test_components_collapsible(self) -> None:
                html = assemble_template("report", "Test", components=["collapsible"])
                assert "Collapsible Component" in html
                assert ".accordion-trigger" in html
                assert "aria-expanded" in html  # JS injected
        
            def test_components_multiple(self) -> None:
                html = assemble_template("spec", "Test", components=["tabs", "collapsible"])
                assert "Tabs Component" in html
                assert "Collapsible Component" in html
        
            def test_components_none(self) -> None:
                """No components = no component CSS/JS injected."""
                html = assemble_template("spec", "Test")
                assert "Tabs Component" not in html
                assert "Drag and Drop Component" not in html
        
            def test_components_empty_list(self) -> None:
                html = assemble_template("spec", "Test", components=[])
                assert "Tabs Component" not in html
        
            def test_invalid_component_raises(self) -> None:
                with pytest.raises(ValueError, match="Invalid component"):
                    assemble_template("spec", "Test", components=["nonexistent"])
        
            def test_components_drag_drop(self) -> None:
                html = assemble_template("editor", "Test", components=["drag-drop"])
                assert "Drag and Drop Component" in html
                assert ".drag-item" in html
        
            def test_components_copy_button(self) -> None:
                html = assemble_template("spec", "Test", components=["copy-button"])
                assert "Copy Button Component" in html
                assert "copyToClipboard" in html
        
            def test_components_theme_toggle(self) -> None:
                html = assemble_template("report", "Test", components=["theme-toggle"])
                assert "Theme Toggle Component" in html
                assert "toggleTheme" in html
        
            def test_components_filter(self) -> None:
                html = assemble_template("data-viz", "Test", components=["filter"])
                assert "Filter Component" in html
                assert "setupFilter" in html
        
            def test_components_slider(self) -> None:
                html = assemble_template("prototype", "Test", components=["slider"])
                assert "Slider Component" in html
                assert 'input[type="range"]' in html
        
            def test_components_keyboard_nav(self) -> None:
                html = assemble_template("deck", "Test", components=["keyboard-nav"])
                assert "Keyboard Navigation Component" in html
                assert "setupKeyNav" in html
        
            def test_components_scrollytelling(self) -> None:
                html = assemble_template("report", "Test", components=["scrollytelling"])
                assert "Scrollytelling Component" in html
                assert ".reveal" in html
                assert "IntersectionObserver" in html  # JS injected
        
            # --- Shape CSS injection tests ---
        
            def test_shape_css_spec(self) -> None:
                html = assemble_template("spec", "Test")
                assert ".comparison-grid" in html
                assert ".approach-card" in html
        
            def test_shape_css_code_review(self) -> None:
                html = assemble_template("code-review", "Test")
                assert ".review-layout" in html
                assert ".diff-file" in html
        
            def test_shape_css_prototype(self) -> None:
                html = assemble_template("prototype", "Test")
                assert ".prototype-layout" in html
                assert ".controls-panel" in html
        
            def test_shape_css_report(self) -> None:
                html = assemble_template("report", "Test")
                assert ".tldr" in html
                assert ".metric-row" in html
        
            def test_shape_css_editor(self) -> None:
                html = assemble_template("editor", "Test")
                assert ".export-bar" in html
                assert ".kanban" in html
        
            def test_shape_css_data_viz(self) -> None:
                html = assemble_template("data-viz", "Test")
                assert ".chart" in html
                assert ".legend" in html
        
            def test_shape_css_diagram(self) -> None:
                html = assemble_template("diagram", "Test")
                assert ".diagram-container" in html
                assert ".figure-grid" in html
        
            def test_shape_css_deck(self) -> None:
                html = assemble_template("deck", "Test")
                assert ".slide-deck" in html
                assert ".progress-bar" in html
        
            # --- Base reset injection test ---
        
            def test_base_reset_always_included(self) -> None:
                html = assemble_template("spec", "Test")
                assert "box-sizing: border-box" in html
                assert "prefers-reduced-motion" in html
        
            # --- data-shape attribute tests ---
        
            @pytest.mark.parametrize(
                "shape",
                ["spec", "code-review", "prototype", "report", "editor", "data-viz", "diagram", "deck"],
            )
            def test_body_has_data_shape_attribute(self, shape: str) -> None:
                html = assemble_template(shape, "Test")
                assert f'<body data-shape="{shape}">' in html
        
            def test_data_shape_appears_exactly_once(self) -> None:
                html = assemble_template("report", "Test")
                # The literal <body> tag should be replaced; <body data-shape=...> appears once.
                assert html.count('<body data-shape="report">') == 1
                # Nothing should leave a bare <body> in place.
                assert "<body>" not in html
        
            # --- Per-shape print CSS injection tests ---
        
            @pytest.mark.parametrize(
                ("shape", "marker"),
                [
                    ("deck", "Deck Print Stylesheet"),
                    ("spec", "Spec Print Stylesheet"),
                    ("report", "Report Print Stylesheet"),
                    ("editor", "Editor Print Stylesheet"),
                    ("code-review", "Code Review Print Stylesheet"),
                    ("prototype", "Prototype Print Stylesheet"),
                    ("data-viz", "Data-Viz Print Stylesheet"),
                    ("diagram", "Diagram Print Stylesheet"),
                ],
            )
            def test_shape_print_css_injected(self, shape: str, marker: str) -> None:
                html = assemble_template(shape, "Test")
                assert marker in html
                assert "@media print" in html
        
            def test_print_css_fallback_to_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
                """When no shape-specific print CSS exists, fall back to default-print.css.
        
                Implementation detail: simulate by pointing _read_template at a fake
                shape that is otherwise valid. We accept a real shape but stub out
                the per-shape print read so the assembler must fall back.
                """
                # Save the real reader and stub it so the per-shape print read returns
                # nothing while every other read passes through.
                real_reader = assemble_mod._read_template
        
                def fake_reader(relpath: str) -> str:
                    if relpath.startswith("print/") and relpath != "print/default-print.css":
                        return ""  # force fallback
                    return real_reader(relpath)
        
                monkeypatch.setattr(assemble_mod, "_read_template", fake_reader)
                html = assemble_template("spec", "Test")
                assert "Default Print Stylesheet" in html
                # Sanity: the spec-print marker should NOT be present because we suppressed it.
                assert "Spec Print Stylesheet" not in html
        
        
        @pytest.mark.slow
        class TestCLIInterface:
            """Integration tests via subprocess."""
        
            def test_cli_basic(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Auth Comparison"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert "<title>Auth Comparison</title>" in proc.stdout
        
            def test_cli_with_theme(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Test", "--theme", "dark-focus"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert "Dark Focus Theme" in proc.stdout
        
            def test_cli_invalid_shape_exits_1(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "banana", "--title", "Test"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
        
            def test_cli_invalid_theme_exits_1(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "spec", "--title", "Test", "--theme", "neon"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
        
            def test_cli_output_is_complete_html(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "report", "--title", "Weekly Report"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert "<!DOCTYPE html>" in proc.stdout
                assert "</html>" in proc.stdout
        
            def test_cli_with_components(self) -> None:
                cmd = [
                    sys.executable,
                    SCRIPT,
                    "--shape",
                    "spec",
                    "--title",
                    "Test",
                    "--components",
                    "tabs,collapsible",
                ]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert "Tabs Component" in proc.stdout
                assert "Collapsible Component" in proc.stdout
        
            def test_cli_invalid_component_exits_1(self) -> None:
                cmd = [
                    sys.executable,
                    SCRIPT,
                    "--shape",
                    "spec",
                    "--title",
                    "Test",
                    "--components",
                    "nonexistent",
                ]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
        
            def test_cli_diagram_shape(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "diagram", "--title", "Architecture"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert ".diagram-container" in proc.stdout
        
            def test_cli_deck_shape(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "deck", "--title", "Slides"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert ".slide-deck" in proc.stdout
        
      • test_css_slop_emission.py 6.5 KB
        """Tests for the vendored slop scan wired into validate-artifact.py and the
        self-describing stamp emitted by assemble-template.py."""
        
        from __future__ import annotations
        
        import re
        import sys
        import tempfile
        from importlib import import_module
        from pathlib import Path
        
        sys.path.insert(0, str(Path(__file__).parent.parent))
        
        validate_mod = import_module("validate-artifact")
        validate_artifact = validate_mod.validate_artifact
        
        assemble_mod = import_module("assemble-template")
        assemble_template = assemble_mod.assemble_template
        
        slop = import_module("css_slop_rules")
        scan_css = slop.scan_css
        
        STAMP_RE = re.compile(r"/\* vexjoy-artifact: shape=(\S+) theme=(\S+) contrast=(pass|fail|n/a) \*/")
        
        # Clean CSS: no slop patterns.
        CLEAN_HTML = """<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Clean</title>
            <style>
            body { color: #111111; background-color: #ffffff; }
            .card { transition: opacity 150ms, transform 150ms; }
            h1 { color: #222222; }
            </style>
        </head>
        <body><h1>Hello</h1><p>content</p></body>
        </html>"""
        
        # Slop CSS: transition-all + gradient-text-headline on h1.
        SLOP_HTML = """<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Slop</title>
            <style>
            .card { transition: all 200ms ease; }
            h1 { background-clip: text; -webkit-background-clip: text; }
            </style>
        </head>
        <body><h1>Headline</h1><p>content</p></body>
        </html>"""
        
        
        def _write_tmp(content: str) -> Path:
            f = tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8")
            f.write(content)
            f.close()
            return Path(f.name)
        
        
        class TestSlopScanWiring:
            """The vendored scan_css fires on slop and stays silent on clean CSS."""
        
            def test_clean_css_no_slop_warnings(self) -> None:
                path = _write_tmp(CLEAN_HTML)
                try:
                    result = validate_artifact(path)
                    assert result.checks["css_slop_clean"] is True
                    assert not any(w.startswith("CSS slop") for w in result.warnings)
                    # Slop warnings are non-blocking — clean file is still valid.
                    assert result.valid
                finally:
                    path.unlink()
        
            def test_slop_css_emits_warnings(self) -> None:
                path = _write_tmp(SLOP_HTML)
                try:
                    result = validate_artifact(path)
                    assert result.checks["css_slop_clean"] is False
                    slop_warnings = [w for w in result.warnings if w.startswith("CSS slop")]
                    assert any("transition-all" in w for w in slop_warnings)
                    assert any("gradient-text-headline" in w for w in slop_warnings)
                finally:
                    path.unlink()
        
            def test_slop_is_non_blocking(self) -> None:
                # Structurally valid file with slop: warnings only, exit-equivalent valid.
                path = _write_tmp(SLOP_HTML)
                try:
                    result = validate_artifact(path)
                    assert result.valid
                finally:
                    path.unlink()
        
            def test_scan_shape_agnostic(self) -> None:
                # Same slop CSS flagged regardless of declared shape (report has no hero).
                path = _write_tmp(SLOP_HTML)
                try:
                    for shape in ("report", "data-viz", "spec", "deck"):
                        result = validate_artifact(path, shape=shape)
                        assert result.checks["css_slop_clean"] is False
                finally:
                    path.unlink()
        
            def test_oversized_file_still_scanned_fast(self) -> None:
                # The slop scanner is linear, so oversized files are scanned, not skipped.
                # Slop in an oversized file is still flagged; the size check fires
                # independently. Must complete quickly, not hang.
                import time
        
                path = _write_tmp(SLOP_HTML + ("x" * (501 * 1024)))
                try:
                    start = time.perf_counter()
                    result = validate_artifact(path)
                    assert time.perf_counter() - start < 1.0
                    assert result.checks["css_slop_clean"] is False
                    assert not result.checks["reasonable_size"]
                finally:
                    path.unlink()
        
            def test_oversized_single_line_does_not_hang(self) -> None:
                # Adversarial ~1MB minified single line: the old O(n^2) block regex hung.
                import time
        
                unit = '<div class="btn cta" style="color:#111;background:#222">x</div>'
                path = _write_tmp("<html><body>" + unit * 16000 + "</body></html>")
                try:
                    start = time.perf_counter()
                    validate_artifact(path)
                    assert time.perf_counter() - start < 1.0
                finally:
                    path.unlink()
        
        
        class TestVendoredModuleParity:
            """The vendored scan_css is the same engine as the source module."""
        
            def test_findings_match_direct_scan(self) -> None:
                findings = scan_css(SLOP_HTML)
                rule_ids = {f.rule_id for f in findings}
                assert "transition-all" in rule_ids
                assert "gradient-text-headline" in rule_ids
        
            def test_clean_css_no_findings(self) -> None:
                assert scan_css(CLEAN_HTML) == []
        
        
        class TestStampEmission:
            """assemble-template.py emits a parseable vexjoy-artifact stamp."""
        
            def test_stamp_present_and_parseable(self) -> None:
                html = assemble_template("spec", "Test")
                m = STAMP_RE.search(html)
                assert m is not None
                assert m.group(1) == "spec"
                assert m.group(2) == "birchline"
                assert m.group(3) == "n/a"
        
            def test_stamp_is_first_css_comment(self) -> None:
                html = assemble_template("report", "Test")
                style_open = html.index("<style>")
                first_comment = html.index("/*", style_open)
                assert html[first_comment:].startswith("/* vexjoy-artifact:")
        
            def test_stamp_reflects_theme_override(self) -> None:
                html = assemble_template("spec", "Test", theme="dark-focus")
                m = STAMP_RE.search(html)
                assert m is not None
                assert m.group(2) == "dark-focus"
        
            def test_stamp_per_shape_default_theme(self) -> None:
                html = assemble_template("code-review", "Test")
                m = STAMP_RE.search(html)
                assert m is not None
                assert m.group(1) == "code-review"
                assert m.group(2) == "dark-focus"
        
            def test_stamp_survives_validation_as_clean(self) -> None:
                # The stamp itself must not trip any slop rule.
                html = assemble_template("report", "Stamped")
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    stamp_warnings = [w for w in result.warnings if "vexjoy-artifact" in w]
                    assert stamp_warnings == []
                finally:
                    path.unlink()
        
      • test_detect_shape.py 5.7 KB
        """Tests for detect-shape.py — deterministic shape classifier."""
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "detect-shape.py")
        
        
        def run_detect(request: str, compact: bool = False) -> dict:
            """Run detect-shape.py and return parsed JSON."""
            cmd = [sys.executable, SCRIPT, "--request", request]
            if compact:
                cmd.append("--json-compact")
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            assert result.returncode == 0, f"Script failed: {result.stderr}"
            return json.loads(result.stdout)
        
        
        # --- Add the script's parent to sys.path so we can import directly ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        from importlib import import_module
        
        # import via importlib because filename has a hyphen
        detect_shape = import_module("detect-shape")
        classify_request = detect_shape.classify_request
        
        
        class TestClassifyRequestDirect:
            """Unit tests calling classify_request() directly."""
        
            def test_empty_request_returns_report_low(self) -> None:
                result = classify_request("")
                assert result["shape"] == "report"
                assert result["confidence"] == "low"
                assert result["signals"] == []
        
            def test_whitespace_only_returns_report_low(self) -> None:
                result = classify_request("   ")
                assert result["shape"] == "report"
                assert result["confidence"] == "low"
        
            def test_no_signals_returns_report_low(self) -> None:
                result = classify_request("hello world foo bar")
                assert result["shape"] == "report"
                assert result["confidence"] == "low"
        
            def test_spec_high_confidence(self) -> None:
                result = classify_request("explore 3 auth approaches and compare tradeoffs")
                assert result["shape"] == "spec"
                assert result["confidence"] == "high"
                assert "explore" in result["signals"]
                assert "compare" in result["signals"]
                assert "tradeoff" in [s.rstrip("s") for s in result["signals"]] or "tradeoff" in result["signals"]
        
            def test_code_review_signals(self) -> None:
                result = classify_request("review PR #42 and annotate the diff")
                assert result["shape"] == "code-review"
                assert result["confidence"] == "high"
        
            def test_prototype_signals(self) -> None:
                result = classify_request("build a prototype with slider controls and animation")
                assert result["shape"] == "prototype"
                assert result["confidence"] == "high"
        
            def test_report_signals(self) -> None:
                result = classify_request("summarize the weekly incident timeline")
                assert result["shape"] == "report"
                assert result["confidence"] == "high"
        
            def test_editor_signals(self) -> None:
                result = classify_request("triage and prioritize the backlog, flag urgent items")
                assert result["shape"] == "editor"
                assert result["confidence"] == "high"
        
            def test_data_viz_signals(self) -> None:
                result = classify_request("visualize the dashboard metrics and chart the trend")
                assert result["shape"] == "data-viz"
                assert result["confidence"] == "high"
        
            def test_medium_confidence(self) -> None:
                # Single primary signal = 2 points = medium
                result = classify_request("let's brainstorm")
                assert result["shape"] == "spec"
                assert result["confidence"] == "medium"
        
            def test_low_confidence_single_secondary(self) -> None:
                # Single secondary signal = 1 point = low
                result = classify_request("show me the pros and cons")
                assert result["shape"] == "spec"
                assert result["confidence"] == "low"
        
            def test_tie_breaking_editor_wins_over_spec(self) -> None:
                # Both editor and spec get same score -> editor wins (higher priority)
                result = classify_request("plan to reorder")
                assert result["shape"] == "editor"
        
            def test_tie_breaking_spec_wins_over_report(self) -> None:
                result = classify_request("explore the status")
                assert result["shape"] == "spec"
        
            def test_case_insensitive(self) -> None:
                result = classify_request("EXPLORE APPROACHES")
                assert result["shape"] == "spec"
        
            def test_markdown_mention_still_classifies(self) -> None:
                result = classify_request("explore approaches, output as markdown")
                assert result["shape"] == "spec"
        
            def test_deterministic_same_input_same_output(self) -> None:
                for _ in range(10):
                    r = classify_request("compare and explore tradeoffs")
                    assert r["shape"] == "spec"
                    assert r["confidence"] == "high"
        
            def test_secondary_signals_contribute(self) -> None:
                result = classify_request("write an implementation plan with design options")
                assert result["shape"] == "spec"
                # "plan" = 2pts primary + "implementation plan" = 1pt + "design options" = 1pt = 4 -> high
                assert result["confidence"] == "high"
        
        
        @pytest.mark.slow
        class TestCLIInterface:
            """Integration tests via subprocess."""
        
            def test_cli_basic(self) -> None:
                result = run_detect("explore approaches")
                assert result["shape"] == "spec"
        
            def test_cli_compact_json(self) -> None:
                cmd = [sys.executable, SCRIPT, "--request", "explore approaches", "--json-compact"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                output = proc.stdout.strip()
                # Compact JSON has no newlines inside the object
                assert "\n" not in output.rstrip("\n")
                parsed = json.loads(output)
                assert parsed["shape"] == "spec"
        
            def test_cli_empty_request(self) -> None:
                result = run_detect("")
                assert result["shape"] == "report"
                assert result["confidence"] == "low"
        
      • test_fill_template.py 5.2 KB
        """Tests for fill-template.py — deterministic saved-template clone-and-fill."""
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        import tempfile
        from importlib import import_module
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "fill-template.py")
        SAVED_DIR = Path(__file__).parent.parent.parent / "templates" / "saved"
        
        sys.path.insert(0, str(Path(__file__).parent.parent))
        fill_mod = import_module("fill-template")
        
        
        def _run(*args: str, slots: dict | None = None) -> subprocess.CompletedProcess:
            """Run the CLI, optionally writing slots to a temp JSON file first."""
            argv = [sys.executable, SCRIPT, *args]
            if slots is not None:
                f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
                json.dump(slots, f)
                f.close()
                argv += ["--slots", f.name]
            return subprocess.run(argv, capture_output=True, text=True)
        
        
        # --- gallery integrity: every shipped template is valid ---
        
        
        def test_all_shipped_templates_listed() -> None:
            """Every .html with a manifest appears in --list; the three we ship are present."""
            names = fill_mod.list_templates()
            for expected in ("business-review", "project-kickoff", "system-design"):
                assert expected in names
        
        
        def test_shipped_manifests_are_valid_json() -> None:
            for manifest in SAVED_DIR.glob("*.slots.json"):
                data = json.loads(manifest.read_text(encoding="utf-8"))
                assert "slots" in data and isinstance(data["slots"], list)
                for slot in data["slots"]:
                    assert "name" in slot
        
        
        def test_every_manifest_slot_marker_exists_in_html() -> None:
            """A declared slot must have a matching {{MARKER}} in the template body."""
            for manifest in SAVED_DIR.glob("*.slots.json"):
                name = manifest.stem.replace(".slots", "")
                html = (SAVED_DIR / f"{name}.html").read_text(encoding="utf-8")
                data = json.loads(manifest.read_text(encoding="utf-8"))
                for slot in data["slots"]:
                    assert f"{{{{{slot['name']}}}}}" in html, f"{name}: declared slot {slot['name']} has no marker in HTML"
        
        
        def test_every_html_marker_is_declared() -> None:
            """Every {{MARKER}} in a template must be declared in its manifest — no orphan markers."""
            for html_path in SAVED_DIR.glob("*.html"):
                manifest_path = SAVED_DIR / f"{html_path.stem}.slots.json"
                if not manifest_path.exists():
                    continue  # e.g. github-issues.html is a static artifact, not a slot template
                html = html_path.read_text(encoding="utf-8")
                data = json.loads(manifest_path.read_text(encoding="utf-8"))
                declared = {s["name"] for s in data["slots"]}
                markers = set(fill_mod.MARKER_RE.findall(html))
                orphan = markers - declared
                assert not orphan, f"{html_path.stem}: undeclared markers {orphan}"
        
        
        # --- fill_template() behaviour ---
        
        
        def _required(name: str) -> dict[str, str]:
            data = fill_mod.load_manifest(name)
            return {s["name"]: f"<p>{s['name']}</p>" for s in data["slots"] if s.get("required", True)}
        
        
        def test_fill_leaves_no_markers() -> None:
            for name in fill_mod.list_templates():
                html = fill_mod.fill_template(name, _required(name))
                assert not fill_mod.MARKER_RE.findall(html)
        
        
        def test_fill_substitutes_content() -> None:
            values = _required("business-review")
            values["TITLE"] = "Q3 Review UNIQUEMARKER"
            html = fill_mod.fill_template("business-review", values)
            assert "Q3 Review UNIQUEMARKER" in html
        
        
        def test_layout_is_preserved() -> None:
            """Clone mode must not alter chrome: the CSS stamp and structure survive verbatim."""
            html = fill_mod.fill_template("business-review", _required("business-review"))
            assert "vexjoy-artifact: shape=report theme=birchline" in html
            assert '<body data-shape="report">' in html
        
        
        def test_missing_required_slot_exits_1() -> None:
            r = _run("--template", "business-review", slots={"TITLE": "x"})
            assert r.returncode == 1
            assert "missing required slot" in r.stderr
        
        
        def test_unknown_slot_exits_1() -> None:
            values = _required("business-review")
            values["NOT_A_SLOT"] = "x"
            r = _run("--template", "business-review", slots=values)
            assert r.returncode == 1
            assert "not declared" in r.stderr
        
        
        def test_optional_slot_omitted_is_blank() -> None:
            """An omitted optional slot resolves to empty string, never a leftover marker."""
            values = _required("business-review")  # KICKER/META/FOOTER are optional, omitted
            html = fill_mod.fill_template("business-review", values)
            assert not fill_mod.MARKER_RE.findall(html)
        
        
        def test_unknown_template_exits_2() -> None:
            r = _run("--template", "does-not-exist", slots={"TITLE": "x"})
            assert r.returncode == 2
        
        
        def test_list_flag() -> None:
            r = _run("--list")
            assert r.returncode == 0
            assert "business-review" in r.stdout
        
        
        def test_bad_slots_json_exits_2() -> None:
            f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
            f.write("{not valid json")
            f.close()
            r = subprocess.run(
                [sys.executable, SCRIPT, "--template", "business-review", "--slots", f.name],
                capture_output=True,
                text=True,
            )
            assert r.returncode == 2
        
        
        if __name__ == "__main__":
            sys.exit(pytest.main([__file__, "-v"]))
        
      • test_generate_filename.py 4.4 KB
        """Tests for generate-filename.py — deterministic filename generator."""
        
        from __future__ import annotations
        
        import subprocess
        import sys
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "generate-filename.py")
        
        # --- Import module directly for unit tests ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        from importlib import import_module
        
        filename_mod = import_module("generate-filename")
        generate_filename = filename_mod.generate_filename
        
        
        class TestGenerateFilenameDirect:
            """Unit tests calling generate_filename() directly."""
        
            def test_basic_request(self) -> None:
                result = generate_filename("explore 3 approaches to rate limiting")
                # "explore" is verb prefix, "3" is numeric (not [a-z]+), "to" is stop word
                assert result == "approaches-rate-limiting.html"
        
            def test_stop_words_removed(self) -> None:
                result = generate_filename("the quick brown fox")
                assert result == "quick-brown-fox.html"
        
            def test_verb_prefixes_removed(self) -> None:
                result = generate_filename("create a dashboard widget")
                assert result == "dashboard-widget.html"
        
            def test_max_4_words(self) -> None:
                result = generate_filename("foo bar baz qux quux corge")
                assert result == "foo-bar-baz-qux.html"
        
            def test_empty_after_filtering_no_shape(self) -> None:
                result = generate_filename("explore the")
                assert result == "artifact.html"
        
            def test_empty_after_filtering_with_shape(self) -> None:
                result = generate_filename("explore the", shape="spec")
                assert result == "spec-artifact.html"
        
            def test_shape_prepended_when_not_in_words(self) -> None:
                result = generate_filename("rate limiting strategies", shape="spec")
                assert result == "spec-rate-limiting-strategies.html"
        
            def test_shape_not_prepended_when_already_present(self) -> None:
                # "data" appears as content word and is a part of "data-viz"
                result = generate_filename("data pipeline performance", shape="data-viz")
                assert result == "data-pipeline-performance.html"
        
            def test_case_insensitive(self) -> None:
                result = generate_filename("EXPLORE Rate LIMITING")
                assert result == "rate-limiting.html"
        
            def test_numbers_stripped(self) -> None:
                # re.findall(r"[a-z]+", ...) only matches letters
                result = generate_filename("explore 3 approaches")
                assert result == "approaches.html"
        
            def test_special_chars_ignored(self) -> None:
                result = generate_filename("auth: OAuth2 vs JWT!")
                assert result == "auth-oauth-vs-jwt.html"
        
            def test_deterministic_same_input_same_output(self) -> None:
                results = [generate_filename("compare auth approaches") for _ in range(10)]
                assert all(r == results[0] for r in results)
        
            def test_all_stop_words(self) -> None:
                result = generate_filename("the a an to for of in on at by")
                assert result == "artifact.html"
        
            def test_shape_code_review_prepended(self) -> None:
                result = generate_filename("auth module refactor", shape="code-review")
                assert result.startswith("code-review-")
                assert result.endswith(".html")
        
        
        @pytest.mark.slow
        class TestCLIInterface:
            """Integration tests via subprocess."""
        
            def test_cli_basic(self) -> None:
                cmd = [sys.executable, SCRIPT, "--request", "explore 3 approaches to rate limiting"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert proc.stdout.strip().endswith(".html")
        
            def test_cli_with_shape(self) -> None:
                cmd = [sys.executable, SCRIPT, "--request", "rate limiting strategies", "--shape", "spec"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert proc.stdout.strip().startswith("spec-")
        
            def test_cli_invalid_shape_exits_1(self) -> None:
                cmd = [sys.executable, SCRIPT, "--request", "test", "--shape", "banana"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
        
            def test_cli_output_has_no_extra_whitespace(self) -> None:
                cmd = [sys.executable, SCRIPT, "--request", "build a dashboard"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                output = proc.stdout.strip()
                assert " " not in output
                assert output.endswith(".html")
        
      • test_select_references.py 3.5 KB
        """Tests for select-references.py — deterministic reference file selector."""
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "select-references.py")
        
        # --- Import module directly for unit tests ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        from importlib import import_module
        
        select_mod = import_module("select-references")
        select_references = select_mod.select_references
        
        
        class TestSelectReferencesDirect:
            """Unit tests calling select_references() directly."""
        
            @pytest.mark.parametrize(
                "shape,expected_specific",
                [
                    ("spec", "references/shape-spec-exploration.md"),
                    ("code-review", "references/shape-code-review.md"),
                    ("prototype", "references/shape-design-prototype.md"),
                    ("report", "references/shape-report-research.md"),
                    ("editor", "references/shape-custom-editor.md"),
                    ("data-viz", "references/shape-data-visualization.md"),
                ],
            )
            def test_each_shape_returns_correct_specific(self, shape: str, expected_specific: str) -> None:
                result = select_references(shape)
                assert result["shape"] == shape
                assert result["shape_specific"] == [expected_specific]
        
            def test_always_load_present(self) -> None:
                result = select_references("spec")
                assert result["always_load"] == [
                    "references/design-system.md",
                    "references/interaction-patterns.md",
                ]
        
            def test_all_files_is_union(self) -> None:
                result = select_references("report")
                assert result["all_files"] == result["always_load"] + result["shape_specific"]
        
            def test_invalid_shape_raises(self) -> None:
                with pytest.raises(ValueError, match="Invalid shape"):
                    select_references("invalid")
        
            def test_deterministic_same_input_same_output(self) -> None:
                results = [select_references("data-viz") for _ in range(5)]
                assert all(r == results[0] for r in results)
        
        
        @pytest.mark.slow
        class TestCLIInterface:
            """Integration tests via subprocess."""
        
            def test_cli_valid_shape(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "spec"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                result = json.loads(proc.stdout)
                assert result["shape"] == "spec"
                assert len(result["all_files"]) == 3
        
            def test_cli_invalid_shape_exits_1(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "banana"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
        
            def test_cli_compact_json(self) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", "editor", "--json-compact"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                output = proc.stdout.strip()
                assert "\n" not in output.rstrip("\n")
                parsed = json.loads(output)
                assert parsed["shape"] == "editor"
        
            @pytest.mark.parametrize("shape", ["spec", "code-review", "prototype", "report", "editor", "data-viz"])
            def test_cli_all_shapes_succeed(self, shape: str) -> None:
                cmd = [sys.executable, SCRIPT, "--shape", shape]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                result = json.loads(proc.stdout)
                assert result["shape"] == shape
        
      • test_to_pdf.py 13.6 KB
        """Tests for to-pdf.py — Playwright-based PDF export for html-artifact."""
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        from importlib import import_module
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "to-pdf.py")
        
        # --- Import module directly for unit tests ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        to_pdf = import_module("to-pdf")
        
        
        def _playwright_runs() -> bool:
            """Returns True only if Playwright imports AND chromium can launch.
        
            Avoids the May 9 mistake of skipping based on Chrome binary existence
            (which lied) — actually launches a browser to confirm.
            """
            try:
                from playwright.sync_api import sync_playwright
        
                with sync_playwright() as p:
                    browser = p.chromium.launch()
                    browser.close()
                return True
            except Exception:
                return False
        
        
        # --- Sample HTML fragments for unit tests ---
        
        _HTML_DECK = """<!DOCTYPE html>
        <html><head><title>T</title>
        <style>
        .slide { width: 13.333in; height: 7.5in; padding: 0.5in; box-sizing: border-box;
          background: #0a0a0a; color: #f5f5f5; display: flex; align-items: center;
          justify-content: center; font: 96px/1.1 system-ui, sans-serif; }
        .slide h1 { margin: 0; }
        </style></head>
        <body data-shape="deck">
        <div class="slide"><h1>Slide One: Introduction</h1></div>
        <div class="slide"><h1>Slide Two: Body</h1></div>
        <div class="slide"><h1>Slide Three: Conclusion</h1></div>
        </body></html>
        """
        
        _HTML_SPEC = """<!DOCTYPE html>
        <html><head><title>T</title></head>
        <body data-shape="spec">
        <main><h1>Spec</h1><p>Body</p></main>
        </body></html>
        """
        
        _HTML_REPORT = """<!DOCTYPE html>
        <html><head><title>T</title></head>
        <body data-shape="report">
        <main><h1>Report</h1><p>Body</p></main>
        </body></html>
        """
        
        _HTML_NO_SHAPE = """<!DOCTYPE html>
        <html><head><title>T</title></head>
        <body><main><p>No shape</p></main></body></html>
        """
        
        _HTML_MALFORMED = "this is not html"
        
        
        class TestShapeDetection:
            """Unit: detect_shape() pulls the data-shape attribute from <body>."""
        
            def test_detect_deck(self) -> None:
                assert to_pdf.detect_shape(_HTML_DECK) == "deck"
        
            def test_detect_spec(self) -> None:
                assert to_pdf.detect_shape(_HTML_SPEC) == "spec"
        
            def test_detect_missing_returns_none(self) -> None:
                assert to_pdf.detect_shape(_HTML_NO_SHAPE) is None
        
            def test_detect_with_extra_attributes(self) -> None:
                html = '<body class="foo" data-shape="report" data-theme="light">'
                assert to_pdf.detect_shape(html) == "report"
        
            def test_detect_data_shape_first_attribute(self) -> None:
                html = '<body data-shape="diagram" class="x">'
                assert to_pdf.detect_shape(html) == "diagram"
        
            def test_detect_case_insensitive_tag(self) -> None:
                html = '<BODY data-shape="editor">'
                assert to_pdf.detect_shape(html) == "editor"
        
        
        class TestPageOptionsForShape:
            """Unit: shape → page-size mapping."""
        
            def test_deck_widescreen_landscape_no_margin(self) -> None:
                opts = to_pdf.page_options_for_shape("deck")
                assert opts["width"] == "13.333in"
                assert opts["height"] == "7.5in"
                assert opts["landscape"] is True
                assert opts["margin"]["top"] == "0"
                assert "format" not in opts
        
            def test_spec_letter_landscape(self) -> None:
                opts = to_pdf.page_options_for_shape("spec")
                assert opts["format"] == "Letter"
                assert opts["landscape"] is True
                assert opts["margin"]["top"] == "0.5in"
        
            def test_report_letter_portrait(self) -> None:
                opts = to_pdf.page_options_for_shape("report")
                assert opts["format"] == "Letter"
                assert opts["landscape"] is False
                assert opts["margin"]["top"] == "0.75in"
        
            def test_editor_letter_portrait(self) -> None:
                opts = to_pdf.page_options_for_shape("editor")
                assert opts["format"] == "Letter"
                assert opts["landscape"] is False
        
            @pytest.mark.parametrize("shape", ["spec", "code-review", "prototype", "data-viz", "diagram"])
            def test_letter_landscape_shapes(self, shape: str) -> None:
                opts = to_pdf.page_options_for_shape(shape)
                assert opts["format"] == "Letter"
                assert opts["landscape"] is True
        
            def test_unknown_shape_falls_back_to_default(self) -> None:
                opts = to_pdf.page_options_for_shape("not-a-shape")
                assert opts["format"] == "Letter"
                assert opts["landscape"] is False
        
            def test_none_falls_back_to_default(self) -> None:
                opts = to_pdf.page_options_for_shape(None)
                assert opts["format"] == "Letter"
                assert opts["landscape"] is False
        
            def test_returned_dict_is_independent_copy(self) -> None:
                a = to_pdf.page_options_for_shape("deck")
                b = to_pdf.page_options_for_shape("deck")
                a["margin"]["top"] = "2in"
                # b's margin should still be the documented default
                assert b["margin"]["top"] == "0"
        
        
        class TestSlideCount:
            """Unit: deck page_count derives from .slide elements."""
        
            def test_three_slides(self) -> None:
                assert to_pdf.count_slides(_HTML_DECK) == 3
        
            def test_zero_slides(self) -> None:
                assert to_pdf.count_slides(_HTML_REPORT) == 0
        
            def test_slide_among_other_classes(self) -> None:
                html = '<div class="slide active dark">A</div><div class="card slide">B</div>'
                assert to_pdf.count_slides(html) == 2
        
            def test_slide_substring_does_not_match(self) -> None:
                html = '<div class="slideshow">A</div>'
                assert to_pdf.count_slides(html) == 0
        
            def test_slide_deck_wrapper_does_not_count(self) -> None:
                # `.slide-deck` is the wrapper element; only its `.slide` children should count.
                html = (
                    '<div class="slide-deck">'
                    '<div class="slide active">A</div>'
                    '<div class="slide">B</div>'
                    '<div class="slide-nav">x</div>'
                    "</div>"
                )
                assert to_pdf.count_slides(html) == 2
        
        
        class TestValidateHtml:
            """Unit: structural sanity check before launching browser."""
        
            def test_valid_html_returns_none(self) -> None:
                assert to_pdf.validate_html(_HTML_SPEC) is None
        
            def test_empty_string_errors(self) -> None:
                assert to_pdf.validate_html("") is not None
                assert to_pdf.validate_html("   \n  ") is not None
        
            def test_no_html_tag_errors(self) -> None:
                assert to_pdf.validate_html(_HTML_MALFORMED) is not None
        
            def test_no_body_tag_errors(self) -> None:
                assert to_pdf.validate_html("<html><head><title>T</title></head></html>") is not None
        
        
        class TestCLIBehavior:
            """Subprocess tests for the CLI surface (no browser interaction)."""
        
            def test_missing_input_exits_1(self, tmp_path: Path) -> None:
                out = tmp_path / "out.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(tmp_path / "missing.html"), "--output", str(out)]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
                assert "input file not found" in proc.stderr
        
            def test_malformed_html_exits_1(self, tmp_path: Path) -> None:
                bad = tmp_path / "bad.html"
                bad.write_text("not valid html", encoding="utf-8")
                out = tmp_path / "out.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(bad), "--output", str(out)]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
                assert "Error" in proc.stderr
        
            def test_missing_data_shape_with_no_flag_exits_1(self, tmp_path: Path) -> None:
                src = tmp_path / "noshape.html"
                src.write_text(_HTML_NO_SHAPE, encoding="utf-8")
                out = tmp_path / "out.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out)]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 1
                assert "data-shape" in proc.stderr
        
            def test_invalid_shape_arg_exits_2(self, tmp_path: Path) -> None:
                src = tmp_path / "x.html"
                src.write_text(_HTML_SPEC, encoding="utf-8")
                out = tmp_path / "out.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out), "--shape", "banana"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                # argparse choices rejection exits 2
                assert proc.returncode == 2
        
            def test_help_exits_0(self) -> None:
                cmd = [sys.executable, SCRIPT, "--help"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                assert proc.returncode == 0
                assert "--input" in proc.stdout
                assert "--output" in proc.stdout
        
        
        class TestJsonOutputContract:
            """JSON output schema tests — verified through unit-level helpers."""
        
            def test_default_page_returned_for_unknown_shape(self) -> None:
                opts = to_pdf.page_options_for_shape("phantom-shape")
                assert opts == to_pdf.DEFAULT_PAGE
                # Defensive: returned dict is a copy
                opts["format"] = "A4"
                assert to_pdf.DEFAULT_PAGE["format"] == "Letter"
        
        
        class TestInstallHintRouting:
            """Regression: missing chromium binary must surface install hint, not opaque error."""
        
            def test_missing_browser_exits_2_with_hint(self, tmp_path: Path, monkeypatch) -> None:
                # Simulate the "Executable doesn't exist" error Playwright raises when
                # the python package is installed but `playwright install chromium` was skipped.
                src = tmp_path / "x.html"
                src.write_text(_HTML_SPEC, encoding="utf-8")
                out = tmp_path / "out.pdf"
        
                def _raise_missing_binary(*_a: object, **_kw: object) -> None:
                    raise RuntimeError(
                        "Executable doesn't exist at /tmp/fake/chrome\n"
                        "Looks like Playwright was just installed or updated.\n"
                        "Please run the following command to download new browsers:\n"
                        "    playwright install"
                    )
        
                monkeypatch.setattr(to_pdf, "render_pdf", _raise_missing_binary)
                rc = to_pdf.main(["--input", str(src), "--output", str(out)])
                assert rc == 2
        
            def test_other_runtime_errors_still_exit_3(self, tmp_path: Path, monkeypatch) -> None:
                src = tmp_path / "x.html"
                src.write_text(_HTML_SPEC, encoding="utf-8")
                out = tmp_path / "out.pdf"
        
                def _raise_other(*_a: object, **_kw: object) -> None:
                    raise RuntimeError("Page navigation timed out after 30000ms")
        
                monkeypatch.setattr(to_pdf, "render_pdf", _raise_other)
                rc = to_pdf.main(["--input", str(src), "--output", str(out)])
                assert rc == 3
        
        
        class TestPathHandling:
            """Regression: file:// URLs must escape spaces and unicode per RFC 8089."""
        
            def test_path_with_spaces_uses_as_uri(self, tmp_path: Path) -> None:
                # pathlib.as_uri() encodes spaces as %20; raw f-string concat doesn't.
                spaced = tmp_path / "with spaces"
                spaced.mkdir()
                src = spaced / "art.html"
                src.write_text(_HTML_SPEC, encoding="utf-8")
                # Confirm the URI form Playwright will receive is properly encoded.
                uri = src.resolve().as_uri()
                assert "%20" in uri
                assert "file://" in uri
        
        
        # --- Integration tests (browser-required) ---
        
        
        @pytest.mark.skipif(
            not _playwright_runs(),
            reason="Playwright not installed or chromium unavailable",
        )
        class TestPdfIntegration:
            """Generate real PDFs for each documented page-size class."""
        
            def _write_html(self, tmp_path: Path, html: str, name: str) -> Path:
                p = tmp_path / name
                p.write_text(html, encoding="utf-8")
                return p
        
            def test_generate_deck_pdf_with_slide_count(self, tmp_path: Path) -> None:
                src = self._write_html(tmp_path, _HTML_DECK, "deck.html")
                out = tmp_path / "deck.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out), "--json"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
                assert proc.returncode == 0, proc.stderr
                result = json.loads(proc.stdout)
                assert result["shape"] == "deck"
                assert result["page_count"] == 3
                assert out.is_file()
                assert out.stat().st_size > 10_000
        
            def test_generate_spec_pdf(self, tmp_path: Path) -> None:
                src = self._write_html(tmp_path, _HTML_SPEC, "spec.html")
                out = tmp_path / "spec.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out), "--json"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
                assert proc.returncode == 0, proc.stderr
                result = json.loads(proc.stdout)
                assert result["shape"] == "spec"
                assert out.stat().st_size > 10_000
        
            def test_generate_report_pdf(self, tmp_path: Path) -> None:
                src = self._write_html(tmp_path, _HTML_REPORT, "report.html")
                out = tmp_path / "report.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out), "--json"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
                assert proc.returncode == 0, proc.stderr
                result = json.loads(proc.stdout)
                assert result["shape"] == "report"
                assert out.stat().st_size > 10_000
        
            def test_shape_flag_override(self, tmp_path: Path) -> None:
                # data-shape says "report"; CLI flag says "spec" — flag wins.
                src = self._write_html(tmp_path, _HTML_REPORT, "src.html")
                out = tmp_path / "out.pdf"
                cmd = [sys.executable, SCRIPT, "--input", str(src), "--output", str(out), "--shape", "spec", "--json"]
                proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
                assert proc.returncode == 0, proc.stderr
                result = json.loads(proc.stdout)
                assert result["shape"] == "spec"
        
      • test_validate_artifact.py 11.4 KB
        """Tests for validate-artifact.py — HTML artifact validator."""
        
        from __future__ import annotations
        
        import json
        import subprocess
        import sys
        import tempfile
        from pathlib import Path
        
        import pytest
        
        SCRIPT = str(Path(__file__).parent.parent / "validate-artifact.py")
        
        # --- Import module directly for unit tests ---
        sys.path.insert(0, str(Path(__file__).parent.parent))
        from importlib import import_module
        
        validate_mod = import_module("validate-artifact")
        validate_artifact = validate_mod.validate_artifact
        
        VALID_HTML = """<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Test Artifact</title>
            <style>body { margin: 0; }</style>
        </head>
        <body>
            <h1>Hello</h1>
        </body>
        </html>"""
        
        VALID_HTML_WITH_COPY = """<!DOCTYPE html>
        <html lang="en">
        <head>
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Editor</title>
            <style>body { margin: 0; }</style>
        </head>
        <body>
            <h1>Editor</h1>
            <script>
            function copyToClipboard(text) { navigator.clipboard.writeText(text); }
            </script>
        </body>
        </html>"""
        
        MINIMAL_VALID = """<!DOCTYPE html>
        <html>
        <head><title>X</title><style>*{}</style></head>
        <body><p>content</p></body>
        </html>"""
        
        
        def _write_tmp(content: str) -> Path:
            """Write content to a temp .html file and return its path."""
            f = tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8")
            f.write(content)
            f.close()
            return Path(f.name)
        
        
        def run_validate(file_path: str, compact: bool = False) -> tuple[dict, int]:
            """Run validate-artifact.py and return (parsed JSON, exit code)."""
            cmd = [sys.executable, SCRIPT, file_path]
            if compact:
                cmd.append("--json-compact")
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            parsed = json.loads(result.stdout)
            return parsed, result.returncode
        
        
        class TestValidateArtifactDirect:
            """Unit tests calling validate_artifact() directly."""
        
            def test_valid_html_passes_all(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path)
                    assert result.valid
                    assert all(result.checks.values())
                    assert result.errors == []
                    assert result.warnings == []
                finally:
                    path.unlink()
        
            def test_missing_doctype(self) -> None:
                html = "<html><head><title>T</title><style>*{}</style></head><body><p>x</p></body></html>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["has_doctype"]
                    assert not result.valid
                finally:
                    path.unlink()
        
            def test_missing_title(self) -> None:
                html = "<!DOCTYPE html><html><head><style>*{}</style></head><body><p>x</p></body></html>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["has_title"]
                    assert not result.valid
                finally:
                    path.unlink()
        
            def test_empty_title(self) -> None:
                html = "<!DOCTYPE html><html><head><title>  </title><style>*{}</style></head><body><p>x</p></body></html>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["has_title"]
                finally:
                    path.unlink()
        
            def test_external_css_fails_self_contained(self) -> None:
                html = (
                    "<!DOCTYPE html><html><head><title>T</title>"
                    '<link rel="stylesheet" href="https://cdn.example.com/style.css">'
                    "<style>*{}</style></head><body><p>x</p></body></html>"
                )
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["self_contained"]
                    assert any("external CSS" in e for e in result.errors)
                finally:
                    path.unlink()
        
            def test_external_js_fails_self_contained(self) -> None:
                html = (
                    "<!DOCTYPE html><html><head><title>T</title><style>*{}</style></head>"
                    '<body><p>x</p><script src="https://cdn.example.com/app.js"></script></body></html>'
                )
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["self_contained"]
                    assert any("external JS" in e for e in result.errors)
                finally:
                    path.unlink()
        
            def test_no_style_is_warning(self) -> None:
                html = "<!DOCTYPE html><html><head><title>T</title></head><body><p>x</p></body></html>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["has_style"]
                    # has_style is a warning, not an error — so valid can still be True
                    assert any("style" in w.lower() for w in result.warnings)
                finally:
                    path.unlink()
        
            def test_missing_viewport_is_warning(self) -> None:
                path = _write_tmp(MINIMAL_VALID)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["has_meta_viewport"]
                    assert any("viewport" in w for w in result.warnings)
                    # Warnings don't cause failure
                    assert result.valid
                finally:
                    path.unlink()
        
            def test_empty_body(self) -> None:
                html = "<!DOCTYPE html><html><head><title>T</title><style>*{}</style></head><body>   </body></html>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["no_empty_body"]
                    assert not result.valid
                finally:
                    path.unlink()
        
            def test_missing_structure_tags(self) -> None:
                html = "<!DOCTYPE html><title>T</title><style>*{}</style><p>content</p>"
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["valid_structure"]
                    assert any("Missing structural tags" in e for e in result.errors)
                finally:
                    path.unlink()
        
            def test_large_file_warns(self) -> None:
                # Create a file just over 500KB
                html = VALID_HTML + ("x" * (501 * 1024))
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path)
                    assert not result.checks["reasonable_size"]
                    assert any("500KB" in w for w in result.warnings)
                finally:
                    path.unlink()
        
            def test_to_dict_structure(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path)
                    d = result.to_dict()
                    assert "valid" in d
                    assert "checks" in d
                    assert "warnings" in d
                    assert "errors" in d
                finally:
                    path.unlink()
        
            def test_deterministic_same_input_same_output(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    results = [validate_artifact(path).to_dict() for _ in range(5)]
                    assert all(r == results[0] for r in results)
                finally:
                    path.unlink()
        
            def test_export_button_check_skipped_without_shape(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path)
                    assert "has_export_button" not in result.checks
                finally:
                    path.unlink()
        
            def test_export_button_check_skipped_for_non_export_shape(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path, shape="spec")
                    assert "has_export_button" not in result.checks
                finally:
                    path.unlink()
        
            def test_export_button_warning_for_editor_without_copy(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path, shape="editor")
                    assert not result.checks["has_export_button"]
                    assert any("copy/export" in w for w in result.warnings)
                    # Warning, not error — still valid
                    assert result.valid
                finally:
                    path.unlink()
        
            def test_export_button_warning_for_prototype_without_copy(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result = validate_artifact(path, shape="prototype")
                    assert not result.checks["has_export_button"]
                    assert any("copy/export" in w for w in result.warnings)
                finally:
                    path.unlink()
        
            def test_export_button_passes_with_clipboard(self) -> None:
                path = _write_tmp(VALID_HTML_WITH_COPY)
                try:
                    result = validate_artifact(path, shape="editor")
                    assert result.checks["has_export_button"]
                    assert not any("copy/export" in w for w in result.warnings)
                finally:
                    path.unlink()
        
            def test_export_button_passes_with_copy_word(self) -> None:
                html = VALID_HTML.replace("</body>", "<script>function copy() {}</script></body>")
                path = _write_tmp(html)
                try:
                    result = validate_artifact(path, shape="prototype")
                    assert result.checks["has_export_button"]
                finally:
                    path.unlink()
        
        
        @pytest.mark.slow
        class TestCLIInterface:
            """Integration tests via subprocess."""
        
            def test_cli_valid_file(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result, code = run_validate(str(path))
                    assert code == 0
                    assert result["valid"] is True
                finally:
                    path.unlink()
        
            def test_cli_invalid_file(self) -> None:
                path = _write_tmp("<p>not valid html</p>")
                try:
                    result, code = run_validate(str(path))
                    assert code == 1
                    assert result["valid"] is False
                finally:
                    path.unlink()
        
            def test_cli_file_not_found(self) -> None:
                result, code = run_validate("/nonexistent/file.html")
                assert code == 2
                assert result["valid"] is False
                assert any("not found" in e.lower() for e in result["errors"])
        
            def test_cli_compact_json(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    cmd = [sys.executable, SCRIPT, str(path), "--json-compact"]
                    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                    output = proc.stdout.strip()
                    assert "\n" not in output.rstrip("\n")
                    parsed = json.loads(output)
                    assert parsed["valid"] is True
                finally:
                    path.unlink()
        
            def test_cli_shape_flag_editor(self) -> None:
                path = _write_tmp(VALID_HTML)
                try:
                    result, code = run_validate(str(path))
                    assert "has_export_button" not in result["checks"]
        
                    # Now with --shape editor
                    cmd = [sys.executable, SCRIPT, str(path), "--shape", "editor"]
                    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                    shaped_result = json.loads(proc.stdout)
                    assert "has_export_button" in shaped_result["checks"]
                    assert shaped_result["checks"]["has_export_button"] is False
                finally:
                    path.unlink()
        
            def test_cli_shape_flag_with_copy(self) -> None:
                path = _write_tmp(VALID_HTML_WITH_COPY)
                try:
                    cmd = [sys.executable, SCRIPT, str(path), "--shape", "editor"]
                    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
                    assert proc.returncode == 0
                    result = json.loads(proc.stdout)
                    assert result["checks"]["has_export_button"] is True
                finally:
                    path.unlink()
        
    • assemble-template.py 7.2 KB
      #!/usr/bin/env python3
      """Deterministic HTML template assembler for html-artifact skill.
      
      Given a shape, title, and optional components, reads CSS/JS template files
      from the templates/ directory and injects them into the base HTML template.
      
      Exit codes:
          0: template assembled successfully
          1: invalid shape, theme, or component
          2: base template not found
      
      Usage:
          python3 skills/meta/html-artifact/scripts/assemble-template.py --shape spec --title "Auth Comparison"
          python3 skills/meta/html-artifact/scripts/assemble-template.py --shape spec --title "Test" --components tabs,collapsible
          python3 skills/meta/html-artifact/scripts/assemble-template.py --shape code-review --title "PR #42" --theme dark-focus
      """
      
      from __future__ import annotations
      
      import argparse
      import sys
      from pathlib import Path
      
      VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz", "diagram", "deck")
      VALID_THEMES = ("birchline", "dark-focus", "interactive-warm", "minimal-document")
      VALID_COMPONENTS = (
          "tabs",
          "collapsible",
          "drag-drop",
          "copy-button",
          "keyboard-nav",
          "theme-toggle",
          "filter",
          "slider",
          "scrollytelling",
      )
      
      SHAPE_DEFAULT_THEME: dict[str, str] = {
          "spec": "birchline",
          "code-review": "dark-focus",
          "prototype": "interactive-warm",
          "report": "birchline",
          "editor": "interactive-warm",
          "data-viz": "dark-focus",
          "diagram": "dark-focus",
          "deck": "dark-focus",
      }
      
      TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
      BASE_TEMPLATE_PATH = Path(__file__).parent.parent / "assets" / "base-template.html"
      
      
      def _read_template(relpath: str) -> str:
          """Read a template file, returning empty string if not found."""
          p = TEMPLATES_DIR / relpath
          if p.exists():
              return p.read_text(encoding="utf-8")
          return ""
      
      
      def assemble_template(
          shape: str,
          title: str,
          theme: str | None = None,
          components: list[str] | None = None,
      ) -> str:
          """Assemble an HTML template with theme, shape, and component CSS/JS.
      
          Args:
              shape: One of the valid artifact shapes.
              title: The title to inject into the template.
              theme: Optional theme override. Defaults to shape-specific theme.
              components: Optional list of component names to inject.
      
          Returns:
              The assembled HTML string.
      
          Raises:
              ValueError: If shape, theme, or component is invalid.
              FileNotFoundError: If base template is missing.
          """
          if shape not in VALID_SHAPES:
              raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}")
      
          resolved_theme = theme if theme is not None else SHAPE_DEFAULT_THEME[shape]
          if resolved_theme not in VALID_THEMES:
              raise ValueError(f"Invalid theme '{resolved_theme}'. Valid themes: {', '.join(VALID_THEMES)}")
      
          if components:
              invalid = [c for c in components if c not in VALID_COMPONENTS]
              if invalid:
                  raise ValueError(
                      f"Invalid component(s): {', '.join(invalid)}. Valid components: {', '.join(VALID_COMPONENTS)}"
                  )
      
          template = BASE_TEMPLATE_PATH.read_text(encoding="utf-8")
      
          # Inject title
          html = template.replace("<!-- TITLE -->", title)
      
          # Tag the body with the shape so downstream tools (e.g. to-pdf.py) can
          # detect it without re-running shape classification.
          html = html.replace("<body>", f'<body data-shape="{shape}">', 1)
      
          # Build CSS injection: stamp + reset + theme + shape + components
          css_parts: list[str] = []
      
          # 0. Self-describing stamp — first CSS comment in the output. Lets a later run
          #    re-audit the build statelessly (shape/theme recovery). contrast=n/a because
          #    the assembler runs no WCAG check; a contrast-aware caller may overwrite it.
          css_parts.append(f"/* vexjoy-artifact: shape={shape} theme={resolved_theme} contrast=n/a */")
      
          # 1. Base reset
          reset_css = _read_template("base-reset.css")
          if reset_css:
              css_parts.append(reset_css)
      
          # 2. Theme CSS (replaces the base template's Birchline tokens)
          theme_css = _read_template(f"themes/{resolved_theme}.css")
          if theme_css:
              css_parts.append(theme_css)
      
          # 3. Shape-specific CSS
          shape_css = _read_template(f"shapes/{shape}.css")
          if shape_css:
              css_parts.append(shape_css)
      
          # 4. Print CSS — per-shape, with default-print.css fallback. Files self-declare
          #    @page and @media print rules; injected as-is (no double-wrapping).
          print_css = _read_template(f"print/{shape}-print.css")
          if not print_css:
              print_css = _read_template("print/default-print.css")
          if print_css:
              css_parts.append(print_css)
      
          # 5. Component CSS
          if components:
              for comp in components:
                  comp_css = _read_template(f"components/{comp}.css")
                  if comp_css:
                      css_parts.append(comp_css)
      
          # Build JS injection: components only
          js_parts: list[str] = []
          if components:
              for comp in components:
                  comp_js = _read_template(f"components/{comp}.js")
                  if comp_js:
                      js_parts.append(comp_js)
      
          # Inject CSS after the STYLES placeholder
          if css_parts:
              css_block = "\n".join(css_parts)
              html = html.replace(
                  "/* <!-- STYLES --> */",
                  css_block + "\n    /* <!-- STYLES --> */",
              )
      
          # Inject JS after the SCRIPTS placeholder
          if js_parts:
              js_block = "\n".join(js_parts)
              html = html.replace(
                  "/* <!-- SCRIPTS --> */",
                  js_block + "\n    /* <!-- SCRIPTS --> */",
              )
      
          return html
      
      
      def main() -> None:
          """CLI entry point."""
          parser = argparse.ArgumentParser(description="Assemble an HTML artifact template.")
          parser.add_argument("--shape", required=True, help="Artifact shape.")
          parser.add_argument("--title", required=True, help="Title for the artifact.")
          parser.add_argument(
              "--theme",
              default=None,
              help="Theme override (birchline, dark-focus, interactive-warm, minimal-document).",
          )
          parser.add_argument(
              "--components",
              default=None,
              help="Comma-separated component names to inject (tabs, collapsible, drag-drop, etc.).",
          )
          args = parser.parse_args()
      
          if args.shape not in VALID_SHAPES:
              sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n")
              sys.exit(1)
      
          if args.theme is not None and args.theme not in VALID_THEMES:
              sys.stderr.write(f"Error: Invalid theme '{args.theme}'. Valid themes: {', '.join(VALID_THEMES)}\n")
              sys.exit(1)
      
          comp_list: list[str] | None = None
          if args.components:
              comp_list = [c.strip() for c in args.components.split(",") if c.strip()]
              invalid = [c for c in comp_list if c not in VALID_COMPONENTS]
              if invalid:
                  sys.stderr.write(
                      f"Error: Invalid component(s): {', '.join(invalid)}. Valid: {', '.join(VALID_COMPONENTS)}\n"
                  )
                  sys.exit(1)
      
          try:
              html = assemble_template(args.shape, args.title, args.theme, comp_list)
          except FileNotFoundError:
              sys.stderr.write(f"Error: Base template not found at {BASE_TEMPLATE_PATH}\n")
              sys.exit(2)
      
          sys.stdout.write(html)
      
      
      if __name__ == "__main__":
          main()
      
    • css_slop_rules.py 12.7 KB
      #!/usr/bin/env python3
      # Vendored verbatim from skills/frontend/distinctive-frontend-design/scripts/css_slop_rules.py — keep in sync.
      """Canonical rendered-CSS slop rules. Self-contained, dependency-free.
      
      Public surface:
          scan_css(css_text: str) -> list[Finding]
          Finding(rule_id, severity, message, line)
      
      Deterministic regex/parse only — no LLM. Designed to be vendored as-is into
      other skills (e.g. html-artifact). Findings start at "warning" severity; the
      promote-to-error path is documented per rule below and gated by the caller.
      
      Rules:
          transition-all          `transition: all` (shorthand spanning all properties)
          universal-hover-scale   :hover whose only effect is a broad transform: scale()
          gradient-text-headline  background-clip:text / -webkit-background-clip:text on h1/h2
          focus-ring-fade         focus outline/ring animated via transition (fades in)
          emoji-feature-icon      emoji codepoint as a feature/list icon (CSS content or markup)
          two-line-cta            clickable/button text that wraps to two lines (heuristic)
          contrast-canary         adjacent fg/bg within delta-L <= 0.05 AND delta-chroma <= 0.05
      """
      
      from __future__ import annotations
      
      import math
      import re
      from dataclasses import dataclass
      
      
      @dataclass(frozen=True)
      class Finding:
          """One slop-rule hit.
      
          rule_id:  stable identifier (see module docstring table).
          severity: "warning" for every rule today; callers promote to "error".
          message:  human-readable explanation and fix direction.
          line:     1-based best-effort source line.
          """
      
          rule_id: str
          severity: str
          message: str
          line: int
      
      
      # Contrast-canary thresholds (oklch space): treat as too-low-contrast when BOTH hold.
      _CONTRAST_DELTA_L = 0.05  # lightness L is 0..1; <=5% apart
      _CONTRAST_DELTA_C = 0.05  # chroma
      
      _EMOJI_RE = re.compile(
          "["
          "\U0001f300-\U0001faff"  # symbols, pictographs, emoji
          "\U00002600-\U000027bf"  # misc symbols + dingbats
          "\U0001f000-\U0001f0ff"  # mahjong/dominoes/cards
          "\U00002190-\U000021ff"  # arrows (often used as icons)
          "\U00002b00-\U00002bff"  # misc symbols and arrows (stars, checks)
          "\U0000fe0f"  # variation selector-16
          "]"
      )
      
      
      def _line_of(text: str, index: int) -> int:
          """Return the 1-based line number for a character offset."""
          return text.count("\n", 0, index) + 1
      
      
      # --- color parsing (hex + oklch) → (L, chroma) in oklch space ---
      
      
      def _parse_hex(token: str) -> tuple[float, float, float] | None:
          """Parse #rgb / #rrggbb (alpha ignored) → sRGB 0..1 triple."""
          h = token.lstrip("#")
          if len(h) in (3, 4):
              h = "".join(c * 2 for c in h[:3])
          elif len(h) in (6, 8):
              h = h[:6]
          else:
              return None
          try:
              r = int(h[0:2], 16) / 255.0
              g = int(h[2:4], 16) / 255.0
              b = int(h[4:6], 16) / 255.0
          except ValueError:
              return None
          return (r, g, b)
      
      
      def _srgb_to_linear(c: float) -> float:
          return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
      
      
      def _srgb_to_oklch_lc(rgb: tuple[float, float, float]) -> tuple[float, float]:
          """sRGB triple → (L, chroma) in OKLCH. Hue is dropped (not needed here)."""
          r, g, b = (_srgb_to_linear(c) for c in rgb)
          lm = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
          mm = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
          sm = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
          l_ = lm ** (1 / 3)
          m_ = mm ** (1 / 3)
          s_ = sm ** (1 / 3)
          big_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
          a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
          b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
          chroma = math.hypot(a, b2)
          return (big_l, chroma)
      
      
      _OKLCH_RE = re.compile(r"oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+", re.IGNORECASE)
      
      
      def _parse_oklch(token: str) -> tuple[float, float] | None:
          """Parse oklch(L C H ...) → (L, chroma). L may be a percentage."""
          m = _OKLCH_RE.search(token)
          if not m:
              return None
          raw_l, raw_c = m.group(1), m.group(2)
          try:
              big_l = float(raw_l[:-1]) / 100.0 if raw_l.endswith("%") else float(raw_l)
              chroma = float(raw_c)
          except ValueError:
              return None
          return (big_l, chroma)
      
      
      def _color_to_lc(value: str) -> tuple[float, float] | None:
          """Resolve a single CSS color value to (L, chroma) in oklch space."""
          value = value.strip()
          if value.lower().startswith("oklch("):
              return _parse_oklch(value)
          hexmatch = re.search(r"#[0-9a-fA-F]{3,8}\b", value)
          if hexmatch:
              rgb = _parse_hex(hexmatch.group(0))
              if rgb is not None:
                  return _srgb_to_oklch_lc(rgb)
          return None
      
      
      # --- rule scanners ---
      
      # Anchored on the literal "{" so finditer scans directly to each brace instead
      # of trying "[^{}]*" from every offset. The old shape r"([^{}]*)\{([^{}]*)\}"
      # was O(n^2) on large brace-free input (e.g. a ~500KB minified single line): the
      # pre-brace run succeeded to end-of-string, the "\{" failed, and the engine
      # retried from the next start position. This form is linear. The selector (the
      # pre-brace text) is recovered by slicing from the previous block's close brace.
      _BLOCK_RE = re.compile(r"\{([^{}]*)\}", re.DOTALL)
      
      
      def _iter_blocks(css: str):
          """Yield (selector, body, body_offset) for each top-level rule block.
      
          Behavior matches the prior "([^{}]*)\\{([^{}]*)\\}" form: the selector is the
          run of non-brace text immediately preceding the block.
          """
          last_close = 0
          for m in _BLOCK_RE.finditer(css):
              gap = css[last_close : m.start()]
              cut = max(gap.rfind("{"), gap.rfind("}"))
              selector = (gap[cut + 1 :] if cut != -1 else gap).strip()
              yield selector, m.group(1), m.start(1)
              last_close = m.end()
      
      
      def _decls(body: str) -> list[tuple[str, str]]:
          """Split a declaration block into (property, value) pairs (lowercased prop)."""
          out: list[tuple[str, str]] = []
          for chunk in body.split(";"):
              if ":" in chunk:
                  prop, _, val = chunk.partition(":")
                  out.append((prop.strip().lower(), val.strip()))
          return out
      
      
      def _scan_transition_all(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for m in re.finditer(r"transition(?:-property)?\s*:\s*([^;}]*)", css, re.IGNORECASE):
              if re.match(r"all\b", m.group(1).strip(), re.IGNORECASE):
                  findings.append(
                      Finding(
                          "transition-all",
                          "warning",
                          "transition: all animates every property; name the properties you "
                          "actually change (e.g. transition: opacity, transform).",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_universal_hover_scale(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              if ":hover" not in selector.lower():
                  continue
              decls = _decls(body)
              effective = [(p, v) for p, v in decls if p and v]
              if len(effective) != 1:
                  continue
              prop, val = effective[0]
              if prop == "transform" and re.match(r"scale\(\s*1(\.0\d*)?\s*\)", val, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "universal-hover-scale",
                          "warning",
                          "the only hover effect is a small transform: scale(); a uniform "
                          "scale-on-hover reads as templated. Give hover a purposeful change.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_gradient_text_headline(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              sel = selector.lower()
              if not re.search(r"\bh[12]\b", sel):
                  continue
              if re.search(r"(?:-webkit-)?background-clip\s*:\s*text", body, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "gradient-text-headline",
                          "warning",
                          "gradient-clipped headline text (background-clip: text on h1/h2) is a "
                          "signature template look; use a solid headline color.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_focus_ring_fade(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for selector, body, offset in _iter_blocks(css):
              if ":focus" not in selector.lower():
                  continue
              decls = _decls(body)
              has_ring = any(p in ("outline", "box-shadow", "border", "outline-color") for p, _ in decls)
              trans = next((v for p, v in decls if p in ("transition", "transition-property")), "")
              if has_ring and re.search(r"\b(all|outline|box-shadow|border)\b", trans, re.IGNORECASE):
                  findings.append(
                      Finding(
                          "focus-ring-fade",
                          "warning",
                          "focus ring is animated via transition; focus indicators must appear "
                          "instantly for accessibility. Remove the transition on the ring.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      def _scan_emoji_feature_icon(css: str) -> list[Finding]:
          findings: list[Finding] = []
          # CSS: content property carrying an emoji (pseudo-element icon).
          for m in re.finditer(r"content\s*:\s*([\"'])(.*?)\1", css, re.IGNORECASE | re.DOTALL):
              if _EMOJI_RE.search(m.group(2)):
                  findings.append(
                      Finding(
                          "emoji-feature-icon",
                          "warning",
                          "emoji used as a feature/list icon (CSS content); use an inline SVG "
                          "icon set for a deliberate look.",
                          _line_of(css, m.start()),
                      )
                  )
          # Markup hook: emoji directly inside a list item / feature element.
          for m in re.finditer(r"<li\b[^>]*>(.*?)</li>", css, re.IGNORECASE | re.DOTALL):
              if _EMOJI_RE.search(m.group(1)):
                  findings.append(
                      Finding(
                          "emoji-feature-icon",
                          "warning",
                          "emoji used as a feature/list icon in markup; use an inline SVG icon set.",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_two_line_cta(css: str) -> list[Finding]:
          findings: list[Finding] = []
          # Heuristic: a button/CTA-classed clickable whose text contains a forced break.
          pattern = re.compile(
              r"<(?:button|a)\b[^>]*(?:class\s*=\s*\"[^\"]*(?:btn|button|cta)[^\"]*\"|role\s*=\s*\"button\")[^>]*>(.*?)</(?:button|a)>",
              re.IGNORECASE | re.DOTALL,
          )
          for m in pattern.finditer(css):
              inner = m.group(1)
              if re.search(r"<br\s*/?>", inner, re.IGNORECASE) or "\n" in inner.strip():
                  findings.append(
                      Finding(
                          "two-line-cta",
                          "warning",
                          "call-to-action text wraps to two lines; keep CTA labels to one short "
                          "line so the action stays scannable.",
                          _line_of(css, m.start()),
                      )
                  )
          return findings
      
      
      def _scan_contrast_canary(css: str) -> list[Finding]:
          findings: list[Finding] = []
          for _selector, body, offset in _iter_blocks(css):
              fg = bg = None
              for prop, val in _decls(body):
                  if prop == "color":
                      fg = _color_to_lc(val)
                  elif prop in ("background-color", "background"):
                      cand = _color_to_lc(val)
                      if cand is not None:
                          bg = cand
              if fg is None or bg is None:
                  continue
              if abs(fg[0] - bg[0]) <= _CONTRAST_DELTA_L and abs(fg[1] - bg[1]) <= _CONTRAST_DELTA_C:
                  findings.append(
                      Finding(
                          "contrast-canary",
                          "warning",
                          "foreground and background are nearly identical "
                          f"(delta-L<={_CONTRAST_DELTA_L}, delta-chroma<={_CONTRAST_DELTA_C}); text "
                          "will be unreadable. Widen the lightness gap.",
                          _line_of(css, offset),
                      )
                  )
          return findings
      
      
      _SCANNERS = (
          _scan_transition_all,
          _scan_universal_hover_scale,
          _scan_gradient_text_headline,
          _scan_focus_ring_fade,
          _scan_emoji_feature_icon,
          _scan_two_line_cta,
          _scan_contrast_canary,
      )
      
      
      def scan_css(css_text: str) -> list[Finding]:
          """Scan CSS (or HTML containing CSS/markup) for slop patterns.
      
          Returns findings sorted by line, then rule_id. Deterministic.
          """
          findings: list[Finding] = []
          for scanner in _SCANNERS:
              findings.extend(scanner(css_text))
          findings.sort(key=lambda f: (f.line, f.rule_id))
          return findings
      
    • detect-shape.py 6.9 KB
      #!/usr/bin/env python3
      """Deterministic shape classifier for html-artifact skill.
      
      Analyzes a user request string and returns the best artifact shape
      from eight categories: spec, code-review, prototype, report, editor, data-viz,
      diagram, deck.
      
      Classification is purely signal-word based — same input always produces same output.
      
      Usage:
          python3 skills/meta/html-artifact/scripts/detect-shape.py --request "explore 3 auth approaches"
          python3 skills/meta/html-artifact/scripts/detect-shape.py --request "visualize metrics" --json-compact
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      from dataclasses import dataclass, field
      
      # --- Shape definitions ---
      # Each shape has primary signals (2 points) and secondary signals (1 point).
      # Priority order for tie-breaking: editor > spec > code-review > prototype > report > data-viz
      
      SHAPES: dict[str, dict[str, list[str]]] = {
          "editor": {
              "primary": [
                  "reorder",
                  "triage",
                  "edit config",
                  "tune prompt",
                  "pick",
                  "drag",
                  "bucket",
                  "prioritize",
                  "flag",
              ],
              "secondary": [
                  "sort tickets",
                  "manage",
                  "organize",
                  "configuration",
                  "feature flags",
              ],
          },
          "spec": {
              "primary": [
                  "plan",
                  "explore",
                  "compare",
                  "brainstorm",
                  "approach",
                  "option",
                  "tradeoff",
                  "direction",
              ],
              "secondary": [
                  "implementation plan",
                  "design options",
                  "side by side",
                  "pros and cons",
              ],
          },
          "code-review": {
              "primary": [
                  "review pr",
                  "review this pr",
                  "review my pr",
                  "diff",
                  "annotate",
                  "code review",
                  "explain code",
                  "understand module",
                  "pr writeup",
              ],
              "secondary": [
                  "pull request",
                  "code walkthrough",
                  "what changed",
                  "review the pr",
                  "pr review",
              ],
          },
          "prototype": {
              "primary": [
                  "prototype",
                  "animation",
                  "tune",
                  "slider",
                  "knob",
                  "try options",
                  "component",
                  "variant",
                  "tweak",
              ],
              "secondary": [
                  "design system",
                  "mockup",
                  "interaction",
                  "adjust",
                  "playground",
              ],
          },
          "report": {
              "primary": [
                  "report",
                  "summarize",
                  "status",
                  "explain how",
                  "incident",
                  "timeline",
                  "weekly",
                  "research",
                  "learning",
              ],
              "secondary": [
                  "post-mortem",
                  "overview",
                  "digest",
                  "briefing",
                  "explainer",
              ],
          },
          "data-viz": {
              "primary": [
                  "visualize",
                  "chart",
                  "dashboard",
                  "graph",
                  "trend",
                  "metric",
                  "plot",
                  "data",
                  "analytics",
              ],
              "secondary": [
                  "show me the numbers",
                  "performance over time",
                  "distribution",
              ],
          },
          "diagram": {
              "primary": [
                  "diagram",
                  "flowchart",
                  "architecture",
                  "sequence",
                  "svg",
                  "illustrate",
                  "figure",
                  "data flow",
                  "dependency map",
                  "node graph",
              ],
              "secondary": [
                  "draw a diagram",
                  "system diagram",
                  "service mesh",
                  "topology",
              ],
          },
          "deck": {
              "primary": [
                  "slides",
                  "presentation",
                  "deck",
                  "talk",
                  "pitch",
                  "keynote",
                  "slideshow",
              ],
              "secondary": [
                  "slide deck",
                  "lightning talk",
                  "conference talk",
                  "pitch deck",
              ],
          },
      }
      
      # Priority order for tie-breaking (index 0 = highest priority)
      PRIORITY_ORDER: list[str] = ["editor", "spec", "code-review", "diagram", "deck", "prototype", "report", "data-viz"]
      
      DEFAULT_SHAPE = "report"
      
      
      @dataclass
      class ShapeResult:
          """Classification result for a single shape."""
      
          shape: str
          score: int
          matched_signals: list[str] = field(default_factory=list)
      
      
      def classify_request(request: str) -> dict[str, object]:
          """Classify a user request into an artifact shape.
      
          Args:
              request: The user's natural language request string.
      
          Returns:
              Dict with keys: shape, confidence, signals.
          """
          lowered = request.lower().strip()
      
          if not lowered:
              return {"shape": DEFAULT_SHAPE, "confidence": "low", "signals": []}
      
          results: list[ShapeResult] = []
      
          for shape_name in PRIORITY_ORDER:
              signals = SHAPES[shape_name]
              score = 0
              matched: list[str] = []
      
              for signal in signals["primary"]:
                  if signal in lowered:
                      score += 2
                      matched.append(signal)
      
              for signal in signals["secondary"]:
                  if signal in lowered:
                      score += 1
                      matched.append(signal)
      
              results.append(ShapeResult(shape=shape_name, score=score, matched_signals=matched))
      
          # Sort by score descending; ties broken by priority order (already iteration order)
          best = max(results, key=lambda r: (r.score, -PRIORITY_ORDER.index(r.shape)))
      
          if best.score == 0:
              return {"shape": DEFAULT_SHAPE, "confidence": "low", "signals": []}
      
          if best.score >= 4:
              confidence = "high"
          elif best.score >= 2:
              confidence = "medium"
          else:
              confidence = "low"
      
          # Find secondary shape (second-highest scorer with meaningful signal match)
          remaining = [r for r in results if r.shape != best.shape and r.score >= 2]
          secondary = max(remaining, key=lambda r: r.score) if remaining else None
      
          return {
              "shape": best.shape,
              "confidence": confidence,
              "signals": best.matched_signals,
              "secondary_shape": secondary.shape if secondary else None,
              "secondary_signals": secondary.matched_signals if secondary else [],
          }
      
      
      def main() -> None:
          """CLI entry point."""
          parser = argparse.ArgumentParser(description="Classify a user request into an artifact shape.")
          parser.add_argument("--request", required=True, help="User request string to classify.")
          parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).")
          args = parser.parse_args()
      
          result = classify_request(args.request)
      
          indent = None if args.json_compact else 2
          json.dump(result, sys.stdout, indent=indent)
          sys.stdout.write("\n")
      
      
      if __name__ == "__main__":
          main()
      
    • fill-template.py 5.5 KB
      #!/usr/bin/env python3
      """Deterministic clone-and-fill for saved html-artifact templates.
      
      Reads a frozen saved template (`templates/saved/<name>.html`) and its slot
      manifest (`templates/saved/<name>.slots.json`), substitutes caller-supplied
      slot values, and writes a finished artifact. The layout, CSS, and chrome are
      never modified: content-vs-layout authority is enforced here, not by prompt
      discipline.
      
      Fail-loud rules (no silent degradation):
          - Missing required slot            -> exit 1
          - Provided slot not in manifest    -> exit 1 (catches typos)
          - Unresolved {{MARKER}} after fill -> exit 1
      
      Exit codes:
          0: filled and written successfully
          1: slot validation failure
          2: template or manifest not found / unreadable
      
      Usage:
          python3 fill-template.py --template business-review --slots slots.json --out artifact.html
          python3 fill-template.py --template business-review --slots slots.json           # prints to stdout
          python3 fill-template.py --list                                                   # list gallery
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      SAVED_DIR = Path(__file__).parent.parent / "templates" / "saved"
      MARKER_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}")
      
      
      def _fail(msg: str, code: int) -> None:
          sys.stderr.write(f"Error: {msg}\n")
          sys.exit(code)
      
      
      def list_templates() -> list[str]:
          """Return the base names of saved templates that have a slot manifest."""
          if not SAVED_DIR.exists():
              return []
          names = []
          for html in sorted(SAVED_DIR.glob("*.html")):
              if (SAVED_DIR / f"{html.stem}.slots.json").exists():
                  names.append(html.stem)
          return names
      
      
      def load_manifest(name: str) -> dict:
          """Load and return the slot manifest for a template."""
          manifest_path = SAVED_DIR / f"{name}.slots.json"
          if not manifest_path.exists():
              _fail(f"no slot manifest for template '{name}' at {manifest_path}", 2)
          try:
              data = json.loads(manifest_path.read_text(encoding="utf-8"))
          except json.JSONDecodeError as e:
              _fail(f"manifest for '{name}' is not valid JSON: {e}", 2)
          if "slots" not in data or not isinstance(data["slots"], list):
              _fail(f"manifest for '{name}' must contain a 'slots' list", 2)
          return data
      
      
      def fill_template(name: str, values: dict[str, str]) -> str:
          """Clone the frozen template and substitute slot values.
      
          Args:
              name: Base name of a saved template.
              values: Mapping of slot name to replacement HTML/text.
      
          Returns:
              The finished artifact HTML.
      
          Exits (non-zero) on any slot validation failure.
          """
          html_path = SAVED_DIR / f"{name}.html"
          if not html_path.exists():
              _fail(f"template '{name}' not found at {html_path}", 2)
      
          manifest = load_manifest(name)
          declared = {s["name"]: s for s in manifest["slots"]}
      
          # Reject slot names not declared in the manifest — catches typos loudly.
          unknown = sorted(set(values) - set(declared))
          if unknown:
              _fail(f"provided slot(s) not declared in '{name}' manifest: {', '.join(unknown)}", 1)
      
          # Every required slot must be provided.
          missing = sorted(s["name"] for s in manifest["slots"] if s.get("required", True) and s["name"] not in values)
          if missing:
              _fail(f"missing required slot(s) for '{name}': {', '.join(missing)}", 1)
      
          html = html_path.read_text(encoding="utf-8")
      
          # Substitute every declared slot. Optional slots default to empty string so
          # the marker never survives into the output.
          for slot_name in declared:
              replacement = values.get(slot_name, "")
              html = html.replace(f"{{{{{slot_name}}}}}", replacement)
      
          # No marker may survive. A leftover means the template has an undeclared
          # slot — a template bug, not a caller bug.
          leftover = sorted(set(MARKER_RE.findall(html)))
          if leftover:
              _fail(
                  f"unresolved marker(s) remain after fill: {', '.join(leftover)} (declare them in {name}.slots.json)",
                  1,
              )
      
          return html
      
      
      def main() -> None:
          parser = argparse.ArgumentParser(description="Clone and fill a saved html-artifact template.")
          parser.add_argument("--template", help="Base name of a saved template (see --list).")
          parser.add_argument("--slots", help="Path to a JSON file mapping slot names to values.")
          parser.add_argument("--out", help="Output path. Prints to stdout if omitted.")
          parser.add_argument("--list", action="store_true", help="List available saved templates and exit.")
          args = parser.parse_args()
      
          if args.list:
              names = list_templates()
              if names:
                  sys.stdout.write("\n".join(names) + "\n")
              else:
                  sys.stdout.write("(no saved templates with manifests)\n")
              return
      
          if not args.template:
              _fail("--template is required (or use --list)", 2)
          if not args.slots:
              _fail("--slots is required", 2)
      
          slots_path = Path(args.slots)
          if not slots_path.exists():
              _fail(f"slots file not found: {slots_path}", 2)
          try:
              values = json.loads(slots_path.read_text(encoding="utf-8"))
          except json.JSONDecodeError as e:
              _fail(f"slots file is not valid JSON: {e}", 2)
          if not isinstance(values, dict):
              _fail("slots file must be a JSON object mapping slot names to values", 2)
      
          html = fill_template(args.template, values)
      
          if args.out:
              Path(args.out).write_text(html, encoding="utf-8")
              sys.stdout.write(f"Wrote {args.out} ({len(html)} bytes)\n")
          else:
              sys.stdout.write(html)
      
      
      if __name__ == "__main__":
          main()
      
    • generate-filename.py 3.6 KB
      #!/usr/bin/env python3
      """Deterministic filename generator for html-artifact skill.
      
      Given a request string, produces a kebab-case .html filename.
      
      Usage:
          python3 skills/meta/html-artifact/scripts/generate-filename.py --request "explore 3 approaches to rate limiting"
          python3 skills/meta/html-artifact/scripts/generate-filename.py --request "build a dashboard" --shape data-viz
      """
      
      from __future__ import annotations
      
      import argparse
      import re
      import sys
      
      VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz")
      
      STOP_WORDS = frozenset(
          {
              "the",
              "a",
              "an",
              "to",
              "for",
              "of",
              "in",
              "on",
              "at",
              "by",
              "with",
              "from",
              "this",
              "that",
              "these",
              "those",
              "my",
              "our",
              "your",
              "is",
              "are",
              "was",
              "were",
              "be",
              "been",
              "being",
              "have",
              "has",
              "had",
              "do",
              "does",
              "did",
              "will",
              "would",
              "shall",
              "should",
              "can",
              "could",
              "may",
              "might",
              "must",
              "need",
              "me",
              "i",
              "we",
              "you",
              "it",
              "he",
              "she",
              "they",
          }
      )
      
      VERB_PREFIXES = frozenset(
          {
              "explore",
              "create",
              "make",
              "build",
              "write",
              "generate",
              "show",
              "help",
              "review",
              "explain",
              "analyze",
              "compare",
              "visualize",
              "prototype",
              "design",
              "report",
              "summarize",
              "triage",
              "reorder",
              "edit",
              "tune",
          }
      )
      
      
      def generate_filename(request: str, shape: str | None = None) -> str:
          """Generate a kebab-case .html filename from a request string.
      
          Args:
              request: The user's natural language request.
              shape: Optional shape to prepend if not already present.
      
          Returns:
              A kebab-case .html filename.
          """
          # Step 1: lowercase
          lowered = request.lower()
      
          # Step 2-3: extract words, remove stop words and verb prefixes
          words = re.findall(r"[a-z]+", lowered)
          content_words = [w for w in words if w not in STOP_WORDS and w not in VERB_PREFIXES]
      
          # Step 4: max 4 content words
          content_words = content_words[:4]
      
          # Step 7: fallback if no content words
          if not content_words:
              if shape:
                  return f"{shape}-artifact.html"
              return "artifact.html"
      
          filename_base = "-".join(content_words)
      
          # Step 8: prepend shape if provided and shape word not in filename
          if shape:
              # Normalize shape for comparison (e.g., "data-viz" -> ["data", "viz"])
              shape_parts = shape.split("-")
              if not any(part in content_words for part in shape_parts):
                  filename_base = f"{shape}-{filename_base}"
      
          return f"{filename_base}.html"
      
      
      def main() -> None:
          """CLI entry point."""
          parser = argparse.ArgumentParser(description="Generate a kebab-case filename from a request.")
          parser.add_argument("--request", required=True, help="User request string.")
          parser.add_argument("--shape", default=None, help="Optional shape to prepend.")
          args = parser.parse_args()
      
          if args.shape is not None and args.shape not in VALID_SHAPES:
              sys.stderr.write(f"Error: Invalid shape '{args.shape}'. Valid shapes: {', '.join(VALID_SHAPES)}\n")
              sys.exit(1)
      
          filename = generate_filename(args.request, args.shape)
          sys.stdout.write(filename + "\n")
      
      
      if __name__ == "__main__":
          main()
      
    • render-github-issues.py 7.5 KB
      #!/usr/bin/env python3
      """Render an HTML artifact listing the user's open GitHub issues.
      
      Sources data via `gh search issues --involves @me` (assigned + mentioned +
      review-requested). For each issue, fetches up to 20 comments via `gh issue
      view` and truncates each comment body to ~200 chars for an at-a-glance
      discussion summary. No LLM in the loop — pure deterministic rendering.
      
      The output substitutes {{TITLE}}, {{GENERATED_AT}}, {{ISSUES_JSON}} into the
      saved template at templates/saved/github-issues.html.
      
      Usage:
          python3 scripts/render-github-issues.py
          python3 scripts/render-github-issues.py --limit 100 --out /tmp/issues.html
          python3 scripts/render-github-issues.py --dry-run
          python3 scripts/render-github-issues.py --no-discussion  # skip per-issue gh issue view
      
      Exit codes:
          0: rendered successfully
          1: gh CLI failed (auth, rate-limit, etc.)
          2: template missing
      """
      
      from __future__ import annotations
      
      import argparse
      import datetime as dt
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      
      SKILL_DIR = Path(__file__).resolve().parent.parent
      TEMPLATE_PATH = SKILL_DIR / "templates" / "saved" / "github-issues.html"
      
      COMMENT_TRUNC = 200  # chars per comment body
      COMMENT_MAX = 20  # comments per issue
      GH_HOST = "github.com"  # disambiguate from SAP enterprise host
      
      
      def run_gh(args: list[str]) -> str:
          """Run gh with GH_HOST=github.com pinned. Return stdout, raise on failure."""
          env = os.environ.copy()
          env["GH_HOST"] = GH_HOST
          try:
              result = subprocess.run(
                  ["gh", *args],
                  capture_output=True,
                  text=True,
                  env=env,
                  check=False,
              )
          except FileNotFoundError as exc:
              raise SystemExit(f"gh CLI not found: {exc}") from exc
          if result.returncode != 0:
              sys.stderr.write(f"gh {' '.join(args)} failed:\n{result.stderr}\n")
              raise SystemExit(1)
          return result.stdout
      
      
      def fetch_issues(limit: int) -> list[dict]:
          """Fetch open issues involving @me from github.com."""
          raw = run_gh(
              [
                  "search",
                  "issues",
                  "--involves",
                  "@me",
                  "--state",
                  "open",
                  "--json",
                  "number,title,url,repository,createdAt,updatedAt,commentsCount,author,state,labels",
                  "--limit",
                  str(limit),
              ]
          )
          return json.loads(raw or "[]")
      
      
      def fetch_comments(repo_nwo: str, number: int) -> list[dict]:
          """Fetch comments for one issue. Returns [] on any error (best-effort)."""
          try:
              raw = run_gh(
                  [
                      "issue",
                      "view",
                      str(number),
                      "--repo",
                      repo_nwo,
                      "--json",
                      "comments",
                  ]
              )
          except SystemExit:
              return []
          try:
              data = json.loads(raw or "{}")
          except json.JSONDecodeError:
              return []
          return data.get("comments", []) or []
      
      
      def truncate(s: str, n: int) -> str:
          if not s:
              return ""
          s = s.strip()
          if len(s) <= n:
              return s
          return s[: n - 1].rstrip() + "…"
      
      
      def shape_issue(issue: dict, comments: list[dict]) -> dict:
          """Reduce gh JSON to the minimal shape the template renders."""
          repo = issue.get("repository", {}) or {}
          repo_nwo = repo.get("nameWithOwner") or repo.get("name") or ""
          author = issue.get("author") or {}
          discussion = []
          for c in comments[:COMMENT_MAX]:
              c_author = c.get("author") or {}
              discussion.append(
                  {
                      "author": c_author.get("login") or "unknown",
                      "authorAvatar": c_author.get("avatarUrl") or "",
                      "createdAt": c.get("createdAt") or "",
                      "summary": truncate(c.get("body") or "", COMMENT_TRUNC),
                  }
              )
          return {
              "number": issue.get("number"),
              "title": issue.get("title") or "",
              "url": issue.get("url") or "",
              "repo": repo_nwo,
              "state": issue.get("state") or "open",
              "createdAt": issue.get("createdAt") or "",
              "updatedAt": issue.get("updatedAt") or "",
              "commentsCount": issue.get("commentsCount") or 0,
              "author": author.get("login") or "unknown",
              "authorAvatar": author.get("avatarUrl") or "",
              "labels": [{"name": l.get("name") or "", "color": l.get("color") or ""} for l in (issue.get("labels") or [])],
              "discussion": discussion,
          }
      
      
      def render(issues: list[dict], title: str, generated_at: str) -> str:
          """Substitute placeholder tokens into the saved template.
      
          The template ships with a sample dataset inside
          `<script id="issues-data" type="application/json">…</script>` so it opens
          standalone for testing. The renderer replaces that script tag's content
          with the live JSON.
          """
          if not TEMPLATE_PATH.exists():
              sys.stderr.write(f"Template not found at {TEMPLATE_PATH}\n")
              raise SystemExit(2)
          template = TEMPLATE_PATH.read_text(encoding="utf-8")
          # JSON-encode so the data lands inside <script type="application/json">.
          # Escape </ to neutralize a stray closing tag inside string content.
          payload = json.dumps(issues, ensure_ascii=False).replace("</", "<\\/")
          import re as _re
      
          rendered, n = _re.subn(
              r'(<script id="issues-data" type="application/json">)(.*?)(</script>)',
              lambda m: m.group(1) + "\n" + payload + "\n" + m.group(3),
              template,
              count=1,
              flags=_re.DOTALL,
          )
          if n == 0:
              sys.stderr.write('Template missing <script id="issues-data"> tag.\n')
              raise SystemExit(2)
          return rendered.replace("{{TITLE}}", title).replace("{{GENERATED_AT}}", generated_at)
      
      
      def default_out_path() -> Path:
          today = dt.date.today().isoformat()
          return Path.home() / "Documents" / "vexjoy-personal-reports" / f"github-issues-{today}.html"
      
      
      def main() -> None:
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument("--limit", type=int, default=50, help="max issues to fetch (default 50)")
          ap.add_argument(
              "--out",
              type=Path,
              default=None,
              help="output path (default ~/Documents/vexjoy-personal-reports/github-issues-<DATE>.html)",
          )
          ap.add_argument("--dry-run", action="store_true", help="emit HTML to stdout, write nothing")
          ap.add_argument(
              "--no-discussion", action="store_true", help="skip per-issue gh issue view (faster, no comment summaries)"
          )
          ap.add_argument("--title", default="My GitHub Issues", help="page title")
          args = ap.parse_args()
      
          issues_raw = fetch_issues(args.limit)
          sys.stderr.write(f"Fetched {len(issues_raw)} issue(s).\n")
      
          shaped = []
          for i, issue in enumerate(issues_raw, 1):
              repo = (issue.get("repository") or {}).get("nameWithOwner") or ""
              number = issue.get("number")
              if args.no_discussion or not repo or number is None:
                  comments = []
              else:
                  sys.stderr.write(f"  [{i}/{len(issues_raw)}] {repo}#{number}\n")
                  comments = fetch_comments(repo, number)
              shaped.append(shape_issue(issue, comments))
      
          generated_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M %Z").strip()
          html = render(shaped, args.title, generated_at)
      
          if args.dry_run:
              sys.stdout.write(html)
              return
      
          out = args.out if args.out else default_out_path()
          out.parent.mkdir(parents=True, exist_ok=True)
          out.write_text(html, encoding="utf-8")
          sys.stderr.write(f"Wrote {out} ({len(html):,} bytes, {len(shaped)} issues)\n")
          sys.stdout.write(str(out) + "\n")
      
      
      if __name__ == "__main__":
          main()
      
    • select-references.py 2.7 KB
      #!/usr/bin/env python3
      """Deterministic reference file selector for html-artifact skill.
      
      Given a shape, outputs the list of reference files to load.
      Replaces LLM table-lookup with a script.
      
      Exit codes:
          0: valid shape, references returned
          1: invalid shape
      
      Usage:
          python3 skills/meta/html-artifact/scripts/select-references.py --shape spec
          python3 skills/meta/html-artifact/scripts/select-references.py --shape report --json-compact
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import sys
      
      VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz")
      
      ALWAYS_LOAD = [
          "references/design-system.md",
          "references/interaction-patterns.md",
      ]
      
      SHAPE_SPECIFIC: dict[str, str] = {
          "spec": "references/shape-spec-exploration.md",
          "code-review": "references/shape-code-review.md",
          "prototype": "references/shape-design-prototype.md",
          "report": "references/shape-report-research.md",
          "editor": "references/shape-custom-editor.md",
          "data-viz": "references/shape-data-visualization.md",
      }
      
      
      def select_references(shape: str) -> dict[str, object]:
          """Return reference file lists for a given shape.
      
          Args:
              shape: One of the 6 valid artifact shapes.
      
          Returns:
              Dict with shape, always_load, shape_specific, and all_files keys.
      
          Raises:
              ValueError: If shape is not one of the valid shapes.
          """
          if shape not in VALID_SHAPES:
              raise ValueError(f"Invalid shape '{shape}'. Valid shapes: {', '.join(VALID_SHAPES)}")
      
          shape_ref = SHAPE_SPECIFIC[shape]
          shape_specific = [shape_ref]
      
          return {
              "shape": shape,
              "always_load": list(ALWAYS_LOAD),
              "shape_specific": shape_specific,
              "all_files": ALWAYS_LOAD + shape_specific,
          }
      
      
      def main() -> None:
          """CLI entry point."""
          parser = argparse.ArgumentParser(description="Select reference files for an artifact shape.")
          parser.add_argument(
              "--shape", required=True, help="Artifact shape (spec, code-review, prototype, report, editor, data-viz)."
          )
          parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).")
          args = parser.parse_args()
      
          try:
              result = select_references(args.shape)
          except ValueError as e:
              error_result = {"error": str(e), "valid_shapes": list(VALID_SHAPES)}
              indent = None if args.json_compact else 2
              json.dump(error_result, sys.stderr, indent=indent)
              sys.stderr.write("\n")
              sys.exit(1)
      
          indent = None if args.json_compact else 2
          json.dump(result, sys.stdout, indent=indent)
          sys.stdout.write("\n")
      
      
      if __name__ == "__main__":
          main()
      
    • to-pdf.py 12.5 KB
      #!/usr/bin/env python3
      """Render an html-artifact to PDF via Playwright (sync API).
      
      Auto-detects shape from `<body data-shape="...">` and selects per-shape page size,
      landscape/portrait, and margins. Waits for `networkidle` before snapshotting.
      
      Usage:
          python3 to-pdf.py --input artifact.html --output artifact.pdf
          python3 to-pdf.py --input artifact.html --output artifact.pdf --shape deck
          python3 to-pdf.py --input artifact.html --output artifact.pdf --json
      
      Exit codes:
          0: PDF generated successfully
          1: input/output validation error, missing data-shape, or malformed HTML
          2: Playwright unavailable (install instructions printed to stderr)
          3: PDF generation failed (browser launch, page load, or page.pdf failure)
      """
      
      from __future__ import annotations
      
      import argparse
      import copy
      import json
      import re
      import sys
      from pathlib import Path
      from typing import Any
      
      VALID_SHAPES = ("spec", "code-review", "prototype", "report", "editor", "data-viz", "diagram", "deck")
      
      PAGE_SIZE_MAP: dict[str, dict[str, Any]] = {
          "deck": {
              "width": "13.333in",
              "height": "7.5in",
              "landscape": True,
              "margin": {"top": "0", "right": "0", "bottom": "0", "left": "0"},
          },
          "spec": {
              "format": "Letter",
              "landscape": True,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
          "code-review": {
              "format": "Letter",
              "landscape": True,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
          "prototype": {
              "format": "Letter",
              "landscape": True,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
          "data-viz": {
              "format": "Letter",
              "landscape": True,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
          "diagram": {
              "format": "Letter",
              "landscape": True,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
          "report": {
              "format": "Letter",
              "landscape": False,
              "margin": {"top": "0.75in", "right": "0.75in", "bottom": "0.75in", "left": "0.75in"},
          },
          "editor": {
              "format": "Letter",
              "landscape": False,
              "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
          },
      }
      
      DEFAULT_PAGE: dict[str, Any] = {
          "format": "Letter",
          "landscape": False,
          "margin": {"top": "0.5in", "right": "0.5in", "bottom": "0.5in", "left": "0.5in"},
      }
      
      INSTALL_HINT = 'pip install -e ".[pdf]" && playwright install chromium'
      
      _DATA_SHAPE_RE = re.compile(r"<body[^>]*\bdata-shape\s*=\s*\"([^\"]+)\"", re.IGNORECASE)
      # Match a complete class attribute so we can split into tokens for exact-match counting.
      _CLASS_ATTR_RE = re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE)
      _HEAD_CLOSE_RE = re.compile(r"</head>", re.IGNORECASE)
      _BODY_OPEN_RE = re.compile(r"<body([^>]*)>", re.IGNORECASE)
      
      # Defensive deck print stylesheet injected at render time when shape=deck.
      # Guarantees one slide per printed page even when source HTML omits the bundled
      # deck-print.css (minimal fixtures, ad-hoc decks, or assembler regressions).
      # Uses !important to override any screen `display: none` applied to non-active
      # slides by the deck shape stylesheet.
      _DECK_PRINT_STYLESHEET = """
      <style id="to-pdf-deck-print">
      @page { size: 13.333in 7.5in; margin: 0; }
      @media print {
        html, body {
          margin: 0 !important;
          padding: 0 !important;
          background: #0a0a0a !important;
          color: #f5f5f5 !important;
          -webkit-print-color-adjust: exact !important;
          print-color-adjust: exact !important;
        }
        *, *::before, *::after {
          -webkit-print-color-adjust: exact !important;
          print-color-adjust: exact !important;
        }
        .deck-nav, .progress-bar, .slide-counter, .deck-controls, .theme-toggle,
        .slide-nav {
          display: none !important;
        }
        /* Un-clip the deck wrapper so child slides can flow into separate pages. */
        .slide-deck {
          position: static !important;
          width: auto !important;
          max-width: none !important;
          margin: 0 !important;
          padding: 0 !important;
          aspect-ratio: auto !important;
          overflow: visible !important;
          display: block !important;
        }
        .slide, .slide:not(.active) {
          display: flex !important;
          flex-direction: column;
          justify-content: center;
          visibility: visible !important;
          opacity: 1 !important;
          transform: none !important;
          position: relative !important;
          inset: auto !important;
          width: 13.333in !important;
          height: 7.5in !important;
          margin: 0 !important;
          padding: 0.5in !important;
          box-sizing: border-box !important;
          page-break-after: always !important;
          break-after: page !important;
          page-break-inside: avoid !important;
          break-inside: avoid !important;
          overflow: hidden;
          background: #0a0a0a !important;
          color: #f5f5f5 !important;
        }
        .slide:last-child {
          page-break-after: auto !important;
          break-after: auto !important;
        }
      }
      </style>
      """
      
      
      def inject_deck_print_css(html: str) -> str:
          """Inject defensive deck print CSS just before </head> for shape=deck.
      
          Idempotent: source HTML already containing `id="to-pdf-deck-print"` is
          returned unchanged. Falls back to inserting after `<body ...>` if no
          `</head>` exists; falls back to prepending if no `<body>` exists either.
          """
          if 'id="to-pdf-deck-print"' in html:
              return html
          if _HEAD_CLOSE_RE.search(html):
              return _HEAD_CLOSE_RE.sub(_DECK_PRINT_STYLESHEET + "</head>", html, count=1)
          if _BODY_OPEN_RE.search(html):
              return _BODY_OPEN_RE.sub(lambda m: f"<body{m.group(1)}>{_DECK_PRINT_STYLESHEET}", html, count=1)
          return _DECK_PRINT_STYLESHEET + html
      
      
      def detect_shape(html: str) -> str | None:
          """Extract the shape from `<body data-shape="...">`. Returns None if absent."""
          match = _DATA_SHAPE_RE.search(html)
          if match:
              return match.group(1).strip()
          return None
      
      
      def page_options_for_shape(shape: str | None) -> dict[str, Any]:
          """Look up Playwright `page.pdf()` options for a given shape, falling back to default.
      
          Returns a deep copy so callers can mutate freely without affecting the source map.
          """
          if shape is None:
              return copy.deepcopy(DEFAULT_PAGE)
          return copy.deepcopy(PAGE_SIZE_MAP.get(shape, DEFAULT_PAGE))
      
      
      def count_slides(html: str) -> int:
          """Count elements whose class attribute contains the exact token `slide`.
      
          Scans every `class="..."` attribute and tokenizes on whitespace, counting
          only attributes that have the literal token `slide` (e.g. `class="slide"`,
          `class="slide active"`). Attributes containing only related tokens such as
          `slide-deck`, `slide-nav`, or `slideshow` do not increment the count.
          """
          count = 0
          for match in _CLASS_ATTR_RE.finditer(html):
              tokens = match.group(1).split()
              if "slide" in tokens:
                  count += 1
          return count
      
      
      def validate_html(html: str) -> str | None:
          """Cheap structural sanity check on the HTML. Returns error message or None."""
          if not html.strip():
              return "Input HTML is empty."
          lower = html.lower()
          if "<html" not in lower:
              return "Input does not look like HTML (no <html> tag found)."
          if "<body" not in lower:
              return "Input does not look like HTML (no <body> tag found)."
          return None
      
      
      def render_pdf(input_path: Path, output_path: Path, shape: str | None) -> dict[str, Any]:
          """Launch Playwright, load the HTML file, and write a PDF.
      
          Returns a dict with keys: output, page_count, shape, bytes.
      
          Raises RuntimeError on browser launch / page.goto / page.pdf failures.
          ImportError if Playwright is unavailable.
          """
          from playwright.sync_api import sync_playwright
      
          html = input_path.read_text(encoding="utf-8")
          err = validate_html(html)
          if err:
              raise ValueError(err)
      
          resolved_shape = shape if shape is not None else detect_shape(html)
          options = page_options_for_shape(resolved_shape)
          options["path"] = str(output_path)
          options["print_background"] = True
      
          # For decks, inject defensive print CSS so each .slide reliably becomes one
          # printed page even when the source HTML omits the bundled deck-print.css
          # (e.g. minimal fixtures). Write the augmented HTML to a sibling temp file
          # so that file:// loading still resolves any relative resources from the
          # original artifact's directory.
          load_path = input_path
          temp_path: Path | None = None
          if resolved_shape == "deck":
              augmented = inject_deck_print_css(html)
              if augmented != html:
                  temp_path = input_path.with_name(f".{input_path.stem}.to-pdf.tmp.html")
                  temp_path.write_text(augmented, encoding="utf-8")
                  load_path = temp_path
      
          # pathlib.as_uri() handles spaces, unicode, and Windows drives per RFC 8089.
          file_url = load_path.resolve().as_uri()
      
          try:
              with sync_playwright() as p:
                  browser = p.chromium.launch()
                  try:
                      context = browser.new_context()
                      page = context.new_page()
                      page.goto(file_url, wait_until="networkidle")
                      page.pdf(**options)
                  finally:
                      browser.close()
          finally:
              if temp_path is not None and temp_path.exists():
                  temp_path.unlink()
      
          pdf_bytes = output_path.stat().st_size
          if resolved_shape == "deck":
              page_count = count_slides(html)
          else:
              page_count = 0  # unknown without parsing the PDF; deck is the documented case
      
          return {
              "output": str(output_path.resolve()),
              "page_count": page_count,
              "shape": resolved_shape or "default",
              "bytes": pdf_bytes,
          }
      
      
      def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
          parser = argparse.ArgumentParser(description="Render an html-artifact to PDF via Playwright.")
          parser.add_argument("--input", required=True, help="Path to the source .html file.")
          parser.add_argument("--output", required=True, help="Path for the generated .pdf file.")
          parser.add_argument(
              "--shape",
              default=None,
              choices=VALID_SHAPES,
              help="Override shape detection (otherwise read from <body data-shape>).",
          )
          parser.add_argument(
              "--json",
              dest="json_out",
              action="store_true",
              help="Emit machine-readable JSON to stdout on success.",
          )
          return parser.parse_args(argv)
      
      
      def main(argv: list[str] | None = None) -> int:
          args = parse_args(argv)
          input_path = Path(args.input)
          output_path = Path(args.output)
      
          if not input_path.is_file():
              sys.stderr.write(f"Error: input file not found: {input_path}\n")
              return 1
      
          # Resolve shape: --shape wins, else parse <body data-shape>.
          if args.shape is None:
              html = input_path.read_text(encoding="utf-8")
              err = validate_html(html)
              if err:
                  sys.stderr.write(f"Error: {err}\n")
                  return 1
              detected = detect_shape(html)
              if detected is None:
                  sys.stderr.write(
                      "Error: HTML artifact missing data-shape attribute. "
                      "Re-assemble with assemble-template.py or pass --shape explicitly.\n"
                  )
                  return 1
              shape = detected
          else:
              shape = args.shape
      
          output_path.parent.mkdir(parents=True, exist_ok=True)
      
          try:
              result = render_pdf(input_path, output_path, shape)
          except ImportError:
              sys.stderr.write(f"Error: Playwright is not installed.\nInstall with: {INSTALL_HINT}\n")
              return 2
          except ValueError as e:
              sys.stderr.write(f"Error: {e}\n")
              return 1
          except Exception as e:
              # Playwright launch fails with a non-ImportError when chromium isn't installed.
              # The error message contains "Executable doesn't exist" — surface install hint.
              msg = str(e)
              if "Executable doesn't exist" in msg or "playwright install" in msg:
                  sys.stderr.write(f"Error: Playwright browser binary missing.\nInstall with: {INSTALL_HINT}\n")
                  return 2
              sys.stderr.write(f"Error: PDF generation failed: {type(e).__name__}: {e}\n")
              return 3
      
          if args.json_out:
              sys.stdout.write(json.dumps(result) + "\n")
          else:
              sys.stdout.write(
                  f"PDF written to {result['output']} "
                  f"(shape={result['shape']}, {result['bytes']} bytes"
                  + (f", {result['page_count']} slides" if result["page_count"] else "")
                  + ")\n"
              )
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • validate-artifact.py 9.7 KB
      #!/usr/bin/env python3
      """Post-generation HTML artifact validator.
      
      Deterministic quality checker for generated .html files. Validates structure,
      self-containment, and minimum quality requirements.
      
      Exit codes:
          0: all checks pass (warnings OK)
          1: one or more errors
          2: file not found or not readable
      
      Usage:
          python3 skills/meta/html-artifact/scripts/validate-artifact.py path/to/artifact.html
          python3 skills/meta/html-artifact/scripts/validate-artifact.py artifact.html --json-compact
          python3 skills/meta/html-artifact/scripts/validate-artifact.py artifact.html --shape editor
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from dataclasses import dataclass, field
      from importlib import import_module
      from pathlib import Path
      
      MAX_FILE_SIZE_BYTES = 500 * 1024  # 500KB
      
      
      @dataclass
      class ValidationResult:
          """Aggregate validation result."""
      
          checks: dict[str, bool] = field(default_factory=dict)
          warnings: list[str] = field(default_factory=list)
          errors: list[str] = field(default_factory=list)
      
          @property
          def valid(self) -> bool:
              """True if no errors (warnings are acceptable)."""
              return len(self.errors) == 0
      
          def to_dict(self) -> dict[str, object]:
              """Serialize to output dict."""
              return {
                  "valid": self.valid,
                  "checks": self.checks,
                  "warnings": self.warnings,
                  "errors": self.errors,
              }
      
      
      def _check_doctype(content: str, result: ValidationResult) -> None:
          """File must start with <!DOCTYPE html> (case-insensitive)."""
          stripped = content.lstrip()
          passed = stripped.lower().startswith("<!doctype html>")
          result.checks["has_doctype"] = passed
          if not passed:
              result.errors.append("Missing <!DOCTYPE html> at start of file.")
      
      
      def _check_title(content: str, result: ValidationResult) -> None:
          """Must contain <title> tag with non-empty content."""
          match = re.search(r"<title[^>]*>(.*?)</title>", content, re.IGNORECASE | re.DOTALL)
          passed = match is not None and match.group(1).strip() != ""
          result.checks["has_title"] = passed
          if not passed:
              result.errors.append("Missing or empty <title> tag.")
      
      
      def _check_self_contained(content: str, result: ValidationResult) -> None:
          """No external stylesheet links or script sources via http(s)."""
          has_external_css = bool(
              re.search(r'<link[^>]+rel=["\']stylesheet["\'][^>]+href=["\']https?://', content, re.IGNORECASE)
          )
          has_external_js = bool(re.search(r'<script[^>]+src=["\']https?://', content, re.IGNORECASE))
          passed = not has_external_css and not has_external_js
          result.checks["self_contained"] = passed
          if not passed:
              externals = []
              if has_external_css:
                  externals.append("external CSS")
              if has_external_js:
                  externals.append("external JS")
              result.errors.append(f"Not self-contained: found {', '.join(externals)}.")
      
      
      def _check_has_style(content: str, result: ValidationResult) -> None:
          """Must contain <style> tag (inline CSS required)."""
          passed = bool(re.search(r"<style[\s>]", content, re.IGNORECASE))
          result.checks["has_style"] = passed
          if not passed:
              result.warnings.append("No <style> tag found. Inline CSS is recommended.")
      
      
      def _check_meta_viewport(content: str, result: ValidationResult) -> None:
          """Should contain <meta name="viewport" ...>."""
          passed = bool(re.search(r'<meta\s+name=["\']viewport["\']', content, re.IGNORECASE))
          result.checks["has_meta_viewport"] = passed
          if not passed:
              result.warnings.append('Missing <meta name="viewport"> tag.')
      
      
      def _check_reasonable_size(file_path: Path, result: ValidationResult) -> None:
          """File size must be under 500KB."""
          size = file_path.stat().st_size
          passed = size < MAX_FILE_SIZE_BYTES
          result.checks["reasonable_size"] = passed
          if not passed:
              size_kb = size / 1024
              result.warnings.append(f"File size {size_kb:.0f}KB exceeds 500KB limit.")
      
      
      def _check_no_empty_body(content: str, result: ValidationResult) -> None:
          """<body> must contain more than whitespace."""
          match = re.search(r"<body[^>]*>(.*?)</body>", content, re.IGNORECASE | re.DOTALL)
          if match is None:
              # No body tag at all — valid_structure will catch this
              passed = False
          else:
              passed = match.group(1).strip() != ""
          result.checks["no_empty_body"] = passed
          if not passed:
              result.errors.append("Empty <body> — no visible content.")
      
      
      def _check_valid_structure(content: str, result: ValidationResult) -> None:
          """Must have <html>, <head>, <body> tags."""
          has_html = bool(re.search(r"<html[\s>]", content, re.IGNORECASE))
          has_head = bool(re.search(r"<head[\s>]", content, re.IGNORECASE))
          has_body = bool(re.search(r"<body[\s>]", content, re.IGNORECASE))
          passed = has_html and has_head and has_body
          result.checks["valid_structure"] = passed
          if not passed:
              missing = []
              if not has_html:
                  missing.append("<html>")
              if not has_head:
                  missing.append("<head>")
              if not has_body:
                  missing.append("<body>")
              result.errors.append(f"Missing structural tags: {', '.join(missing)}.")
      
      
      def _check_emitted_css_slop(content: str, result: ValidationResult) -> None:
          """Scan emitted CSS/markup for slop patterns via the vendored slop rules.
      
          Findings are reported as warnings — they do not fail the build (matches the
          non-blocking choice in distinctive-frontend-design). Promote-to-error path:
          raise a finding's rule_id to a build error here once a rule graduates from
          "warning" to "error" in css_slop_rules.py.
      
          Shape-agnostic: the rules check CSS/markup, not page structure, so they apply
          to every artifact shape (including hero-less shapes like report or data-viz).
      
          Scans every size. The slop rules are linear (the block scanner is anchored on
          "{", not the old O(n^2) "[^{}]*" pre-brace run that hung on minified single
          lines), so there is no ReDoS reason to cap by size. Oversized files are still
          flagged independently by the reasonable_size check.
          """
          sys.path.insert(0, str(Path(__file__).parent))
          slop = import_module("css_slop_rules")
          findings = slop.scan_css(content)
          result.checks["css_slop_clean"] = not findings
          for f in findings:
              result.warnings.append(f"CSS slop [{f.rule_id}] line {f.line}: {f.message}")
      
      
      EXPORT_SHAPES = frozenset({"editor", "prototype"})
      
      
      def _check_export_button(content: str, shape: str, result: ValidationResult) -> None:
          """For editor/prototype shapes, check for copy/export functionality in scripts.
      
          This is a warning, not an error — the shape context isn't always available.
          """
          if shape not in EXPORT_SHAPES:
              return
      
          # Look for export/copy patterns in <script> blocks
          script_blocks = re.findall(r"<script[^>]*>(.*?)</script>", content, re.IGNORECASE | re.DOTALL)
          script_content = " ".join(script_blocks)
      
          has_clipboard = "navigator.clipboard" in script_content
          has_copy_func = "copyToClipboard" in script_content
          has_copy = bool(re.search(r"\bcopy\b", script_content, re.IGNORECASE))
      
          passed = has_clipboard or has_copy_func or has_copy
          result.checks["has_export_button"] = passed
          if not passed:
              result.warnings.append(
                  f"Shape '{shape}' should include copy/export functionality "
                  "(navigator.clipboard, copyToClipboard, or copy function)."
              )
      
      
      def validate_artifact(file_path: Path, shape: str | None = None) -> ValidationResult:
          """Run all validation checks on an HTML artifact file.
      
          Args:
              file_path: Path to the .html file to validate.
              shape: Optional artifact shape. When provided, enables shape-specific checks.
      
          Returns:
              ValidationResult with all check outcomes.
          """
          result = ValidationResult()
          content = file_path.read_text(encoding="utf-8")
      
          _check_doctype(content, result)
          _check_title(content, result)
          _check_self_contained(content, result)
          _check_has_style(content, result)
          _check_meta_viewport(content, result)
          _check_reasonable_size(file_path, result)
          _check_no_empty_body(content, result)
          _check_valid_structure(content, result)
          _check_emitted_css_slop(content, result)
      
          if shape is not None:
              _check_export_button(content, shape, result)
      
          return result
      
      
      def main() -> None:
          """CLI entry point."""
          parser = argparse.ArgumentParser(description="Validate a generated HTML artifact.")
          parser.add_argument("file", help="Path to the .html file to validate.")
          parser.add_argument("--json-compact", action="store_true", help="Output compact JSON (no indentation).")
          parser.add_argument(
              "--shape", default=None, help="Artifact shape for shape-specific checks (e.g., editor, prototype)."
          )
          args = parser.parse_args()
      
          file_path = Path(args.file)
      
          if not file_path.is_file():
              error_result = {"valid": False, "checks": {}, "warnings": [], "errors": [f"File not found: {args.file}"]}
              indent = None if args.json_compact else 2
              json.dump(error_result, sys.stdout, indent=indent)
              sys.stdout.write("\n")
              sys.exit(2)
      
          try:
              result = validate_artifact(file_path, shape=args.shape)
          except (OSError, UnicodeDecodeError) as e:
              error_result = {"valid": False, "checks": {}, "warnings": [], "errors": [f"Cannot read file: {e}"]}
              indent = None if args.json_compact else 2
              json.dump(error_result, sys.stdout, indent=indent)
              sys.stdout.write("\n")
              sys.exit(2)
      
          indent = None if args.json_compact else 2
          json.dump(result.to_dict(), sys.stdout, indent=indent)
          sys.stdout.write("\n")
      
          sys.exit(0 if result.valid else 1)
      
      
      if __name__ == "__main__":
          main()
      
  • templates
    • components
      • collapsible.css 1.1 KB · in bundle
      • collapsible.js 516 B
        /* === Collapsible/Accordion Component === */
        document.querySelectorAll('.accordion-trigger').forEach(trigger => {
          trigger.addEventListener('click', () => {
            const expanded = trigger.getAttribute('aria-expanded') === 'true';
            const panel = document.getElementById(trigger.getAttribute('aria-controls'));
            trigger.setAttribute('aria-expanded', String(!expanded));
            if (!expanded) {
              panel.style.maxHeight = panel.scrollHeight + 'px';
            } else {
              panel.style.maxHeight = '0';
            }
          });
        });
        
      • copy-button.css 534 B · in bundle
      • copy-button.js 334 B
        /* === Copy Button Component === */
        function copyToClipboard(text, btn) {
          navigator.clipboard.writeText(text).then(() => {
            const original = btn.textContent;
            btn.textContent = '✓ Copied!';
            btn.classList.add('copied');
            setTimeout(() => { btn.textContent = original; btn.classList.remove('copied'); }, 1500);
          });
        }
        
      • drag-drop.css 612 B · in bundle
      • drag-drop.js 1.1 KB
        /* === Drag and Drop Component === */
        let dragEl = null;
        document.querySelectorAll('.drag-item').forEach(item => {
          item.addEventListener('dragstart', e => { dragEl = item; item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
          item.addEventListener('dragend', () => { item.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); dragEl = null; });
          item.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drag-over'); });
          item.addEventListener('dragleave', () => { item.classList.remove('drag-over'); });
          item.addEventListener('drop', e => {
            e.preventDefault(); item.classList.remove('drag-over');
            if (dragEl && dragEl !== item) {
              const list = item.parentNode; const items = [...list.children];
              const fromIdx = items.indexOf(dragEl); const toIdx = items.indexOf(item);
              if (fromIdx < toIdx) { list.insertBefore(dragEl, item.nextSibling); } else { list.insertBefore(dragEl, item); }
            }
          });
        });
        
      • filter.css 909 B · in bundle
      • filter.js 987 B
        /* === Filter Component === */
        function setupFilter(inputSelector, itemSelector) {
          const input = document.querySelector(inputSelector);
          input.addEventListener('input', () => {
            const query = input.value.toLowerCase();
            document.querySelectorAll(itemSelector).forEach(item => {
              const text = (item.textContent + ' ' + (item.dataset.keywords || '')).toLowerCase();
              item.classList.toggle('hidden', query && !text.includes(query));
            });
          });
        }
        function setupTagFilter(barSelector, itemSelector) {
          document.querySelectorAll(barSelector + ' .tag-btn').forEach(btn => {
            btn.addEventListener('click', () => {
              document.querySelectorAll(barSelector + ' .tag-btn').forEach(b => b.classList.remove('active'));
              btn.classList.add('active');
              const tag = btn.dataset.tag;
              document.querySelectorAll(itemSelector).forEach(item => {
                item.classList.toggle('hidden', tag !== 'all' && !item.dataset.tags.includes(tag));
              });
            });
          });
        }
        
      • keyboard-nav.css 249 B · in bundle
      • keyboard-nav.js 900 B
        /* === Keyboard Navigation Component === */
        function setupKeyNav(containerSelector, itemSelector) {
          const container = document.querySelector(containerSelector);
          const items = () => container.querySelectorAll(itemSelector);
          let current = 0;
          const counter = container.querySelector('.key-nav-counter');
          function update() {
            const all = items();
            all.forEach((el, i) => el.classList.toggle('active', i === current));
            if (counter) counter.textContent = (current + 1) + '/' + all.length;
          }
          document.addEventListener('keydown', e => {
            const all = items();
            if (!all.length) return;
            if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); current = (current + 1) % all.length; update(); }
            else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); current = (current - 1 + all.length) % all.length; update(); }
          });
          update();
        }
        
      • scrollytelling.css 1.5 KB · in bundle
      • scrollytelling.js 2 KB
        /* === Scrollytelling Component === */
        
        document.addEventListener('DOMContentLoaded', () => {
          // --- IntersectionObserver for .reveal, .fade-in, .slide-left ---
          const observer = new IntersectionObserver(
            (entries) => {
              for (const entry of entries) {
                if (entry.isIntersecting) {
                  entry.target.classList.add('visible');
                  observer.unobserve(entry.target); // fire once
                }
              }
            },
            { threshold: 0.15, rootMargin: '0px' }
          );
        
          document.querySelectorAll('.reveal, .fade-in, .slide-left, .stagger-group')
            .forEach(el => observer.observe(el));
        
          // --- Animated Counters ---
          const counters = document.querySelectorAll('.counter');
          if (counters.length > 0) {
            const counterObserver = new IntersectionObserver((entries) => {
              for (const entry of entries) {
                if (entry.isIntersecting) {
                  const el = entry.target;
                  const target = parseInt(el.dataset.target, 10);
                  const suffix = el.dataset.suffix || '';
                  const duration = 800;
                  const start = performance.now();
        
                  function tick(now) {
                    const elapsed = now - start;
                    const progress = Math.min(elapsed / duration, 1);
                    const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
                    el.textContent = Math.round(target * eased).toLocaleString() + suffix;
                    if (progress < 1) requestAnimationFrame(tick);
                  }
        
                  requestAnimationFrame(tick);
                  counterObserver.unobserve(el);
                }
              }
            }, { threshold: 0.3 });
        
            counters.forEach(el => counterObserver.observe(el));
          }
        
          // --- Reading Progress Bar ---
          const progressFill = document.getElementById('progress-fill');
          if (progressFill) {
            window.addEventListener('scroll', () => {
              const scrollTop = window.scrollY;
              const docHeight = document.documentElement.scrollHeight - window.innerHeight;
              const progress = docHeight > 0 ? Math.min((scrollTop / docHeight) * 100, 100) : 0;
              progressFill.style.width = progress + '%';
            }, { passive: true });
          }
        });
        
      • slider.css 848 B · in bundle
      • tabs.css 750 B · in bundle
      • tabs.js 508 B
        /* === Tabs Component === */
        document.querySelectorAll('.tab').forEach(tab => {
          tab.addEventListener('click', () => {
            document.querySelectorAll('.tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); });
            document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
            tab.classList.add('active');
            tab.setAttribute('aria-selected', 'true');
            document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
          });
        });
        
      • theme-toggle.css 963 B · in bundle
      • theme-toggle.js 178 B
        /* === Theme Toggle Component === */
        function toggleTheme() {
          const html = document.documentElement;
          html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
        }
        
    • print
      • code-review-print.css 1.9 KB · in bundle
      • data-viz-print.css 1.5 KB · in bundle
      • deck-print.css 1.5 KB · in bundle
      • default-print.css 1.6 KB · in bundle
      • diagram-print.css 1.6 KB · in bundle
      • editor-print.css 2.1 KB · in bundle
      • prototype-print.css 1.4 KB · in bundle
      • report-print.css 1.8 KB · in bundle
      • spec-print.css 1.4 KB · in bundle
    • saved
      • business-review.html 3.4 KB · in bundle
      • business-review.slots.json 1.5 KB
        {
          "template": "business-review",
          "shape": "report",
          "description": "Business performance review: KPIs, segment results, strategic priorities, decisions, outlook.",
          "slots": [
            { "name": "TITLE", "required": true, "description": "Report title, plain text." },
            { "name": "KICKER", "required": false, "description": "Small uppercase label above the title (e.g. 'Q3 2026 Business Review')." },
            { "name": "SUBTITLE", "required": true, "description": "One-line summary shown under the title." },
            { "name": "META", "required": false, "description": "Inline meta spans, e.g. '<span>Period: Q3</span><span>Owner: Ops</span>'." },
            { "name": "SUMMARY", "required": true, "description": "TL;DR block: one or more <p> paragraphs." },
            { "name": "KPIS", "required": true, "description": "One or more .kpi cards: '<div class=\"kpi\"><div class=\"label\">Revenue</div><div class=\"value\">$4.2M</div><div class=\"delta\">+12%</div></div>'." },
            { "name": "SEGMENTS", "required": true, "description": "Segment results as an HTML <table> or paragraphs." },
            { "name": "PRIORITIES", "required": true, "description": "Strategic priorities as an <ul> or <ol>." },
            { "name": "DECISIONS", "required": true, "description": "Decisions made/needed as a list or table." },
            { "name": "OUTLOOK", "required": true, "description": "Forward outlook: <p> paragraphs, optionally a .callout." },
            { "name": "FOOTER", "required": false, "description": "Footer line: source, date, author." }
          ]
        }
        
      • github-issues.html 20.3 KB · in bundle
      • project-kickoff.html 3.6 KB · in bundle
      • project-kickoff.slots.json 1.7 KB
        {
          "template": "project-kickoff",
          "shape": "report",
          "description": "Project kickoff: agenda, proven foundation, scope and success criteria, workstreams and owners, milestone gates, open decisions, risks. Structure adapted from a proven kickoff information architecture.",
          "slots": [
            { "name": "TITLE", "required": true, "description": "Project name, plain text." },
            { "name": "KICKER", "required": false, "description": "Small uppercase label (e.g. 'Issue #907 · KICKOFF')." },
            { "name": "SUBTITLE", "required": true, "description": "One-line project goal." },
            { "name": "META", "required": false, "description": "Inline meta spans: DRI, date, phase." },
            { "name": "AGENDA", "required": true, "description": "Kickoff agenda as an <ol> or <ul>." },
            { "name": "FOUNDATION", "required": true, "description": "What is already delivered/proven: <p> or list, optionally 'Now in scope' contrast." },
            { "name": "SCOPE", "required": true, "description": "Scope and success criteria. Table or list; state parity/coverage/behavior/operations." },
            { "name": "WORKSTREAMS", "required": true, "description": "Workstreams with owners (DRIs) as an HTML <table>: name, description, owner." },
            { "name": "GATES", "required": true, "description": "One or more .gate cards: '<div class=\"gate\"><div class=\"g-label\">Gate 1</div><h4>Design locked</h4><p>...</p></div>'." },
            { "name": "DECISIONS", "required": true, "description": "Open decisions to resolve at kickoff, as a list or table." },
            { "name": "RISKS", "required": false, "description": "Risks and mitigations as a table or list." },
            { "name": "FOOTER", "required": false, "description": "Footer: source issue link, snapshot date." }
          ]
        }
        
      • README.md 1.8 KB
        # Saved templates — named, frozen HTML layouts
        
        A named starting layout the builder clones and fills, instead of regenerating
        structure from scratch. One skill, many template files: add a layout here to
        grow the gallery — never add a skill per template.
        
        ## How a saved template works
        
        Each entry is two files sharing a base name:
        
        | File | Role |
        |---|---|
        | `<name>.html` | Frozen layout. Content slots are `{{SLOT_NAME}}` markers. |
        | `<name>.slots.json` | Slot manifest: each slot's name, whether it is required, and a one-line description. |
        
        The layout (CSS, structure, chrome) is fixed. Only slot text changes.
        
        ## Fill a template (deterministic)
        
        ```
        python3 skills/meta/html-artifact/scripts/fill-template.py \
          --template business-review \
          --slots slots.json \
          --out artifact.html
        ```
        
        `slots.json` maps slot names to HTML/text values. The script:
        
        - Refuses if a required slot is missing (exit 1).
        - Refuses if a provided slot name is not declared in the manifest (exit 1) — no silent typos.
        - Substitutes every declared slot, then verifies no `{{...}}` markers remain (exit 1).
        - Never edits layout, CSS, or chrome. Content-vs-layout authority is enforced by the script, not by prompt discipline.
        
        ## Gallery
        
        | Template | Shape | Use |
        |---|---|---|
        | `business-review` | report | Performance/KPI review with segments, decisions, outlook |
        | `system-design` | report | Architecture doc: requirements, components, data flow, tradeoffs |
        | `github-issues` | data-viz | Rendered GitHub issue set (pre-existing) |
        
        ## Add a template
        
        1. Author `<name>.html` in the Birchline design system, marking content spots with `{{SLOT}}`.
        2. Author `<name>.slots.json` declaring every slot.
        3. Run `fill-template.py --template <name> --slots <sample>.json --out /tmp/check.html` and confirm no markers remain.
        4. Add a row to the gallery table above.
        
      • system-design.html 3.2 KB · in bundle
      • system-design.slots.json 1.6 KB
        {
          "template": "system-design",
          "shape": "report",
          "description": "System design doc: overview, requirements, architecture diagram, components, data flow, tradeoffs, operational considerations.",
          "slots": [
            { "name": "TITLE", "required": true, "description": "System/design name, plain text." },
            { "name": "KICKER", "required": false, "description": "Small uppercase label (e.g. 'RFC 012 · System Design')." },
            { "name": "SUBTITLE", "required": true, "description": "One-line description of what the system does." },
            { "name": "META", "required": false, "description": "Inline meta spans: author, status, date." },
            { "name": "OVERVIEW", "required": true, "description": "TL;DR overview: one or more <p> paragraphs." },
            { "name": "REQUIREMENTS", "required": true, "description": "Functional and non-functional requirements as a list or table." },
            { "name": "DIAGRAM", "required": true, "description": "Inline SVG or ASCII architecture diagram. Sits inside a .diagram box." },
            { "name": "COMPONENTS", "required": true, "description": "Components as an HTML <table>: name, responsibility, tech." },
            { "name": "DATA_FLOW", "required": true, "description": "Data flow: ordered <ol> or paragraphs describing request/event path." },
            { "name": "TRADEOFFS", "required": true, "description": "Design tradeoffs and rejected alternatives, as a table or list." },
            { "name": "OPERATIONS", "required": true, "description": "Operational considerations: scaling, failure modes, monitoring, rollback." },
            { "name": "FOOTER", "required": false, "description": "Footer: source, date, author." }
          ]
        }
        
    • shapes
      • code-review.css 2.7 KB · in bundle
      • data-viz.css 2.3 KB · in bundle
      • deck.css 1.9 KB · in bundle
      • diagram.css 2.7 KB · in bundle
      • editor.css 2.6 KB · in bundle
      • prototype.css 2.5 KB · in bundle
      • report.css 2.9 KB · in bundle
      • spec.css 2.9 KB · in bundle
    • themes
      • birchline.css 1.5 KB · in bundle
      • dark-focus.css 2 KB · in bundle
      • interactive-warm.css 2.7 KB · in bundle
      • minimal-document.css 2.2 KB · in bundle
    • base-reset.css 1.2 KB · in bundle
  • EVAL.md 6.3 KB
    # html-artifact Evaluation Cases
    
    Repeatable evaluation cases for the html-artifact skill. Used by `skill-eval` to measure shape detection accuracy, generation quality, and routing correctness.
    
    ---
    
    ## Should-Trigger Prompts
    
    Requests that MUST activate html-artifact and produce the expected shape.
    
    | # | Prompt | Expected Shape | Key Assertion |
    |---|---|---|---|
    | 1 | "Explore 3 different approaches to implement rate limiting" | spec | Comparison grid with 3 columns, pro/con per option, recommendation section |
    | 2 | "Help me review PR #42, annotate the diff" | code-review | Diff with line numbers, severity-colored annotations, file jump links |
    | 3 | "Prototype a checkout button animation with sliders to tune it" | prototype | Interactive sliders, live preview, export/copy button |
    | 4 | "Write a weekly status report for the team" | report | TL;DR box at top, collapsible sections, metric callouts if applicable |
    | 5 | "I need to reprioritize these 30 tickets across Now/Next/Later/Cut" | editor | Drag-drop or form controls, state persistence, export buttons (Markdown + JSON + Prompt) |
    | 6 | "Show me test coverage trends over the last 6 months" | data-viz | SVG chart, legend, tooltips, filter controls |
    | 7 | "Make an HTML artifact explaining how our auth flow works" | report | Explicit trigger via "HTML artifact"; report shape for explanatory content |
    | 8 | "Create an interactive feature flag editor" | editor | Form-based editing, toggle switches, export buttons |
    
    ---
    
    ## Should-NOT-Trigger Prompts
    
    Requests that route elsewhere instead of activating html-artifact.
    
    | # | Prompt | Why Not | Correct Route |
    |---|---|---|---|
    | 1 | "Fix the bug in auth.ts" | Code fix, not visualization | typescript-frontend-engineer |
    | 2 | "Run the tests" | Test execution, not output generation | test runner / quick |
    | 3 | "Write this as markdown" | Explicit markdown request | Standard markdown output |
    | 4 | "Create an interactive essay about caching" | Self-contained HTML with scroll animations | html-artifact skill with scrollytelling-patterns.md |
    | 5 | "Make a slide deck for the conference" | Presentation deck | distinctive-frontend-design skill |
    | 6 | "Build a React component for the login page" | Framework component | typescript-frontend-engineer |
    
    ---
    
    ## Behavioral Expectations Per Shape
    
    ### spec
    
    | Must Have | Must NOT Have |
    |---|---|
    | N-column comparison grid (2-5 columns) | External dependencies or build steps |
    | Pro/Con section per option | Generic "Option A / Option B" without substance |
    | Metadata badges (complexity, risk, timeline) | Hardcoded colors or spacing |
    | Recommendation section at bottom | Missing mobile layout (stacked columns) |
    
    ### code-review
    
    | Must Have | Must NOT Have |
    |---|---|
    | Diff with line numbers | Broken syntax highlighting |
    | Severity-colored annotations (critical/warning/info) | External CDN for highlight.js or similar |
    | File navigation / jump links | Missing line number alignment |
    | Risk map overview | Diff without context lines |
    
    ### prototype
    
    | Must Have | Must NOT Have |
    |---|---|
    | Interactive controls (sliders, selectors, toggles) | Missing export/copy button |
    | Live preview that updates with controls | Controls that do not update preview |
    | Export/Copy button | Framework imports |
    | Responsive layout | Hardcoded animation values without control |
    
    ### report
    
    | Must Have | Must NOT Have |
    |---|---|
    | TL;DR box visible without scrolling | Wall of unstyled text |
    | Collapsible sections (default collapsed) | All sections expanded by default |
    | Metric callouts for key numbers (if applicable) | Numbers buried in paragraphs |
    | Table of contents with jump links | Missing section structure |
    
    ### editor
    
    | Must Have | Must NOT Have |
    |---|---|
    | Drag-drop or form-based editing | No export mechanism |
    | State persistence (survives re-ordering) | State loss on interaction |
    | Export buttons: Markdown + JSON + Prompt (min 2 formats) | Single export format only |
    | Visual feedback on state changes | Silent state changes |
    
    ### data-viz
    
    | Must Have | Must NOT Have |
    |---|---|
    | SVG charts (unless >1000 data points) | Canvas for simple datasets |
    | Legend with labels | External charting libraries (Chart.js, D3 CDN) |
    | Tooltips on data points | Charts without axis labels |
    | Filter controls (if multiple series) | Static image with no interactivity |
    
    ---
    
    ## Quality Checks (All Shapes)
    
    These checks apply to every generated artifact regardless of shape.
    
    | Check | Method | Pass Criterion |
    |---|---|---|
    | Structural validity | `validate-artifact.py` | Exit code 0 |
    | File size | `validate-artifact.py` | < 500KB |
    | No external deps | `validate-artifact.py` | No `src=` or `href=` to external URLs |
    | Has `<title>` | `validate-artifact.py` | Non-empty, descriptive title |
    | Has charset meta | `validate-artifact.py` | `<meta charset="utf-8">` present |
    | Has viewport meta | `validate-artifact.py` | `<meta name="viewport">` present |
    | Responsive | Manual / browser test | Renders at 375px and 1440px without horizontal scroll |
    | Keyboard accessible | Manual / browser test | Tab through all interactive elements |
    | No console errors | Browser DevTools | Zero errors on load and interaction |
    | Design tokens used | Grep source | CSS custom properties, not hardcoded values |
    | Reduced motion | Grep source | `prefers-reduced-motion` media query present |
    
    ---
    
    ## Shape Detection Accuracy
    
    Test `detect-shape.py` independently with these inputs:
    
    | Input | Expected | Notes |
    |---|---|---|
    | "explore 3 auth approaches" | spec | Primary signal: "explore", "approaches" |
    | "compare rate limiting strategies" | spec | Primary signal: "compare" |
    | "review the diff for PR 42" | code-review | Primary signal: "review", "diff", "PR" |
    | "annotate this code change" | code-review | Primary signal: "annotate", "code" |
    | "prototype a button hover effect" | prototype | Primary signal: "prototype" |
    | "tune the animation timing" | prototype | Primary signal: "tune" |
    | "weekly team status update" | report | Primary signal: "status", "report" |
    | "explain how the auth flow works" | report | Primary signal: "explain" |
    | "triage these 20 bugs by priority" | editor | Primary signal: "triage", "priority" |
    | "reorder the feature backlog" | editor | Primary signal: "reorder" |
    | "chart our deploy frequency" | data-viz | Primary signal: "chart" |
    | "show error rate trends" | data-viz | Primary signal: "trends" |
    
  • SKILL.md 17.2 KB
    ---
    name: frontend
    description: "Frontend: UI design, distinctive visual styles, HTML artifacts, Three.js 3D."
    user-invocable: true
    allowed-tools:
      - Read
      - Write
      - Bash
      - Grep
      - Glob
      - Edit
      - Task
      - Agent
      - Skill
    routing:
      force_route: false
      not_for: "game UI (use game-dev), backend work (use workflow), brand or logo design without code"
      triggers:
        - "design"
        - "UX copy"
        - "design system"
        - "accessibility"
        - "WCAG"
        - "design handoff"
        - "frontend design"
        - "visual identity"
        - "HTML artifact"
        - "make HTML"
        - "self-contained HTML"
        - "threejs"
        - "three.js"
        - "3D web"
        - "3D scene"
        - "WebGL"
        - "WebGPU"
        - "react three fiber"
        - "r3f"
        - "text animation"
        - "card effects"
        - "pptx"
        - "powerpoint"
        - "slide deck"
        - "design critique"
      category: frontend
      pairs_with:
        - typescript-frontend-engineer
        - ui-frontend-engineer
        - game-dev
    ---
    
    # Frontend Skill
    
    Four modes: **Design** (UX copy, design systems, critique, accessibility, handoff,
    research), **Distinctive** (context-driven aesthetic exploration with anti-cliche
    validation), **HTML-Artifact** (self-contained HTML generation with 8 shapes), and
    **Three.js** (3D web apps in imperative, R3F, or WebGPU paradigms).
    
    ## Mode Selection
    
    Classify the request into one mode before loading references.
    
    | Mode | Signals | What to Load |
    |------|---------|-------------|
    | **DESIGN** | UX copy, design system, design critique, accessibility, WCAG, design handoff, user research | Design-refs per sub-mode table below |
    | **DISTINCTIVE** | Frontend design, typography, visual identity, anti-cliche, text animation, card effects | `references/distinctive-frontend-design-refs/` per phase |
    | **HTML-ARTIFACT** | HTML artifact, make HTML, as HTML, rich visualization, interactive document, pptx, deck | `references/html-artifact-refs/` per shape |
    | **THREEJS** | Three.js, 3D scene, WebGL, WebGPU, react three fiber, R3F, GLTF | `references/threejs-builder-refs/` per paradigm |
    
    ---
    
    ## DESIGN Mode
    
    Design methodology: UX copy, design systems, critique, accessibility review,
    developer handoff, user research synthesis. Always load
    `references/design-refs/llm-design-failure-modes.md` alongside mode-specific
    reference.
    
    ### Sub-mode Detection
    
    | Sub-mode | Signals | Load |
    |----------|---------|------|
    | UX-COPY | write copy, button text, error message, empty state, tooltip | `references/design-refs/ux-copy.md` |
    | DESIGN-SYSTEM | design tokens, component library, audit components, theme | `references/design-refs/design-systems.md` |
    | CRITIQUE | review design, critique mockup, design feedback, usability | `references/design-refs/design-critique.md` |
    | ACCESSIBILITY | WCAG, accessibility audit, color contrast, keyboard nav, a11y | `references/design-refs/accessibility-review.md` |
    | HANDOFF | developer handoff, spec sheet, implementation spec, responsive | `references/design-refs/design-handoff.md` |
    | RESEARCH | synthesize research, interview analysis, usability findings | (inline below) |
    
    ### UX-COPY
    
    1. Gather context: component type, user emotional state, brand voice, constraints, existing terminology.
    2. Generate copy: primary recommendation with rationale, 2-3 alternatives, localization notes.
    3. Validate: terminology consistency, action labels match outcomes, error messages follow What/Why/Fix, character limits met.
    
    **Gate**: Copy for all requested components. Each piece has rationale. Alternatives differentiated.
    
    ### DESIGN-SYSTEM
    
    Three operations: **Audit** (naming consistency, token coverage, hardcoded values, state completeness), **Document** (props/variants/states/a11y/usage spec), **Extend** (new component using existing tokens). Use design token architecture: color, typography, spacing, borders, shadows, motion.
    
    Validate: consistent naming, all values reference tokens, all states defined (default/hover/active/disabled/loading/error), ARIA documented.
    
    ### CRITIQUE
    
    Four-step method: Describe (elements, layout -- no judgment), Analyze (hierarchy, contrast, alignment), Interpret (emotional tone, brand alignment), Evaluate (recommendations). Apply Nielsen's 10 heuristics to the specific design. Match feedback depth to stage: exploration (concept direction), refinement (hierarchy, patterns), final (contrast, spacing, a11y).
    
    ### ACCESSIBILITY
    
    Audit by WCAG principle: Perceivable (alt text 1.1.1, contrast 4.5:1 text / 3:1 UI), Operable (keyboard 2.1.1, focus order 2.4.3, touch 44x44px), Understandable (predictable 3.2.1, error ID 3.3.1), Robust (name/role/value 4.1.2). Report: severity matrix, each finding with WCAG criterion + remediation, contrast table, keyboard map.
    
    ### HANDOFF
    
    Gather: design source, tech stack, tokens, breakpoints. Generate spec covering: layout, tokens, components, states, interactions, content limits, edge cases (empty/loading/error/overflow/i18n), accessibility, animation. Validate: all states documented, token references used, edge cases present.
    
    ### RESEARCH
    
    Accept inputs (transcripts, surveys, support tickets). Extract observations and quotes -- behavioral data outweighs stated preferences. Synthesize via affinity mapping and theme development. Priority matrix (impact x frequency). Output: executive summary, themes with evidence, insights-to-opportunities table, user segments, recommendations.
    
    ### Output Conventions
    
    Markdown with tables. Severity: Critical (blocks users), Major (degrades), Minor (polish). Every recommendation names element + issue + concrete fix. Include what works alongside what needs improvement.
    
    ---
    
    ## DISTINCTIVE Mode
    
    Systematic aesthetic exploration producing validated design specifications. Every
    choice flows from project context, not defaults. Seven phases with gates.
    
    ### Phase 1: Context Discovery
    
    1. Gather: purpose, surface type (landing vs app/dashboard), audience, emotion, cultural context, constraints, tech stack, real content, previous projects.
    2. Pick one macrostructure from `references/distinctive-frontend-design-refs/macrostructure-catalog.md` by heading anchor. Load only the chosen entry.
    3. Define 3-5 aesthetic directions using `references/distinctive-frontend-design-refs/color-inspirations.json` and `references/distinctive-frontend-design-refs/font-catalog.json`. See `references/distinctive-frontend-design-refs/phase-details.md` for examples.
    4. Write narrative brief: visual thesis, content plan, interaction thesis.
    
    **Gate**: Macrostructure chosen, aesthetic direction justified, narrative brief written.
    
    ### Phase 2: Typography
    
    Load `references/distinctive-frontend-design-refs/font-catalog.json`. Banned fonts: Inter, Roboto, Arial, Helvetica, system fonts, Space Grotesk. Select pairing per `references/distinctive-frontend-design-refs/phase-details.md`. Two typefaces max. Validate against banned list.
    
    ### Phase 3: Color Palette
    
    Research inspiration via `references/distinctive-frontend-design-refs/color-inspirations.json`. Build with strict 60/30/10 dominance (dominant, secondary, accent). One accent color only. Check against cliche list in `references/distinctive-frontend-design-refs/preferred-patterns.json`.
    
    ### Phase 4: Animation Strategy
    
    2-to-3 rule: ship 2-3 intentional motions per page. Fill three slots (entrance, scroll, interaction). Load `references/distinctive-frontend-design-refs/animation-patterns.md` for patterns. Define easing and timing per `references/distinctive-frontend-design-refs/phase-details.md`.
    
    ### Phase 5: Hero & Background
    
    First viewport reads as one composition. See `references/distinctive-frontend-design-refs/app-vs-landing-rules.md` for surface-specific rules. Choose technique from `references/distinctive-frontend-design-refs/background-techniques.md`. Minimum 2 layers. Check contrast against WCAG AA.
    
    ### Phase 6: Validation
    
    Run: `python3 ${CLAUDE_SKILL_DIR}/scripts-distinctive-frontend-design/validate_design.py --fonts "Display,Body" --palette palette.json --project NAME --macrostructure macro:ID --animation --background --emitted-css generated.html`
    
    Score must reach 80 (Grade B+). If below, iterate on failed checks.
    
    ### Phase 7: Specification Output
    
    Emit design stamp comment. Generate CSS custom properties (tokens). Create base styles. Document specification. Provide framework-specific starter code if requested (see `references/distinctive-frontend-design-refs/implementation-examples.md`).
    
    ---
    
    ## HTML-ARTIFACT Mode
    
    Generate single self-contained `.html` files. All CSS in `<style>`, all JS in
    `<script>`. No CDN links, no frameworks, no external deps. Max 500KB.
    
    ### Phase 0: Check Saved Template
    
    Run: `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/fill-template.py --list`
    
    If a saved template matches, clone it and fill slots only. Skip to Phase 4 VALIDATE. Do not restyle the template.
    
    ### Phase 1: Detect Shape
    
    Run: `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/detect-shape.py --request "{request}"`
    
    | Shape | Signals | Output |
    |-------|---------|--------|
    | spec | plan, compare, brainstorm | Side-by-side grids, pro/con, SVG diagrams |
    | code-review | review PR, explain diff | Diff rendering, severity colors, annotations |
    | prototype | prototype, tune, try options | Sliders, CSS var live update, sandbox |
    | report | report, summarize, status | TL;DR box, collapsibles, timeline, metrics |
    | editor | reorder, triage, edit config | Drag-drop, kanban, toggles, export |
    | data-viz | visualize, chart, dashboard | SVG charts, canvas, tooltips, filters |
    | diagram | diagram, flowchart, architecture | Inline SVG, annotated flowcharts |
    | deck | slides, presentation, pitch | Arrow-key nav, 16:9, progress bar |
    
    Low confidence falls back to report. Hybrid shapes: primary controls layout, secondary provides embedded components.
    
    ### Phase 2: Assemble + Load Context
    
    Run: `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/assemble-template.py --shape {shape} --title "{title}" --components {components}`
    
    Always load `references/html-artifact-refs/design-system.md` and `references/html-artifact-refs/interaction-patterns.md`. Load shape-specific reference from `references/html-artifact-refs/shape-{name}.md`.
    
    ### Phase 3: Generate
    
    Dispatch html-builder agent (see `agents/html-builder.md`) with pre-assembled template. Vanilla JS only. Semantic HTML. SVG inline.
    
    ### Phase 4: Validate
    
    Run: `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/validate-artifact.py {html_file}`
    
    Checks: valid HTML structure, no external deps, has `<title>`, charset, viewport, under 500KB, no broken internal refs, CSS slop scan. Fix failures and re-run (max 3 attempts).
    
    ### Phase 5: Deliver
    
    Print absolute file path, 1-line summary, offer browser open. Check `$DISPLAY`/`$SSH_TTY` before offering open on Linux.
    
    ### Phase 6-7: Export (optional)
    
    PDF: `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/to-pdf.py --input <html> --output <pdf> --json`. PPTX (deck shape only): `python3 ${CLAUDE_SKILL_DIR}/scripts-html-artifact/pptx-bridge/run-unified.py --input <html> --format pptx --out <pptx> --no-render`.
    
    ---
    
    ## THREEJS Mode
    
    Build Three.js web applications in four phases: Design, Build, Animate, Polish.
    Three paradigms detected from context.
    
    ### Phase 1: Design
    
    Detect paradigm first:
    
    | Signal | Paradigm | Load |
    |--------|----------|------|
    | `@react-three/fiber`, r3f, drei, `useFrame` | React Three Fiber | `references/threejs-builder-refs/react-three-fiber.md` |
    | `WebGPURenderer`, TSL, compute shader | WebGPU | `references/threejs-builder-refs/webgpu.md` |
    | Standalone HTML, `new THREE.Scene()`, vanilla | Imperative | `references/threejs-builder-refs/advanced-topics.md` |
    | EventBus, GameState, player controller | Game (alongside paradigm) | `references/threejs-builder-refs/game-architecture.md` + `game-patterns.md` |
    | GLTF/GLB, `.glb`, skeletal rigs | GLTF (alongside paradigm) | `references/threejs-builder-refs/gltf-loading.md` |
    
    If ambiguous, ask -- imperative and R3F patterns conflict. Identify core visual element, select components per `references/threejs-builder-refs/build-recipes.md`, document visual style.
    
    ### Phase 2: Build
    
    Follow paradigm-specific patterns from loaded reference. Imperative defaults: single HTML, CONFIG object, three-point lighting, `renderer.setAnimationLoop()`. See `references/threejs-builder-refs/build-recipes.md` for boilerplate, scene infrastructure, constraints.
    
    ### Phase 3: Animate
    
    R3F uses `useFrame` (never `requestAnimationFrame`). Imperative uses `setAnimationLoop`. No geometry/material allocation in animation loop. Wire interaction handlers per scene plan.
    
    ### Phase 4: Polish
    
    Remove debug helpers. Handle window resize. Verify visible lighting. Match visual style. Run 4 verification steps: responsive, visual quality, output test, cleanup. See `references/threejs-builder-refs/build-recipes.md`.
    
    ---
    
    ## Deep References
    
    Load on demand when the task needs detailed patterns, examples, or specifications:
    
    ### Design Refs
    
    | File | Content |
    |------|---------|
    | `references/design-refs/ux-copy.md` | Component-specific UX copy patterns |
    | `references/design-refs/design-systems.md` | Design token architecture, component specs |
    | `references/design-refs/design-critique.md` | Structured critique method, heuristics |
    | `references/design-refs/accessibility-review.md` | WCAG criteria, component a11y patterns |
    | `references/design-refs/design-handoff.md` | Spec categories, artifact templates |
    | `references/design-refs/llm-design-failure-modes.md` | 8 LLM failure modes with defenses |
    
    ### Distinctive Frontend Design Refs
    
    | File | Content |
    |------|---------|
    | `references/distinctive-frontend-design-refs/font-catalog.json` | Curated fonts by aesthetic category |
    | `references/distinctive-frontend-design-refs/color-inspirations.json` | Cultural/contextual palette sources |
    | `references/distinctive-frontend-design-refs/animation-patterns.md` | Animation choreography with CSS/React |
    | `references/distinctive-frontend-design-refs/background-techniques.md` | Atmospheric background methods |
    | `references/distinctive-frontend-design-refs/implementation-examples.md` | CSS tokens, framework templates |
    | `references/distinctive-frontend-design-refs/macrostructure-catalog.md` | Named macro:* page structures |
    | `references/distinctive-frontend-design-refs/phase-details.md` | Selection processes, validation, timing |
    | `references/distinctive-frontend-design-refs/css-audit-patterns.md` | Detection commands for CSS slop |
    | `references/distinctive-frontend-design-refs/performance-budgets.md` | Render costs, layout thrashing |
    | `references/distinctive-frontend-design-refs/game-ui-polish.md` | Game-native UI polish rules |
    | `references/distinctive-frontend-design-refs/card-shader-patterns.md` | Fragment shader GLSL |
    | `references/distinctive-frontend-design-refs/shader-integration-react.md` | React 19 WebGL hook + context pool |
    | `references/distinctive-frontend-design-refs/balatro-shader-breakdown.md` | Holographic foil shader |
    | `references/distinctive-frontend-design-refs/roll-text.md` | Roll/slot text patterns |
    | `references/distinctive-frontend-design-refs/text-animation-patterns.md` | Reveal, typewriter, crossfade |
    | `references/distinctive-frontend-design-refs/oklch-color-harmony.md` | OKLCH color harmony |
    | `references/distinctive-frontend-design-refs/honest-placeholders.md` | Placeholder content rules |
    
    ### HTML Artifact Refs
    
    | File | Content |
    |------|---------|
    | `references/html-artifact-refs/design-system.md` | Theme tokens, a11y checklist, SVG rules |
    | `references/html-artifact-refs/diagram-layering.md` | SVG layer order, dark-theme colors |
    | `references/html-artifact-refs/infographic-layouts.md` | 21 layout types, 22 visual styles |
    | `references/html-artifact-refs/pdf-export.md` | Page-size table, troubleshooting |
    | `references/html-artifact-refs/pptx-export.md` | Layout types, THEME dict, CLI ref |
    | `references/html-artifact-refs/shape-diagram-illustration.md` | SVG construction, diagram types |
    
    ### Three.js Refs
    
    | File | Content |
    |------|---------|
    | `references/threejs-builder-refs/build-recipes.md` | Boilerplate, scene setup, error handling |
    | `references/threejs-builder-refs/react-three-fiber.md` | R3F patterns, Drei, Zustand |
    | `references/threejs-builder-refs/webgpu.md` | WebGPURenderer, TSL, compute shaders |
    | `references/threejs-builder-refs/advanced-topics.md` | GLTF, post-processing, shaders, physics |
    | `references/threejs-builder-refs/visual-polish.md` | Materials, lighting, HDR, shadows |
    | `references/threejs-builder-refs/gltf-loading.md` | Coordinate contract, caching, auto-center |
    | `references/threejs-builder-refs/game-patterns.md` | Animation FSM, camera movement, input |
    | `references/threejs-builder-refs/game-architecture.md` | EventBus, GameState, pre-ship checklist |
    | `references/threejs-builder-refs/shader-patterns.md` | ShaderMaterial, vertex displacement, effects |
    | `references/threejs-builder-refs/performance-patterns.md` | InstancedMesh, batching, LOD, dispose |
    | `references/threejs-builder-refs/advanced-animation.md` | AnimationMixer, IK, spring physics, GSAP |
    
    ## Scripts
    
    - Distinctive design: `scripts-distinctive-frontend-design/validate_design.py`, `scripts-distinctive-frontend-design/css_slop_rules.py`
    - HTML artifact: `scripts-html-artifact/detect-shape.py`, `scripts-html-artifact/assemble-template.py`, `scripts-html-artifact/validate-artifact.py`, `scripts-html-artifact/fill-template.py`, `scripts-html-artifact/to-pdf.py`, `scripts-html-artifact/pptx-bridge/`
    - HTML artifact templates: `templates/`, agents: `agents/html-builder.md`
    
  • SPEC.md 4.4 KB
    # html-artifact Specification
    
    ## Purpose
    
    Generate rich self-contained HTML artifacts instead of markdown when output benefits from visual structure, interactivity, or information density.
    
    ## Scope
    
    **IN:**
    - Single self-contained `.html` files for 6 shapes: spec, code-review, prototype, report, editor, data-viz
    - Auto-detection of artifact shape from user request
    - Birchline design system with 4 theme presets
    - Interactive elements: tabs, collapsibles, drag-drop, sliders, copy buttons
    - SVG diagrams and charts (inline, no external deps)
    
    **OUT:**
    - Multi-page sites, framework apps, deployment artifacts
    - Anything requiring npm, build steps, or external dependencies
    - Presentation decks (use `distinctive-frontend-design` skill)
    - Application UIs with backend integration
    
    ## Non-Goals
    
    - Not a web app builder — no npm, no build steps, no server-side logic
    - For scroll-triggered animations, use the `scrollytelling-patterns.md` reference instead of full Vite+React projects
    - Not a replacement for `distinctive-frontend-design` (presentation decks)
    - Not forced — user opts out with "as markdown" or "in markdown"
    - Not a charting library — SVG generation is inline, not a reusable API
    
    ## Invariants
    
    1. Every artifact is a SINGLE `.html` file with no external dependencies
    2. All CSS inline in `<style>`, all JS inline in `<script>`
    3. `detect-shape.py` classification is deterministic (same input produces same shape)
    4. `validate-artifact.py` checks run before delivery
    5. Editor and prototype shapes MUST include export/copy buttons
    6. All artifacts use Birchline design tokens (not hardcoded values)
    7. File size under 500KB
    8. Semantic HTML structure with accessibility support
    
    ## Pipeline
    
    ```
    Phase 1: DETECT SHAPE    → scripts/detect-shape.py (deterministic)
    Phase 2: LOAD CONTEXT    → design-system.md + interaction-patterns.md + shape-*.md
    Phase 3: GENERATE        → agents/html-builder.md subagent
    Phase 4: VALIDATE        → scripts/validate-artifact.py (deterministic)
    Phase 5: DELIVER         → file path + browser open offer
    ```
    
    ## Dependencies
    
    | Dependency | Required | Purpose |
    |---|---|---|
    | Python 3.10+ | Yes | Shape detection, artifact validation |
    | External Python packages | No | Scripts use stdlib only |
    | Node.js / npm | No | Not used |
    | `xdg-open` / `open` | Optional | Browser preview |
    
    ## Success Criteria
    
    | Criterion | Measurement |
    |---|---|
    | Renders correctly | Chrome, Firefox, Safari — no console errors |
    | Self-contained | No network requests on load (validate-artifact.py check) |
    | Interactive | All controls respond to user input without external deps |
    | File size | < 500KB (validate-artifact.py check) |
    | Validation | `validate-artifact.py` exits 0 |
    | Shareable | User can email/share the `.html` file directly — no build required |
    | Accessible | Keyboard navigation works; ARIA labels present; reduced-motion respected |
    | Responsive | Works at 375px (mobile) and 1440px (desktop) |
    
    ## Router Integration
    
    | Mechanism | Details |
    |---|---|
    | Auto-detect | `/do` Phase 3 ENHANCE injects when output benefits from HTML |
    | Explicit | User types `/html [description]` |
    | Opt-out | User says "as markdown" or "in markdown" |
    | Shape override | `/html --shape=<name> <description>` |
    
    ## File Layout
    
    ```
    skills/meta/html-artifact/
    ├── SKILL.md                              # Orchestrator (5-phase pipeline)
    ├── SPEC.md                               # This file
    ├── EVAL.md                               # Evaluation cases
    ├── agents/
    │   └── html-builder.md                   # Subagent: generates the HTML
    ├── references/
    │   ├── design-system.md                  # Birchline CSS tokens, themes
    │   ├── interaction-patterns.md           # Shared JS patterns
    │   ├── shape-spec-exploration.md         # Spec shape patterns
    │   ├── shape-code-review.md              # Code review shape patterns
    │   ├── shape-design-prototype.md         # Prototype shape patterns
    │   ├── shape-report-research.md          # Report shape patterns
    │   ├── shape-custom-editor.md            # Editor shape patterns
    │   └── shape-data-visualization.md       # Data viz shape patterns
    ├── scripts/
    │   ├── detect-shape.py                   # Deterministic shape classifier
    │   └── validate-artifact.py              # Post-generation validator
    └── assets/                               # (reserved for templates)
    ```
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related