diff --git a/skills/INDEX.json b/skills/INDEX.json index 05bd0d28..03b36a67 100644 --- a/skills/INDEX.json +++ b/skills/INDEX.json @@ -1,6 +1,6 @@ { "version": "2.0", - "generated": "2026-05-06T18:04:11Z", + "generated": "2026-05-08T22:32:26Z", "generated_by": "scripts/generate-skill-index.py", "skills": { "csuite": { @@ -1494,6 +1494,26 @@ "codebase-overview" ] }, + "html-artifact": { + "file": "skills/meta/html-artifact/SKILL.md", + "description": "Generate rich self-contained HTML artifacts instead of markdown.", + "triggers": [ + "HTML artifact", + "make HTML", + "as HTML", + "rich visualization", + "interactive document", + "HTML file", + "self-contained HTML" + ], + "category": "meta", + "user_invocable": false, + "pairs_with": [ + "pr-workflow", + "research-pipeline", + "planning" + ] + }, "install": { "file": "skills/meta/install/SKILL.md", "description": "Verify VexJoy Agent installation, diagnose issues, and guide first-time setup.", diff --git a/skills/meta/html-artifact/EVAL.md b/skills/meta/html-artifact/EVAL.md new file mode 100644 index 00000000..b666bba8 --- /dev/null +++ b/skills/meta/html-artifact/EVAL.md @@ -0,0 +1,134 @@ +# 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 must NOT activate 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" | Full Vite+React project | interactive-essay skill | +| 5 | "Make a slide deck for the conference" | Presentation deck | frontend-slides 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 `` | `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" | diff --git a/skills/meta/html-artifact/SKILL.md b/skills/meta/html-artifact/SKILL.md new file mode 100644 index 00000000..fc605b97 --- /dev/null +++ b/skills/meta/html-artifact/SKILL.md @@ -0,0 +1,258 @@ +--- +name: html-artifact +description: | + Generate rich self-contained HTML artifacts instead of markdown. Auto-detects + artifact shape (spec, code-review, prototype, report, editor, data-viz) and + loads shape-specific patterns. Bundles Birchline design system with 4 theme + presets. Use for "make HTML", "as HTML", "HTML artifact", or auto-injected + by router when output benefits from rich visualization. +user_invocable: true # justification: users type "/html" directly for explicit + # HTML output; also auto-injected by /do router enhancement +command: /html +argument-hint: "[description of what to generate]" +routing: + triggers: + - HTML artifact + - make HTML + - as HTML + - rich visualization + - interactive document + - HTML file + - self-contained HTML + pairs_with: + - pr-workflow + - research-pipeline + - planning + complexity: Medium + category: meta +--- + +# /html - Self-Contained HTML Artifacts + +Generate single self-contained `.html` files that replace markdown when the output needs color, interactivity, layout, or visualization. Auto-detect artifact shape from the request, load shape-specific patterns, generate, validate, deliver. + +**Core constraint:** Every artifact is ONE `.html` file. All CSS in `<style>`, all JS in `<script>`. No CDN links, no frameworks, no build steps, no external dependencies. Works offline, opens in any browser. + +--- + +## Instructions + +### Overview + +5-phase pipeline: DETECT SHAPE, LOAD CONTEXT, GENERATE, VALIDATE, DELIVER. Phase 1 classifies the request into one of 6 shapes via deterministic script. Phase 2 loads the Birchline design system plus shape-specific reference. Phase 3 dispatches a subagent to generate the HTML. Phase 4 validates structure. Phase 5 delivers the file path and offers browser preview. + +--- + +### Phase 1: DETECT SHAPE + +Classify the user's request into one of 6 artifact shapes. + +Run: `python3 skills/meta/html-artifact/scripts/detect-shape.py --request "{user_request}"` + +The script outputs a shape name and confidence score. + +| Shape | Trigger Signals | What It Produces | +|---|---|---| +| spec | plan, explore options, compare N approaches, brainstorm | Side-by-side grids, Pro/Con badges, SVG data-flow diagrams, risk tables | +| code-review | review PR, explain diff, annotate code, understand module | Diff rendering, severity colors, margin annotations, jump links | +| prototype | prototype, animation, tune, try options, component variants | Sliders, CSS var live update, animation sandbox, contact sheets | +| report | report, summarize, status update, explain how X works, incident | TL;DR box, collapsible sections, timeline, metric callouts, SVG diagrams | +| editor | reorder, triage, edit config, tune prompt, pick values | Drag-drop, kanban, toggle switches, split-pane, export buttons | +| data-viz | visualize, chart, dashboard, show data, trends | SVG charts, canvas, interactive tooltips, filter controls | + +Gate: Shape detected with medium+ confidence. +-- because low-confidence classification produces artifacts that mix concerns and satisfy no shape well. Fallback to "report" (safest general-purpose shape) if confidence is low or ambiguous. + +--- + +### Phase 2: LOAD CONTEXT + +Load the design system and shape-specific reference files. + +**Always load (every invocation):** +1. `references/design-system.md` -- Birchline CSS tokens, 4 theme presets, typography scale, color system +2. `references/interaction-patterns.md` -- Shared JS patterns: tabs, collapsibles, drag-drop, copy buttons, keyboard nav + +**Load per detected shape:** + +| Shape | Reference File | Key Content | +|---|---|---| +| spec | `references/shape-spec-exploration.md` | Grid layouts, comparison cards, SVG flow diagrams | +| code-review | `references/shape-code-review.md` | Diff rendering, severity system, annotations | +| prototype | `references/shape-design-prototype.md` | Sliders, live preview, animation sandbox | +| report | `references/shape-report-research.md` | TL;DR boxes, collapsibles, timelines, metrics | +| editor | `references/shape-custom-editor.md` | Drag-drop, forms, export buttons, live re-render | +| data-viz | `references/shape-data-visualization.md` | SVG charts, canvas, tooltips, filters | + +Gate: All required references loaded (design-system + interaction-patterns + shape-specific). +-- because generating without the design system produces inconsistent visual output, and generating without shape patterns produces generic HTML that defeats the purpose. + +--- + +### Phase 3: GENERATE + +Dispatch the html-builder subagent to produce the artifact. + +1. Read `agents/html-builder.md` for the subagent prompt +2. Dispatch with: detected shape, design system tokens, interaction patterns, shape-specific patterns, user request +3. Agent writes a single `.html` file to the project directory (or `/tmp/html-artifacts/` if no project context) + +**Self-contained file constraints (inline here because they govern generation):** + +| Constraint | Reason | +|---|---| +| All CSS in `<style>` tag | No external stylesheets -- file must work offline | +| All JS in `<script>` tag | No CDN imports -- no React, Vue, Tailwind CDN, Bootstrap CDN | +| Vanilla JS only | Single file, no build step, no transpilation | +| Must include `<title>` | Browser tab identification, validation requirement | +| Must include `<meta charset="utf-8">` | Consistent rendering across platforms | +| Must include `<meta name="viewport">` | Responsive on mobile/tablet | +| Semantic HTML sections | `<header>`, `<main>`, `<section>`, `<footer>` for structure | +| SVG inline, not `<img src>` | No external file references | +| Max 500KB file size | Keeps generation time reasonable, prevents bloated inline assets | + +Constraint: No framework boilerplate. +-- because React/Vue/Svelte require build steps and external imports that violate the single-file self-contained requirement. Vanilla JS handles all 6 shapes adequately. + +Constraint: Generate HTML directly, never generate markdown then convert. +-- because markdown-to-HTML conversion loses the shape-specific layout, interactivity, and visual structure that justifies using HTML in the first place. + +Gate: `.html` file exists on disk. +-- because Phase 4 validation reads the file; a missing file means generation failed silently. + +--- + +### Phase 4: VALIDATE + +Run deterministic validation on the generated file. + +Run: `python3 skills/meta/html-artifact/scripts/validate-artifact.py {html_file_path}` + +The script checks: + +| Check | Fails When | +|---|---| +| Valid HTML structure | Missing `<html>`, `<head>`, or `<body>` | +| No external dependencies | Any `src=` or `href=` pointing to external URLs | +| Has `<title>` | Missing or empty `<title>` tag | +| Has charset meta | Missing `<meta charset>` | +| Has viewport meta | Missing viewport meta tag | +| File size under 500KB | Excessive inline assets or animation keyframes | +| No broken internal refs | `href="#id"` pointing to nonexistent `id` attributes | + +Gate: All validation checks pass. +-- because an HTML file with external dependencies fails offline, missing meta tags render inconsistently across browsers, and missing structure breaks accessibility. + +**If validation fails:** Read the specific failures from script output, fix the identified issues in the HTML file, re-run validation. Maximum 3 fix attempts before showing the user the remaining issues and asking for guidance. + +--- + +### Phase 5: DELIVER + +1. Print the absolute file path +2. Print a 1-line summary of what was generated (shape + key features) +3. Ask user: "Open in browser?" +4. If yes: run `open {file}` (macOS) or `xdg-open {file}` (Linux) + +Constraint: Detect headless/SSH environments before offering browser open. +-- because `xdg-open` fails without a display server, producing confusing errors. Check `$DISPLAY` on Linux or `$SSH_TTY` presence. If headless, print path only and skip the open offer. + +--- + +## Error Handling + +| Error | Cause | Solution | +|---|---|---| +| detect-shape.py returns low confidence | Ambiguous request mapping to multiple shapes | Fall back to "report" shape -- safest general-purpose format | +| Generated HTML has external dependencies | Builder included CDN links or external `src` refs | Regenerate with explicit constraint: "no external deps, all CSS/JS inline" | +| File exceeds 500KB | Excessive inline SVGs or animation keyframes | Simplify SVG paths, reduce keyframe count, compress data | +| Browser won't open | No display server (headless, SSH, WSL without WSLg) | Print path only, suggest `scp` or `python3 -m http.server` | +| Validation fails repeatedly (3+ attempts) | Structural issue the builder cannot self-correct | Show validation output to user, ask for guidance | +| Shape misclassified | Auto-detection picked wrong shape for request | User overrides with `/html --shape=<name> <request>` | + +--- + +## Preferred Patterns + +### Pattern 1: CDN and Framework Imports + +**What it looks like:** `<link href="https://cdn.jsdelivr.net/...">` or `<script src="https://unpkg.com/react@18/...">` in the generated HTML. + +**Why wrong:** Breaks the self-contained contract. File fails offline, introduces version drift, adds weight the user didn't ask for. + +**Do instead:** Inline all CSS in `<style>`. Write vanilla JS in `<script>`. The Birchline design system in `references/design-system.md` provides the full token set. + +### Pattern 2: Markdown-to-HTML Conversion + +**What it looks like:** Generating a markdown document first, then running it through a converter or wrapping it in `<pre>` tags. + +**Why wrong:** Loses shape-specific layout, interactivity, SVG diagrams, and responsive grid structures. Produces "markdown in a browser" instead of a native HTML artifact. + +**Do instead:** Generate HTML directly using shape-specific patterns from references. The HTML structure IS the output format, not a rendering layer on top of text. + +### Pattern 3: Monolithic Unstructured HTML + +**What it looks like:** One giant `<div>` with inline styles on every element, no semantic structure, no comments. + +**Why wrong:** Unreadable source, hard to debug, impossible for the user to modify. Accessibility tools cannot navigate it. + +**Do instead:** Use semantic HTML (`<header>`, `<main>`, `<section>`, `<footer>`). Define CSS classes in `<style>`. Add section comments. Group related elements logically. + +### Pattern 4: Over-Engineering Simple Requests + +**What it looks like:** Generating a full interactive dashboard when the user asked for a simple comparison table. + +**Why wrong:** 2-4x generation time for features the user didn't request. Complexity without value. + +**Do instead:** Match artifact complexity to request complexity. A comparison of 3 options needs a grid with cards, not a filterable dashboard with animations. + +--- + +## Anti-Rationalization + +| Rationalization | Why Wrong | Required Action | +|---|---|---| +| "Markdown is fine for this" | If shape detection triggered, the request has visual/interactive needs markdown can't serve | Generate HTML; user opts out with "as markdown" | +| "I'll add Tailwind CDN for faster styling" | Breaks self-contained requirement, fails offline | Use Birchline tokens from design-system.md | +| "The HTML looks right, skip validation" | Visual inspection misses missing meta tags, broken internal links, external deps | Run validate-artifact.py every time | +| "Report shape works for everything" | Each shape has distinct layout and interaction patterns; report is a fallback, not a default | Use the detected shape; report only when confidence is genuinely low | + +--- + +## Reference Loading Table + +| Signal | Load These Files | Why | +|---|---|---| +| Any html-artifact invocation | `references/design-system.md` | CSS tokens, themes, typography, color system | +| Any html-artifact invocation | `references/interaction-patterns.md` | Shared JS: tabs, drag-drop, copy buttons, keyboard nav | +| Shape = spec | `references/shape-spec-exploration.md` | Grid layouts, comparison patterns, SVG flows | +| Shape = code-review | `references/shape-code-review.md` | Diff rendering, severity system, annotations | +| Shape = prototype | `references/shape-design-prototype.md` | Sliders, live preview, animation sandbox | +| Shape = report | `references/shape-report-research.md` | TL;DR boxes, collapsibles, timelines, metrics | +| Shape = editor | `references/shape-custom-editor.md` | Drag-drop, forms, export buttons, live re-render | +| Shape = data-viz | `references/shape-data-visualization.md` | SVG charts, canvas, tooltips, filters | + +--- + +## Shared Patterns + +This skill uses: +- [Anti-Rationalization](../../shared-patterns/anti-rationalization-core.md) -- Prevents shortcut rationalizations +- [Verification Checklist](../../shared-patterns/verification-checklist.md) -- Pre-completion checks +- [Gate Enforcement](../../shared-patterns/gate-enforcement.md) -- Phase transitions + +--- + +## Reference Files + +- `references/design-system.md`: Birchline CSS tokens, 4 theme presets, typography scale, color palette +- `references/interaction-patterns.md`: Shared JS patterns across all shapes (tabs, collapsibles, drag-drop, copy, keyboard) +- `references/shape-spec-exploration.md`: Spec shape -- grids, comparison cards, SVG flow diagrams, risk tables +- `references/shape-code-review.md`: Code review shape -- diff rendering, severity colors, annotations, jump links +- `references/shape-design-prototype.md`: Prototype shape -- sliders, CSS var live update, animation sandbox +- `references/shape-report-research.md`: Report shape -- TL;DR boxes, collapsible sections, timelines, metric callouts +- `references/shape-custom-editor.md`: Editor shape -- drag-drop, kanban, toggle switches, export buttons +- `references/shape-data-visualization.md`: Data viz shape -- SVG charts, canvas rendering, tooltips, filter controls +- `agents/html-builder.md`: Subagent prompt for HTML generation +- `scripts/detect-shape.py`: Deterministic shape classification from user request +- `scripts/validate-artifact.py`: HTML structure and self-containment validation diff --git a/skills/meta/html-artifact/SPEC.md b/skills/meta/html-artifact/SPEC.md new file mode 100644 index 00000000..e97f571f --- /dev/null +++ b/skills/meta/html-artifact/SPEC.md @@ -0,0 +1,105 @@ +# 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 +- Full Vite+React projects (use `interactive-essay` skill) +- Presentation decks (use `frontend-slides` skill) +- Application UIs with backend integration + +## Non-Goals + +- Not a web app builder — no npm, no build steps, no server-side logic +- Not a replacement for `interactive-essay` (full Vite+React projects with scroll animations) +- Not a replacement for `frontend-slides` (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) +``` diff --git a/skills/meta/html-artifact/agents/html-builder.md b/skills/meta/html-artifact/agents/html-builder.md new file mode 100644 index 00000000..86eef8cc --- /dev/null +++ b/skills/meta/html-artifact/agents/html-builder.md @@ -0,0 +1,91 @@ +# 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` +- **user_request**: The original request text +- **design_system**: Birchline CSS tokens from `references/design-system.md` +- **shape_patterns**: Shape-specific HTML/CSS/JS patterns from `references/shape-*.md` +- **interaction_patterns**: Shared JS patterns (tabs, collapsibles, drag-drop, copy buttons, keyboard nav) + +## Generation Rules + +### Structure + +Every artifact follows this skeleton: + +```html +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>[descriptive title] + + + +
+
+ + + + +``` + +### Shape-Specific Rules + +Reference files carry the full patterns. These rules 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 `--color-severity-*` tokens | +| 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, SVG diagrams where applicable | 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: Copy as Markdown + Copy as JSON + Copy as Prompt | 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 | + +### Quality Rules + +1. **Design tokens** -- Use CSS custom properties (`--color-*`, `--sp-*`, `--type-*`, `--radius-*`) from the design system. Never hardcode colors, spacing, or font sizes. +2. **Responsive** -- Works from 375px to 1440px+. Use CSS Grid or Flexbox with `min()`, `clamp()`, media queries. +3. **Accessible** -- Keyboard navigation for all interactive elements. ARIA labels on controls. `prefers-reduced-motion` media query disables animations. Focus indicators visible. +4. **Title** -- `` describes the content specifically (e.g., "Rate Limiting: 3 Approaches Compared"), not generically ("HTML Artifact"). +5. **File size** -- Under 500KB total. If approaching the limit: simplify SVG paths, reduce keyframe count, compress inline data. +6. **Clean source** -- Semantic HTML elements, CSS classes in `<style>`, named JS functions with comments, section separators. No `console.log` in output. +7. **Reduced motion** -- Wrap all animations/transitions in `@media (prefers-reduced-motion: no-preference) { ... }`. + +### 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 (e.g., `auth-approach-comparison.html`, `pr-42-review.html`, `ticket-triage-editor.html`) +4. After writing: report the absolute file path + +## Anti-Patterns + +| Do NOT | Do Instead | +|---|---| +| CDN links (`<link href="https://...">`, `<script src="https://...">`) | Inline CSS using design tokens; SVG for charts | +| Framework imports (React, Vue, Angular, Svelte) | Vanilla JS -- single file, no build step | +| Generate markdown then wrap in `<pre>` or convert | Generate HTML natively using shape-specific patterns | +| Monolithic 1000-line `<script>` with one function | Named functions, clear comments, logical sections | +| Hardcoded px values (`margin: 16px`, `font-size: 14px`) | Use `--sp-*` tokens and `--type-*` scale | +| Color by name (`red`, `blue`, `#ff0000`) | Use `--color-*` semantic tokens | +| Missing export button on editors/prototypes | Every interactive artifact gets Copy/Export buttons | +| `canvas` for simple charts (<100 points) | SVG with inline `<path>`, `<rect>`, `<circle>` | +| `alert()` or `confirm()` for user feedback | Inline toast notifications or status badges | +| Inline styles on individual elements | CSS classes defined in `<style>` block | + +## Generation Process + +1. Read the shape-specific reference file patterns +2. Plan the HTML structure: which sections, what interactive elements, how the grid/layout works +3. Write the CSS tokens block (from design system) + component styles +4. Write the semantic HTML body with section comments +5. Write the JS for interactivity (event listeners, state management, export functions) +6. Self-review: check for external deps, hardcoded values, missing ARIA, missing export buttons +7. Write the file to disk diff --git a/skills/meta/html-artifact/assets/base-template.html b/skills/meta/html-artifact/assets/base-template.html new file mode 100644 index 00000000..6ab9ab8b --- /dev/null +++ b/skills/meta/html-artifact/assets/base-template.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html lang="en" data-theme="light"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="color-scheme" content="light dark"> + <title><!-- TITLE --> + + + +
+
+ +
+ + + + diff --git a/skills/meta/html-artifact/references/design-system.md b/skills/meta/html-artifact/references/design-system.md new file mode 100644 index 00000000..c4be8962 --- /dev/null +++ b/skills/meta/html-artifact/references/design-system.md @@ -0,0 +1,746 @@ +# HTML Artifact Design System + +Loaded by the html-builder agent on every artifact generation. Provides CSS tokens, typography, spacing, theme variants, and structural patterns. All artifacts are self-contained vanilla CSS — no external frameworks. + +--- + +## Theme Selection + +Pick theme before writing CSS. Match artifact shape to theme: + +| Shape | Theme | Rationale | +|---|---|---| +| spec | Birchline | Warm, readable, comparison grids | +| report | Birchline | Professional, scannable typography | +| code-review | Dark Focus | Developer-familiar, high-contrast diffs | +| data-viz | Dark Focus | Charts pop on dark backgrounds | +| prototype | Interactive Warm | Clean surface, prominent controls | +| editor | Interactive Warm | Clear interactive affordances | +| explainer | Minimal Document | Serif headings, generous whitespace | +| research | Minimal Document | Long-form optimized reading | + +User preference overrides shape default. + +--- + +## CSS Reset (include in EVERY artifact) + +```css +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } +body { min-height: 100vh; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +img, svg { display: block; max-width: 100%; } +input, button, textarea, select { font: inherit; } +p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } +} +``` + +--- + +## Theme 1: Birchline (Default) + +Extracted from thariqs.github.io/html-effectiveness/. Warm, earthy, professional. + +### Ready-to-paste CSS + +```css +:root { + /* --- Colors --- */ + --color-primary: #D97757; + --color-slate: #141413; + --color-ivory: #FAF9F5; + --color-oat: #E3DACC; + --color-white: #FFFFFF; + --color-gray-100: #F0EEE6; + --color-gray-150: #F0EEE6; + --color-gray-300: #D1CFC5; + --color-gray-500: #87867F; + --color-gray-700: #3D3D3A; + --color-success: #788C5D; + --color-warning: #C78E3F; + --color-danger: #B04A4A; + --color-info: #5C7CA3; + + /* --- Typography --- */ + --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --type-display: 500 48px/1.1 var(--font-sans); + --type-h1: 500 32px/1.2 var(--font-sans); + --type-h2: 500 24px/1.3 var(--font-sans); + --type-body: 430 16px/1.55 var(--font-sans); + --type-small: 430 14px/1.5 var(--font-sans); + --type-caption: 500 12px/1.4 var(--font-sans); + + /* --- Spacing --- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + + /* --- Border Radius --- */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + + /* --- Shadows --- */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --shadow-md: 0 4px 10px rgba(0,0,0,0.08); + --shadow-lg: 0 12px 28px rgba(0,0,0,0.12); + + /* --- Semantic Aliases --- */ + --bg-page: var(--color-ivory); + --bg-surface: var(--color-white); + --bg-muted: var(--color-gray-100); + --bg-card: var(--color-oat); + --text-primary: var(--color-slate); + --text-secondary: var(--color-gray-700); + --text-muted: var(--color-gray-500); + --border-default: var(--color-gray-300); + --border-subtle: var(--color-gray-150); + --accent: var(--color-primary); +} + +body { + font: var(--type-body); + color: var(--text-primary); + background: var(--bg-page); +} +``` + +--- + +## Theme 2: Dark Focus + +Data visualization, code review, late-night reading. High contrast, inner glows instead of drop shadows. + +### Ready-to-paste CSS + +```css +:root { + /* --- Colors --- */ + --color-primary: #64B5F6; + --color-bg: #1A1A2E; + --color-surface: #232340; + --color-surface-raised: #2C2C4A; + --color-text: #E0E0E0; + --color-text-secondary: #A0A0B8; + --color-text-muted: #6E6E8A; + --color-border: #3A3A5C; + --color-border-subtle: #2E2E4E; + --color-success: #81C784; + --color-warning: #FFB74D; + --color-danger: #EF5350; + --color-info: #64B5F6; + --color-code-bg: #16162A; + --color-diff-add: rgba(129, 199, 132, 0.12); + --color-diff-remove: rgba(239, 83, 80, 0.12); + + /* --- Typography --- */ + --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --type-display: 500 48px/1.1 var(--font-sans); + --type-h1: 500 32px/1.2 var(--font-sans); + --type-h2: 500 24px/1.3 var(--font-sans); + --type-body: 400 16px/1.55 var(--font-sans); + --type-small: 400 14px/1.5 var(--font-sans); + --type-caption: 500 12px/1.4 var(--font-sans); + + /* --- Spacing --- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + + /* --- Border Radius --- */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + + /* --- Shadows (inverted: inner glows) --- */ + --shadow-sm: inset 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: inset 0 2px 6px rgba(0,0,0,0.4); + --shadow-lg: 0 0 24px rgba(100,181,246,0.06); + --shadow-glow: 0 0 12px rgba(100,181,246,0.15); + + /* --- Semantic Aliases --- */ + --bg-page: var(--color-bg); + --bg-surface: var(--color-surface); + --bg-muted: var(--color-surface-raised); + --bg-card: var(--color-surface); + --text-primary: var(--color-text); + --text-secondary: var(--color-text-secondary); + --text-muted: var(--color-text-muted); + --border-default: var(--color-border); + --border-subtle: var(--color-border-subtle); + --accent: var(--color-primary); +} + +body { + font: var(--type-body); + color: var(--text-primary); + background: var(--bg-page); +} + +/* Dark Focus: code block override */ +pre, code { + background: var(--color-code-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} +pre { padding: var(--sp-4); } +code { padding: var(--sp-1) var(--sp-2); font-size: 14px; } + +/* Dark Focus: diff highlighting */ +.diff-add { background: var(--color-diff-add); } +.diff-remove { background: var(--color-diff-remove); } +``` + +### Contrast Ratios (WCAG AA verified) + +| Pair | Ratio | Pass | +|---|---|---| +| --color-text on --color-bg | 11.5:1 | AA | +| --color-text-secondary on --color-bg | 5.8:1 | AA | +| --color-primary on --color-bg | 5.2:1 | AA | +| --color-text on --color-surface | 9.1:1 | AA | + +--- + +## Theme 3: Interactive Warm + +Editors, prototypes, design tools. Clean white surface, blue accent for actions, prominent shadows on interactive elements. + +### Ready-to-paste CSS + +```css +:root { + /* --- Colors --- */ + --color-primary: #5B8DEF; + --color-primary-hover: #4A7DE0; + --color-bg: #FAFAF8; + --color-surface: #FFFFFF; + --color-surface-raised: #F5F5F2; + --color-text: #2D2D2D; + --color-text-secondary: #5A5A5A; + --color-text-muted: #8A8A8A; + --color-border: #D4D4D0; + --color-border-subtle: #E8E8E4; + --color-border-focus: var(--color-primary); + --color-success: #4CAF50; + --color-warning: #F9A825; + --color-danger: #E53935; + --color-info: #5B8DEF; + + /* --- Typography --- */ + --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --type-display: 600 48px/1.1 var(--font-sans); + --type-h1: 600 32px/1.2 var(--font-sans); + --type-h2: 600 24px/1.3 var(--font-sans); + --type-body: 400 16px/1.55 var(--font-sans); + --type-small: 400 14px/1.5 var(--font-sans); + --type-caption: 500 12px/1.4 var(--font-sans); + + /* --- Spacing --- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + + /* --- Border Radius --- */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + + /* --- Shadows (prominent on interactive elements) --- */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 12px rgba(0,0,0,0.1); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.12); + --shadow-interactive: 0 2px 8px rgba(91,141,239,0.2); + --shadow-interactive-hover: 0 4px 16px rgba(91,141,239,0.3); + + /* --- Semantic Aliases --- */ + --bg-page: var(--color-bg); + --bg-surface: var(--color-surface); + --bg-muted: var(--color-surface-raised); + --bg-card: var(--color-surface); + --text-primary: var(--color-text); + --text-secondary: var(--color-text-secondary); + --text-muted: var(--color-text-muted); + --border-default: var(--color-border); + --border-subtle: var(--color-border-subtle); + --accent: var(--color-primary); +} + +body { + font: var(--type-body); + color: var(--text-primary); + background: var(--bg-page); +} + +/* Interactive Warm: button and control styles */ +button, [role="button"] { + background: var(--color-primary); + color: #FFFFFF; + border: none; + border-radius: var(--radius-sm); + padding: var(--sp-2) var(--sp-4); + font: var(--type-small); + font-weight: 500; + cursor: pointer; + box-shadow: var(--shadow-interactive); + transition: box-shadow 0.15s ease, background 0.15s ease; +} +button:hover, [role="button"]:hover { + background: var(--color-primary-hover); + box-shadow: var(--shadow-interactive-hover); +} +button:focus-visible, [role="button"]:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +input, textarea, select { + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + padding: var(--sp-2) var(--sp-3); + font: var(--type-body); + background: var(--bg-surface); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +input:focus, textarea:focus, select:focus { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 3px rgba(91,141,239,0.15); + outline: none; +} +``` + +### Contrast Ratios (WCAG AA verified) + +| Pair | Ratio | Pass | +|---|---|---| +| --color-text on --color-bg | 12.8:1 | AA | +| --color-text-secondary on --color-bg | 7.0:1 | AA | +| #FFFFFF on --color-primary | 4.6:1 | AA | +| --color-text on --color-surface | 14.7:1 | AA | + +--- + +## Theme 4: Minimal Document + +Long-form reports, explainers, research summaries. Serif headings, generous whitespace, restrained palette. + +### Ready-to-paste CSS + +```css +:root { + /* --- Colors --- */ + --color-primary: #555555; + --color-bg: #FFFFF8; + --color-surface: #FFFFFF; + --color-surface-raised: #F8F8F2; + --color-text: #333333; + --color-text-secondary: #555555; + --color-text-muted: #888888; + --color-border: #D0D0C8; + --color-border-subtle: #E8E8E0; + --color-accent-subtle: rgba(85,85,85,0.08); + --color-success: #5A7A42; + --color-warning: #B8860B; + --color-danger: #A83232; + --color-info: #4A6A8A; + + /* --- Typography (serif headings, sans body) --- */ + --font-serif: Georgia, 'Times New Roman', serif; + --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --type-display: 500 44px/1.15 var(--font-serif); + --type-h1: 500 30px/1.25 var(--font-serif); + --type-h2: 500 22px/1.35 var(--font-serif); + --type-body: 400 17px/1.7 var(--font-sans); + --type-small: 400 15px/1.6 var(--font-sans); + --type-caption: 500 12px/1.4 var(--font-sans); + + /* --- Spacing (generous for long-form) --- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + + /* --- Border Radius --- */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* --- Shadows (minimal) --- */ + --shadow-sm: 0 1px 1px rgba(0,0,0,0.04); + --shadow-md: 0 2px 6px rgba(0,0,0,0.06); + --shadow-lg: 0 6px 16px rgba(0,0,0,0.08); + + /* --- Semantic Aliases --- */ + --bg-page: var(--color-bg); + --bg-surface: var(--color-surface); + --bg-muted: var(--color-surface-raised); + --bg-card: var(--color-surface); + --text-primary: var(--color-text); + --text-secondary: var(--color-text-secondary); + --text-muted: var(--color-text-muted); + --border-default: var(--color-border); + --border-subtle: var(--color-border-subtle); + --accent: var(--color-primary); + + /* --- Document-specific --- */ + --content-width: 680px; + --content-margin: auto; +} + +body { + font: var(--type-body); + color: var(--text-primary); + background: var(--bg-page); + max-width: var(--content-width); + margin: var(--sp-8) var(--content-margin); + padding: 0 var(--sp-5); +} + +/* Minimal Document: heading rhythm */ +h1 { font: var(--type-h1); margin: var(--sp-8) 0 var(--sp-5); } +h2 { font: var(--type-h2); margin: var(--sp-7) 0 var(--sp-4); } +p + p { margin-top: var(--sp-4); } + +/* Minimal Document: blockquote */ +blockquote { + border-left: 3px solid var(--color-border); + padding: var(--sp-3) var(--sp-5); + color: var(--text-secondary); + font-style: italic; + margin: var(--sp-5) 0; +} + +/* Minimal Document: horizontal rule */ +hr { + border: none; + border-top: 1px solid var(--color-border-subtle); + margin: var(--sp-7) 0; +} +``` + +### Contrast Ratios (WCAG AA verified) + +| Pair | Ratio | Pass | +|---|---|---| +| --color-text on --color-bg | 12.4:1 | AA | +| --color-text-secondary on --color-bg | 7.5:1 | AA | +| --color-text-muted on --color-bg | 3.5:1 | AA large text only | +| --color-text on --color-surface | 12.6:1 | AA | + +Note: `--text-muted` passes AA for large text (>=18px bold or >=24px). Use only for captions, labels, and supplementary text at `--type-small` size or above. + +--- + +## Card Variants + +Six structural treatments. Use semantic aliases (--bg-card, --border-default, etc.) so cards adapt to any theme. + +```css +/* Flat: dense lists, inline content */ +.card-flat { + background: var(--bg-muted); + padding: var(--sp-4); + border-radius: var(--radius-sm); +} + +/* Outlined: content cards, comparison items */ +.card-outlined { + background: var(--bg-surface); + border: 1px solid var(--border-default); + padding: var(--sp-4); + border-radius: var(--radius-sm); +} + +/* Elevated: draggable items, interactive cards */ +.card-elevated { + background: var(--bg-surface); + box-shadow: var(--shadow-md); + padding: var(--sp-4); + border-radius: var(--radius-sm); +} + +/* Accent stripe: priority items, callouts */ +.card-accent { + background: var(--bg-surface); + border-left: 3px solid var(--accent); + padding: var(--sp-4); + border-radius: var(--radius-xs); +} + +/* Inset: nested content, code blocks */ +.card-inset { + background: var(--bg-muted); + padding: var(--sp-4); + border-radius: var(--radius-sm); +} + +/* Horizontal: list rows, table alternatives */ +.card-horizontal { + display: flex; + align-items: center; + gap: var(--sp-3); + background: var(--bg-surface); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); +} +``` + +### Card Selection Guide + +| Need | Variant | Example | +|---|---|---| +| List of items, dense | Flat | Task list, log entries | +| Comparable items side-by-side | Outlined | Feature comparison, option cards | +| User can grab/move/click | Elevated | Kanban cards, drag-and-drop items | +| Needs visual emphasis | Accent stripe | Warnings, key metrics, status callouts | +| Content within content | Inset | Code snippet inside a section, nested quote | +| Scannable rows | Horizontal | Search results, notification feed | + +--- + +## Responsive Breakpoints + +```css +/* Mobile-first: base styles target < 640px */ + +@media (min-width: 640px) { + /* Tablet: 2-column layouts where applicable */ +} + +@media (min-width: 1024px) { + /* Desktop: full layout, side-by-side panels */ +} +``` + +| Breakpoint | Width | Column behavior | +|---|---|---| +| Mobile | < 640px | Single column, stacked | +| Tablet | 640-1024px | 2 columns where applicable | +| Desktop | > 1024px | Full layout, side-by-side panels | + +### Responsive Utilities + +```css +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--sp-4); +} + +.grid-2 { + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-4); +} +@media (min-width: 640px) { + .grid-2 { grid-template-columns: 1fr 1fr; } +} + +.grid-3 { + display: grid; + grid-template-columns: 1fr; + gap: var(--sp-4); +} +@media (min-width: 640px) { + .grid-3 { grid-template-columns: 1fr 1fr; } +} +@media (min-width: 1024px) { + .grid-3 { grid-template-columns: 1fr 1fr 1fr; } +} + +/* Hide/show by breakpoint */ +.hide-mobile { display: none; } +@media (min-width: 640px) { .hide-mobile { display: initial; } } +.hide-desktop { display: initial; } +@media (min-width: 1024px) { .hide-desktop { display: none; } } +``` + +--- + +## SVG Illustration Conventions + +All inline SVGs follow these rules for visual consistency across artifacts. + +| Property | Value | +|---|---| +| Dimensions | 720 x 320px (viewBox) | +| 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 | Theme tokens (use currentColor or CSS vars) | +| Self-contained | Embed ` + + +``` + +--- + +## Common Component Patterns + +Reusable CSS classes that work across all themes via semantic aliases. + +### Status Badge + +```css +.badge { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: var(--sp-1) var(--sp-2); + border-radius: 999px; + font: var(--type-caption); + font-weight: 500; +} +.badge-success { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); } +.badge-warning { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); } +.badge-danger { background: color-mix(in srgb, var(--color-danger) 15%, transparent); color: var(--color-danger); } +.badge-info { background: color-mix(in srgb, var(--color-info) 15%, transparent); color: var(--color-info); } +``` + +### Table + +```css +table { + width: 100%; + border-collapse: collapse; + font: var(--type-small); +} +th { + text-align: left; + font-weight: 500; + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: var(--sp-2) var(--sp-3); + border-bottom: 2px solid var(--border-default); +} +td { + padding: var(--sp-3); + border-bottom: 1px solid var(--border-subtle); + vertical-align: top; +} +tr:hover td { + background: var(--bg-muted); +} +``` + +### Section Divider + +```css +.divider { + border: none; + border-top: 1px solid var(--border-subtle); + margin: var(--sp-6) 0; +} +``` + +--- + +## Accessibility Checklist (every artifact) + +1. **Color contrast**: text on background >= 4.5:1 (normal), >= 3:1 (large text >= 18px bold or >= 24px) +2. **Focus indicators**: all interactive elements have `:focus-visible` styles +3. **Semantic HTML**: headings in order (h1 > h2 > h3), lists for lists, tables for tabular data +4. **Alt text**: every `` has `alt`, every `` has `role="img"` + `aria-label` +5. **Reduced motion**: the CSS reset handles this globally via `prefers-reduced-motion` +6. **Touch targets**: interactive elements minimum 44x44px hit area +7. **Language**: `` on the root element + +--- + +## Anti-Patterns + +| Pattern | Why Wrong | Do Instead | +|---|---|---| +| CSS frameworks (Bootstrap, Tailwind CDN) | External dependency, breaks self-contained | Use the token system with vanilla CSS | +| Random colors per artifact | Inconsistent, unprofessional | Use theme tokens — they work together | +| Hardcoded px values | Breaks the scale, inconsistent spacing | Use --sp-N tokens and --type-* scale | +| Dark theme = just invert colors | Poor contrast ratios, ugly results | Use the Dark Focus preset with tuned contrast | +| `outline: none` without replacement | Destroys keyboard navigation | Add `:focus-visible` with ring or border | +| `
` | Not keyboard accessible, no semantics | Use `` + +--- + +## 2. Tab Switching + +```html +
+ + +
+
Content one
+
Content two
+``` + +```js +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'); + }); +}); +``` + +```css +.tab-bar { + display: flex; + gap: var(--sp-1); + border-bottom: 2px solid var(--border-subtle); + padding: 0 var(--sp-2); +} +.tab { + background: none; + border: none; + padding: var(--sp-2) var(--sp-4); + font: var(--type-small); + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.15s ease, border-color 0.15s ease; +} +.tab:hover { color: var(--text-primary); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-xs); } +.tab-panel { display: none; padding: var(--sp-4) 0; } +.tab-panel.active { display: block; } +``` + +--- + +## 3. Collapsible Sections + +### Approach A: Native `
/` (preferred) + +Zero JS. Use when smooth animation is not required. + +```html +
+ Section Title +
Content here
+
+``` + +```css +details { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: var(--sp-3); +} +summary { + padding: var(--sp-3) var(--sp-4); + font: var(--type-small); + font-weight: 500; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; +} +summary::after { content: '+'; font-size: 18px; color: var(--text-muted); transition: transform 0.2s ease; } +details[open] summary::after { content: '\2212'; } +.details-content { padding: 0 var(--sp-4) var(--sp-4); } +``` + +### Approach B: Custom Accordion with Smooth Height Animation + +Use when you need animated open/close transitions that `
` cannot provide. + +```html +
+ +
+
Content here
+
+
+``` + +```js +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'; + } + }); +}); +``` + +```css +.accordion-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: var(--bg-muted); + border: none; + padding: var(--sp-3) var(--sp-4); + font: var(--type-small); + font-weight: 500; + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); +} +.accordion-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.accordion-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.25s cubic-bezier(.16, 1, .3, 1); +} +.accordion-inner { padding: var(--sp-3) var(--sp-4); } +``` + +--- + +## 4. HTML5 Drag and Drop + +Complete implementation for reordering list items. + +```html +
    +
  • Item A
  • +
  • Item B
  • +
  • Item C
  • +
+``` + +```js +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); + } + } + }); +}); +``` + +```css +.drag-list { list-style: none; padding: 0; } +.drag-item { + padding: var(--sp-3) var(--sp-4); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: var(--sp-2); + cursor: grab; + transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; + user-select: none; +} +.drag-item:active { cursor: grabbing; } +.drag-item.dragging { opacity: 0.35; transform: rotate(2deg); } +.drag-item.drag-over { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } +``` + +--- + +## 5. Slider to Live CSS Variable Update + +Pattern for `` that updates a CSS custom property in real-time. + +```html + +``` + +One line of JS per slider via `oninput`. No separate event listeners needed. + +```css +.slider-group { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; +} +.slider-label { font: var(--type-small); color: var(--text-secondary); min-width: 120px; } +.slider-value { font: var(--type-caption); font-family: var(--font-mono); color: var(--text-muted); min-width: 48px; } +input[type="range"] { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: var(--border-default); + border-radius: 3px; + outline: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-surface); + box-shadow: var(--shadow-sm); +} +input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; } +``` + +--- + +## 6. Keyboard Navigation + +Arrow key listener for sequential content (slides, cards, items). + +```js +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(); +} + +// Usage: setupKeyNav('.slide-deck', '.slide'); +``` + +```css +.key-nav-counter { + font: var(--type-caption); + font-family: var(--font-mono); + color: var(--text-muted); + padding: var(--sp-1) var(--sp-2); + background: var(--bg-muted); + border-radius: var(--radius-xs); +} +``` + +--- + +## 7. Anchor Navigation with Active Tracking + +Scroll-based active section detection using IntersectionObserver. + +```html +
+``` + +```js +function setupScrollNav(tocSelector, sectionSelector) { + const links = document.querySelectorAll(tocSelector + ' a'); + const sections = document.querySelectorAll(sectionSelector); + + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + links.forEach(link => link.classList.remove('active')); + const active = document.querySelector(tocSelector + ' a[href="#' + entry.target.id + '"]'); + if (active) active.classList.add('active'); + } + }); + }, { rootMargin: '-20% 0px -60% 0px' }); + + sections.forEach(section => observer.observe(section)); +} + +// Usage: setupScrollNav('.toc', 'section[id]'); +``` + +```css +.toc { + position: sticky; + top: var(--sp-5); + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3); + max-height: calc(100vh - var(--sp-8)); + overflow-y: auto; +} +.toc-link { + font: var(--type-small); + color: var(--text-muted); + text-decoration: none; + padding: var(--sp-1) var(--sp-3); + border-left: 2px solid transparent; + border-radius: 0; + transition: color 0.15s ease, border-color 0.15s ease; +} +.toc-link:hover { color: var(--text-primary); } +.toc-link.active { color: var(--accent); border-left-color: var(--accent); font-weight: 500; } +.toc-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-xs); } +``` + +Layout: use a two-column grid with `grid-template-columns: 200px 1fr` at desktop breakpoint, single column on mobile with `.toc` position static. + +--- + +## 8. Live Template Re-rendering + +Pattern for textarea that parses `{{var}}` template variables and renders filled versions live. + +```html +
+ +
+
+ 0 chars +
+``` + +```js +function setupTemplateEditor(containerSelector) { + const container = document.querySelector(containerSelector); + const input = container.querySelector('.template-input'); + const varsContainer = container.querySelector('.template-vars'); + const preview = container.querySelector('.template-preview'); + const charCount = container.querySelector('.char-count'); + const varValues = {}; + + function extractVars(text) { + const matches = text.match(/\{\{(\w+)\}\}/g) || []; + return [...new Set(matches.map(m => m.slice(2, -2)))]; + } + + function render() { + const text = input.value; + charCount.textContent = text.length + ' chars'; + const vars = extractVars(text); + + // Build var inputs if new vars appear + vars.forEach(v => { + if (!container.querySelector('[data-var="' + v + '"]')) { + const label = document.createElement('label'); + label.className = 'template-var-field'; + label.innerHTML = '' + v + ''; + const inp = document.createElement('input'); + inp.type = 'text'; + inp.dataset.var = v; + inp.placeholder = v; + inp.value = varValues[v] || ''; + inp.addEventListener('input', () => { varValues[v] = inp.value; render(); }); + label.appendChild(inp); + varsContainer.appendChild(label); + } + }); + + // Render preview + let filled = text; + vars.forEach(v => { + filled = filled.replaceAll('{{' + v + '}}', varValues[v] || '' + v + ''); + }); + preview.innerHTML = filled; + } + + input.addEventListener('input', render); + render(); +} +``` + +```css +.template-editor { display: grid; gap: var(--sp-3); } +.template-input { + width: 100%; + min-height: 120px; + padding: var(--sp-3); + font-family: var(--font-mono); + font-size: 14px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + resize: vertical; +} +.template-vars { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.template-var-field { display: flex; align-items: center; gap: var(--sp-2); font: var(--type-caption); } +.template-var-field input { width: 120px; padding: var(--sp-1) var(--sp-2); font: var(--type-small); border: 1px solid var(--border-subtle); border-radius: var(--radius-xs); } +.template-preview { + padding: var(--sp-4); + background: var(--bg-muted); + border-radius: var(--radius-sm); + font: var(--type-body); + white-space: pre-wrap; +} +.template-preview .unfilled { color: var(--text-muted); font-style: italic; } +.char-count { font: var(--type-caption); color: var(--text-muted); text-align: right; } +``` + +--- + +## 9. Filter / Search + +Client-side filtering of displayed items. + +### Text Search + +```html + +
+
Apple
+
Banana
+
+``` + +```js +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)); + }); + }); +} + +// Usage: setupFilter('.filter-input', '.filter-item'); +``` + +### Tag-Based Filter + +```html +
+ + + +
+``` + +```js +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)); + }); + }); + }); +} +``` + +```css +.filter-input { + width: 100%; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + font: var(--type-body); + margin-bottom: var(--sp-3); +} +.filter-input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); } +.tag-filters { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-3); } +.tag-btn { + background: var(--bg-muted); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: var(--sp-1) var(--sp-3); + font: var(--type-caption); + cursor: pointer; + transition: all 0.15s ease; +} +.tag-btn:hover { border-color: var(--accent); } +.tag-btn.active { background: var(--accent); color: var(--bg-surface); border-color: var(--accent); } +.tag-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.hidden { display: none !important; } +``` + +--- + +## 10. Theme Toggle (Light/Dark) + +```html + +``` + +```js +function toggleTheme() { + const html = document.documentElement; + html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; +} +``` + +Requires two `:root` blocks -- light tokens as default, dark overrides under `[data-theme="dark"]`: + +```css +/* Default light tokens already in :root */ + +[data-theme="dark"] { + --color-primary: #64B5F6; + --bg-page: #1A1A2E; + --bg-surface: #232340; + --bg-muted: #2C2C4A; + --bg-card: #232340; + --text-primary: #E0E0E0; + --text-secondary: #A0A0B8; + --text-muted: #6E6E8A; + --border-default: #3A3A5C; + --border-subtle: #2E2E4E; + --accent: #64B5F6; + --shadow-sm: inset 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: inset 0 2px 6px rgba(0,0,0,0.4); +} + +.theme-toggle { + position: fixed; + top: var(--sp-3); + right: var(--sp-3); + background: var(--bg-muted); + border: 1px solid var(--border-default); + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 18px; + z-index: 100; +} +.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.theme-icon-dark { display: none; } +[data-theme="dark"] .theme-icon-light { display: none; } +[data-theme="dark"] .theme-icon-dark { display: inline; } +``` + +--- + +## Shared CSS Utilities + +Include as needed in any artifact. + +```css +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Flex centering */ +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +/* Text overflow ellipsis */ +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Hide scrollbar, keep scrollable */ +.no-scrollbar { overflow: auto; scrollbar-width: none; -ms-overflow-style: none; } +.no-scrollbar::-webkit-scrollbar { display: none; } + +/* Focus visible styles (apply globally) */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +:focus:not(:focus-visible) { outline: none; } + +/* Print: hide interactive elements */ +@media print { + .no-print, + button, + input[type="range"], + .tab-bar, + .theme-toggle, + .drag-item[draggable] { display: none !important; } + + body { background: white; color: black; } + a { color: black; text-decoration: underline; } + a::after { content: ' (' attr(href) ')'; font-size: 0.8em; } +} +``` + +--- + +## Animation Utilities + +```css +/* Keyframes */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(10px); opacity: 0; } + to { transform: none; opacity: 1; } +} + +@keyframes scaleIn { + from { transform: scale(0.92); opacity: 0; } + to { transform: none; opacity: 1; } +} + +/* Easing presets */ +/* Spring: overshoot for interactive feedback */ +/* cubic-bezier(.34, 1.56, .64, 1) */ + +/* Smooth out: decelerate for exits and settling */ +/* cubic-bezier(.16, 1, .3, 1) */ + +/* Stagger delay classes */ +.delay-1 { animation-delay: 80ms; } +.delay-2 { animation-delay: 160ms; } +.delay-3 { animation-delay: 240ms; } +.delay-4 { animation-delay: 320ms; } +.delay-5 { animation-delay: 400ms; } +.delay-6 { animation-delay: 480ms; } +.delay-7 { animation-delay: 560ms; } +.delay-8 { animation-delay: 640ms; } + +/* Apply animation to elements */ +.animate-fade { animation: fadeIn 0.3s cubic-bezier(.16, 1, .3, 1) both; } +.animate-slide { animation: slideUp 0.35s cubic-bezier(.16, 1, .3, 1) both; } +.animate-scale { animation: scaleIn 0.3s cubic-bezier(.34, 1.56, .64, 1) both; } + +/* Reduced motion: disable all animations */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +--- + +## Accessibility Patterns + +Rules that apply to every pattern above. + +| Rule | Implementation | +|---|---| +| Keyboard accessible | All interactive elements are ` + + +
+ + + +``` + +### CSS + +```css +.file-nav { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + padding: var(--sp-5) var(--sp-4); + border-right: 1px solid var(--color-gray-200); + background: white; +} +.file-nav-title { + font-size: var(--type-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); + margin-bottom: var(--sp-4); +} + +/* --- Filter buttons --- */ +.filter-controls { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); + margin-bottom: var(--sp-4); +} +.filter-btn { + font-family: var(--font-body); + font-size: var(--type-caption); + padding: 2px 8px; + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-xs); + background: white; + color: var(--color-gray-600); + cursor: pointer; + transition: background 0.1s; +} +.filter-btn:hover { background: var(--color-gray-100); } +.filter-btn.active { + background: var(--color-slate); + color: white; + border-color: var(--color-slate); +} +.filter-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.filter-count { + font-family: var(--font-mono); + margin-left: 2px; +} + +/* --- File list --- */ +.file-list { + list-style: none; + padding: 0; + margin: 0; +} +.file-link { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-xs); + text-decoration: none; + color: var(--color-text); + font-size: var(--type-small); + transition: background 0.1s; +} +.file-link:hover { background: var(--color-gray-100); } +.file-link:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} +.file-link.active { background: var(--color-gray-100); font-weight: 600; } + +.file-severity-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.file-severity-dot.blocking { background: var(--color-blocking); } +.file-severity-dot.attention { background: var(--color-attention); } +.file-severity-dot.look { background: var(--color-look); } +.file-severity-dot.safe { background: var(--color-safe); } + +.file-name { + flex: 1; + font-family: var(--font-mono); + font-size: var(--type-caption); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-stats { + font-family: var(--font-mono); + font-size: var(--type-caption); + color: var(--color-gray-400); + white-space: nowrap; +} + +/* Mobile: horizontal scrollable nav bar */ +@media (max-width: 768px) { + .file-nav { + position: static; + height: auto; + overflow-y: visible; + overflow-x: auto; + border-right: none; + border-bottom: 1px solid var(--color-gray-200); + padding: var(--sp-3) var(--sp-4); + } + .file-list { + display: flex; + gap: var(--sp-1); + overflow-x: auto; + padding-bottom: var(--sp-2); + } + .file-link { white-space: nowrap; } +} +``` + +--- + +## Pattern 4: Diff File Block + +Core rendering unit. One per changed file. Contains diff lines and inline annotations. + +### HTML + +```html +
+
+ + src/auth.ts + Blocking + + +142 + −38 + +
+ +
+ +
+ 14 + 14 + import { validate } from './validate'; +
+ + +
+ 15 + + const token = localStorage.get('token'); +
+ + +
+ + 15 + const token = await getToken(); +
+ + +
+ 16 + 16 + return validate(token); +
+ + +
+
+ Blocking + Line 15 +
+
+

getToken() is async but the error path is unhandled. + If the token fetch fails, the function silently returns undefined, + which validate() will accept as a guest session.

+
+ Suggestion: +
const token = await getToken().catch(() => {
+  throw new AuthError('Token fetch failed');
+});
+
+
+
+ + +
+
+``` + +### CSS + +```css +/* --- Diff File Block --- */ +.diff-file { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-md); + margin: var(--sp-5) var(--sp-7); + overflow: hidden; + background: white; +} + +.diff-header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: var(--color-gray-100); + border-bottom: 1px solid var(--color-gray-200); +} +.diff-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: none; + border: none; + cursor: pointer; + border-radius: var(--radius-xs); + color: var(--color-gray-500); + transition: transform 0.15s ease; +} +.diff-toggle:hover { background: var(--color-gray-200); } +.diff-toggle:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.diff-toggle[aria-expanded="true"] .chevron { + transform: rotate(90deg); +} +.diff-filename { + font-family: var(--font-mono); + font-size: var(--type-small); + font-weight: 600; + flex: 1; +} +.diff-stats { + font-family: var(--font-mono); + font-size: var(--type-small); +} + +/* --- Diff Body (collapsible) --- */ +.diff-body { + overflow: hidden; + transition: max-height 0.2s ease; +} +.diff-body.collapsed { + max-height: 0 !important; +} + +/* --- Diff Lines --- */ +.diff-line { + display: flex; + align-items: stretch; + font-family: var(--font-mono); + font-size: var(--type-small); + line-height: 1.5; + min-height: 24px; +} +.diff-line.addition { background: var(--diff-add-bg); } +.diff-line.deletion { background: var(--diff-del-bg); } +.diff-line.context { background: var(--diff-ctx-bg); } + +/* Line numbers */ +.line-num { + display: inline-block; + width: 48px; + min-width: 48px; + text-align: right; + padding: 0 8px 0 0; + color: var(--color-gray-400); + font-size: var(--type-caption); + border-right: 1px solid var(--color-gray-200); + user-select: none; + line-height: 24px; +} +.line-num-old { + border-right: none; + padding-right: 4px; +} +.line-num-new { + padding-left: 4px; +} + +/* Line code content */ +.line-code { + flex: 1; + padding: 0 var(--sp-4); + white-space: pre; + overflow-x: auto; + tab-size: 4; + line-height: 24px; +} + +/* +/- prefix indicators */ +.diff-line.addition .line-code::before { + content: '+'; + color: var(--color-success); + font-weight: 700; + margin-right: var(--sp-2); +} +.diff-line.deletion .line-code::before { + content: '\2212'; + color: var(--color-danger); + font-weight: 700; + margin-right: var(--sp-2); +} +.diff-line.context .line-code::before { + content: '\00a0'; + margin-right: var(--sp-2); +} + +/* --- Annotations --- */ +.annotation { + margin: 0 var(--sp-4) var(--sp-3); + padding: var(--sp-4); + border-radius: var(--radius-sm); + border-left: 3px solid var(--color-gray-300); + background: var(--color-gray-100); +} +.annotation.blocking { border-left-color: var(--color-blocking); background: color-mix(in srgb, var(--color-blocking) 4%, white); } +.annotation.attention { border-left-color: var(--color-attention); background: color-mix(in srgb, var(--color-attention) 4%, white); } +.annotation.look { border-left-color: var(--color-look); background: color-mix(in srgb, var(--color-look) 4%, white); } +.annotation.safe { border-left-color: var(--color-safe); background: color-mix(in srgb, var(--color-safe) 4%, white); } + +.annotation-header { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-3); +} +.annotation-line { + font-family: var(--font-mono); + font-size: var(--type-caption); + color: var(--color-gray-500); +} +.annotation-body { + font-size: var(--type-small); + line-height: 1.5; + color: var(--color-gray-600); +} +.annotation-body code { + font-family: var(--font-mono); + background: color-mix(in srgb, var(--color-gray-200) 50%, transparent); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.875em; +} +.annotation-suggestion { + margin-top: var(--sp-3); +} +.annotation-suggestion strong { + font-size: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); + display: block; + margin-bottom: var(--sp-2); +} +.annotation-suggestion pre { + background: var(--color-slate); + color: var(--color-ivory); + padding: var(--sp-3); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: var(--type-caption); + line-height: 1.5; + overflow-x: auto; +} + +/* --- Responsive --- */ +@media (max-width: 640px) { + .diff-file { margin: var(--sp-3) var(--sp-3); } + .line-num { width: 32px; min-width: 32px; font-size: 10px; } + .line-code { font-size: var(--type-caption); padding: 0 var(--sp-2); } + .annotation { margin: 0 var(--sp-2) var(--sp-2); padding: var(--sp-3); } +} + +@media (prefers-reduced-motion: reduce) { + .diff-body, .diff-toggle .chevron { transition: none; } +} +``` + +### Syntax Highlight Tokens + +Minimal token classes for inline syntax coloring within diffs: + +```css +/* Apply these to elements wrapping tokens inside .line-code */ +.tok-kw { color: #C792EA; } /* keyword: const, let, async, await, return, if, else */ +.tok-fn { color: #82AAFF; } /* function name / method call */ +.tok-str { color: #C3E88D; } /* string literal */ +.tok-cm { color: #6A737D; } /* comment */ +.tok-num { color: #F78C6C; } /* number literal */ +.tok-op { color: #89DDFF; } /* operator: =, =>, ===, !== */ +.tok-typ { color: #FFCB6B; } /* type / interface name */ + +/* Inside diff additions/deletions, boost token visibility */ +.diff-line.addition .tok-kw, +.diff-line.deletion .tok-kw { font-weight: 600; } +``` + +--- + +## Pattern 5: Module Map (SVG) + +Box-and-arrow diagram for visualizing module architecture. Hot path highlighted. + +### HTML + +```html +
+

Module Map

+
+ + + + + + + + + + + + + + + + + + + + auth.ts + +142 −38 + + + + + + middleware.ts + +89 −12 + + + + + + db.ts + +5 −2 + + + + + authenticateRequest() + + + + findUser() + + + + + Entry point + + + Hot path (changed) + + + Dependency + + + Blocking issue + + + +
+
+``` + +### CSS + +```css +.module-map-section { + padding: var(--sp-6) var(--sp-7); +} +.module-map-section h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-5); +} +.diagram-container { + background: white; + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-md); + padding: var(--sp-4); + overflow-x: auto; +} +.diagram-container svg { + width: 100%; + height: auto; + min-width: 700px; +} + +/* Clickable modules (link to file diff) */ +.module { cursor: pointer; } +.module:hover rect { + filter: brightness(0.97); +} + +/* SVG construction rules: + - Modules: with 1.5px gray stroke (normal) or 2px severity stroke (flagged) + - Hot path: 2.5px stroke in --color-primary with arrow marker + - Normal deps: 1.5px stroke in --color-gray-400 with arrow marker + - Labels: font-family var(--font-mono), 12px bold for names, 10px for stats + - Entry point: triangle polygon pointing right, filled with --color-primary + - Legend: always present at bottom of SVG +*/ +``` + +--- + +## Pattern 6: JavaScript Interactions + +### Expand/Collapse File Diffs + +```js +(function () { + document.querySelectorAll('.diff-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const expanded = btn.getAttribute('aria-expanded') === 'true'; + const targetId = btn.getAttribute('aria-controls'); + const target = document.getElementById(targetId); + + btn.setAttribute('aria-expanded', String(!expanded)); + + if (expanded) { + target.style.maxHeight = target.scrollHeight + 'px'; + // Force reflow, then collapse + requestAnimationFrame(() => { + target.style.maxHeight = '0'; + target.classList.add('collapsed'); + }); + } else { + target.classList.remove('collapsed'); + target.style.maxHeight = target.scrollHeight + 'px'; + // Clean up after transition + target.addEventListener('transitionend', function handler() { + target.style.maxHeight = ''; + target.removeEventListener('transitionend', handler); + }); + } + }); + }); +})(); +``` + +### Filter Files by Severity + +```js +(function () { + const filterBtns = document.querySelectorAll('.filter-btn'); + const fileLinks = document.querySelectorAll('.file-link'); + const diffFiles = document.querySelectorAll('.diff-file'); + + filterBtns.forEach(btn => { + btn.addEventListener('click', () => { + // Update active state + filterBtns.forEach(b => { + b.classList.remove('active'); + b.setAttribute('aria-pressed', 'false'); + }); + btn.classList.add('active'); + btn.setAttribute('aria-pressed', 'true'); + + const severity = btn.dataset.filter; + + // Filter side nav links + fileLinks.forEach(link => { + const li = link.closest('li'); + if (severity === 'all' || link.dataset.severity === severity) { + li.style.display = ''; + } else { + li.style.display = 'none'; + } + }); + + // Filter diff file blocks + diffFiles.forEach(file => { + if (severity === 'all' || file.dataset.severity === severity) { + file.style.display = ''; + } else { + file.style.display = 'none'; + } + }); + }); + }); +})(); +``` + +### Keyboard Navigation (j/k between files) + +```js +(function () { + let currentFileIndex = -1; + const files = Array.from(document.querySelectorAll('.diff-file')); + + function scrollToFile(index) { + if (index < 0 || index >= files.length) return; + currentFileIndex = index; + files[index].scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Update active state in file nav + const fileId = files[index].id; + document.querySelectorAll('.file-link').forEach(link => { + link.classList.toggle('active', link.getAttribute('href') === '#' + fileId); + }); + } + + document.addEventListener('keydown', (e) => { + // Don't intercept when user is typing in an input/textarea + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + // Only respond to visible (non-filtered) files + const visibleFiles = files.filter(f => f.style.display !== 'none'); + + switch (e.key) { + case 'j': // Next file + e.preventDefault(); + currentFileIndex = Math.min(currentFileIndex + 1, visibleFiles.length - 1); + scrollToFile(files.indexOf(visibleFiles[currentFileIndex])); + break; + case 'k': // Previous file + e.preventDefault(); + currentFileIndex = Math.max(currentFileIndex - 1, 0); + scrollToFile(files.indexOf(visibleFiles[currentFileIndex])); + break; + case 'x': // Toggle expand/collapse current file + e.preventDefault(); + if (currentFileIndex >= 0 && currentFileIndex < files.length) { + const toggle = files[currentFileIndex].querySelector('.diff-toggle'); + if (toggle) toggle.click(); + } + break; + } + }); + + // Sync current file index on scroll + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const index = files.indexOf(entry.target); + if (index !== -1) currentFileIndex = index; + + // Update side nav active state + document.querySelectorAll('.file-link').forEach(link => { + link.classList.toggle('active', link.getAttribute('href') === '#' + entry.target.id); + }); + } + }); + }, { threshold: 0.3 }); + + files.forEach(file => observer.observe(file)); +})(); +``` + +### Jump-to-File via Anchor Links + +```js +(function () { + // Smooth scroll for file nav links + document.querySelectorAll('.file-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetId = link.getAttribute('href').slice(1); + const target = document.getElementById(targetId); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // Expand if collapsed + const body = target.querySelector('.diff-body'); + const toggle = target.querySelector('.diff-toggle'); + if (body && body.classList.contains('collapsed') && toggle) { + toggle.click(); + } + } + }); + }); +})(); +``` + +### Clickable Module Map + +```js +(function () { + // Click module box in SVG to jump to that file's diff + document.querySelectorAll('.module').forEach(mod => { + mod.addEventListener('click', () => { + const fileSlug = mod.dataset.file; + const target = document.getElementById('file-' + fileSlug); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); +})(); +``` + +--- + +## Pattern 7: Risk Map Click-to-Filter + +Connect the risk map rows to the severity filter. + +### JS + +```js +(function () { + document.querySelectorAll('.risk-row').forEach(row => { + row.addEventListener('click', () => { + const severity = row.dataset.severity; + const filterBtn = document.querySelector(`.filter-btn[data-filter="${severity}"]`); + if (filterBtn) filterBtn.click(); + }); + }); +})(); +``` + +--- + +## Composition Guide + +| Request Shape | Patterns to Combine | +|---|---| +| "Review this PR" | Shell + PR Summary + Risk Map + File Nav + Diff Blocks + Annotations + Keyboard Nav | +| "Annotate this diff" | Shell (no side nav) + Diff Blocks + Annotations | +| "Explain module architecture" | Shell + Module Map + Brief annotations per module | +| "PR writeup for reviewers" | Shell + PR Summary + Risk Map + File Nav + Diff Blocks + Annotations + Keyboard Nav | +| "What changed in this commit" | Shell (no side nav) + Diff Blocks (context-heavy, fewer annotations) | + +### Section Ordering + +1. PR summary header (title, author, branch, stats) +2. Risk map (severity distribution) +3. Module map (if architecture/dependency visualization needed) +4. File diffs in severity order: blocking first, safe last +5. Each diff block contains its annotations inline + +### Keyboard Shortcut Reference + +Include as a tooltip or small `
` block at bottom: + +```html +
+ Keyboard shortcuts +
+
j
Next file
+
k
Previous file
+
x
Toggle expand/collapse
+
+
+``` + +```css +.keyboard-help { + margin: var(--sp-7) var(--sp-7) var(--sp-5); + font-size: var(--type-small); + color: var(--color-gray-500); +} +.keyboard-help summary { + cursor: pointer; + font-weight: 500; +} +.keyboard-help summary:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.shortcut-list { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--sp-2) var(--sp-4); + margin-top: var(--sp-3); +} +.shortcut-list dt { text-align: right; } +kbd { + display: inline-block; + padding: 2px 6px; + border: 1px solid var(--color-gray-300); + border-radius: 3px; + background: var(--color-gray-100); + font-family: var(--font-mono); + font-size: var(--type-caption); + line-height: 1; +} +``` + +--- + +## Accessibility Checklist + +- [ ] All `` elements have `role="img"` and `aria-label` +- [ ] Expand/collapse buttons have `aria-expanded` and `aria-controls` +- [ ] Filter buttons use `aria-pressed` state +- [ ] File nav has `aria-label="File navigation"` +- [ ] Annotations have `role="note"` and descriptive `aria-label` +- [ ] Focus-visible outlines on all buttons and links +- [ ] Color is never the sole indicator (severity uses text labels + color) +- [ ] Keyboard shortcuts don't fire inside text inputs +- [ ] `prefers-reduced-motion` disables transitions and smooth scrolling +- [ ] Side nav scrolls independently with visible scroll region +- [ ] Semantic elements: `
` per file, `
+ + + + +
+``` + +### CSS + +```css +.export-bar { + position: sticky; + bottom: 0; + background: var(--bg-page); + border-top: 1px solid var(--border-default); + padding: var(--sp-3) var(--sp-5); + display: flex; + gap: var(--sp-3); + align-items: center; + justify-content: flex-end; + z-index: 50; +} + +.btn-primary { + background: var(--accent); + color: white; + border: none; + padding: var(--sp-2) var(--sp-4); + border-radius: var(--radius-sm); + font: var(--type-small); + font-weight: 500; + cursor: pointer; + box-shadow: var(--shadow-interactive); + transition: box-shadow 0.15s ease, background 0.15s ease; +} +.btn-primary:hover { + filter: brightness(0.9); + box-shadow: var(--shadow-interactive-hover); +} +.btn-primary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn-secondary { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-default); + padding: var(--sp-2) var(--sp-4); + border-radius: var(--radius-sm); + font: var(--type-small); + cursor: pointer; + transition: background 0.15s ease; +} +.btn-secondary:hover { + background: var(--bg-muted); +} +.btn-secondary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.pending-badge { + background: var(--color-warning); + color: white; + padding: var(--sp-1) var(--sp-3); + border-radius: 999px; + font: var(--type-caption); + font-weight: 600; + margin-right: auto; +} +``` + +### JS: Copy with Visual Feedback + +```js +function copyWithFeedback(btn, text) { + navigator.clipboard.writeText(text); + const original = btn.textContent; + btn.textContent = '✓ Copied!'; + const originalBg = btn.style.background; + btn.style.background = 'var(--color-success)'; + setTimeout(() => { + btn.textContent = original; + btn.style.background = originalBg; + }, 1500); +} +``` + +### JS: Pending Changes Counter + +```js +let changeCount = 0; + +function trackChange() { + changeCount++; + const badge = document.getElementById('pending-count'); + badge.textContent = `${changeCount} change${changeCount !== 1 ? 's' : ''}`; + badge.style.display = 'inline-block'; +} + +function resetPendingCount() { + changeCount = 0; + document.getElementById('pending-count').style.display = 'none'; +} +``` + +--- + +## Editor Type 1: Kanban Triage Board + +Drag tickets between columns (Now / Next / Later / Cut), then export the triage result. + +### HTML Structure + +```html +
+

Ticket Triage

+
+ +
+
+
+ +
+
+

Now 0

+
+ +
+
+
+

Next 0

+
+
+
+

Later 0

+
+
+
+

Cut 0

+
+
+
+``` + +### Card Element + +```html +
+
TICK-001
+
Implement auth flow
+
+ P1 + 3pt +
+
+ + +
+
+``` + +### Kanban CSS + +```css +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-4) var(--sp-5); + border-bottom: 1px solid var(--border-subtle); + flex-wrap: wrap; + gap: var(--sp-3); +} +.editor-header h1 { + font: var(--type-h2); + color: var(--text-primary); +} +.header-controls { + display: flex; + align-items: center; + gap: var(--sp-3); +} +.header-controls input { + width: 220px; +} + +.kanban { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sp-4); + padding: var(--sp-5); + min-height: 400px; +} +@media (max-width: 1024px) { + .kanban { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 640px) { + .kanban { grid-template-columns: 1fr; } +} + +.kanban-column { + background: var(--bg-muted); + border-radius: var(--radius-md); + padding: var(--sp-3); + min-height: 300px; + transition: background 0.15s ease; +} +.kanban-column.drag-over { + background: color-mix(in srgb, var(--accent) 8%, var(--bg-muted)); + outline: 2px dashed var(--accent); + outline-offset: -2px; +} + +.column-header { + font: var(--type-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: var(--sp-2) var(--sp-2) var(--sp-3); + display: flex; + justify-content: space-between; + align-items: center; +} +.column-header .count { + background: var(--bg-surface); + padding: var(--sp-1) var(--sp-2); + border-radius: 999px; + font: var(--type-caption); + min-width: 24px; + text-align: center; +} + +.card-container { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.ticket-card { + background: var(--bg-surface); + border-radius: var(--radius-sm); + padding: var(--sp-3); + box-shadow: var(--shadow-sm); + cursor: grab; + transition: box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease; + user-select: none; +} +.ticket-card:hover { + box-shadow: var(--shadow-md); +} +.ticket-card:active { + cursor: grabbing; +} + +.card-id { + font: var(--type-caption); + color: var(--text-muted); + margin-bottom: var(--sp-1); +} +.card-title { + font: var(--type-small); + font-weight: 500; + color: var(--text-primary); + margin-bottom: var(--sp-2); +} +.card-meta { + display: flex; + gap: var(--sp-2); + align-items: center; + margin-bottom: var(--sp-2); +} +.card-priority { + font: var(--type-caption); + padding: var(--sp-1) var(--sp-2); + border-radius: 999px; +} +.card-estimate { + font: var(--type-caption); + color: var(--text-muted); +} + +.card-tags { + display: flex; + gap: var(--sp-1); + flex-wrap: wrap; +} +.tag { + background: var(--bg-muted); + color: var(--text-secondary); + border: none; + padding: 2px var(--sp-2); + border-radius: var(--radius-xs); + font: var(--type-caption); + cursor: pointer; + transition: background 0.1s ease; +} +.tag:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); +} +.tag.active { + background: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--accent); + font-weight: 600; +} +.tag:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.active-filters { + display: flex; + gap: var(--sp-1); +} +.filter-pill { + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + padding: var(--sp-1) var(--sp-2); + border-radius: 999px; + font: var(--type-caption); + display: flex; + align-items: center; + gap: var(--sp-1); +} +.filter-pill button { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + font-size: 14px; + line-height: 1; + box-shadow: none; +} +``` + +### Drag-and-Drop JS + +```js +let draggedCard = null; + +function dragStart(e) { + draggedCard = e.target.closest('.ticket-card'); + draggedCard.style.opacity = '0.35'; + draggedCard.style.transform = 'rotate(2deg)'; + e.dataTransfer.effectAllowed = 'move'; +} + +function allowDrop(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const column = e.target.closest('.kanban-column'); + if (column) column.classList.add('drag-over'); +} + +function dropCard(e) { + e.preventDefault(); + const column = e.target.closest('.kanban-column'); + if (!column || !draggedCard) return; + column.querySelector('.card-container').appendChild(draggedCard); + draggedCard.style.opacity = '1'; + draggedCard.style.transform = 'none'; + column.classList.remove('drag-over'); + draggedCard = null; + updateCounts(); + trackChange(); +} + +document.addEventListener('dragend', () => { + document.querySelectorAll('.kanban-column').forEach(c => + c.classList.remove('drag-over') + ); + if (draggedCard) { + draggedCard.style.opacity = '1'; + draggedCard.style.transform = 'none'; + } +}); + +function updateCounts() { + document.querySelectorAll('.kanban-column').forEach(col => { + const count = col.querySelectorAll('.ticket-card').length; + col.querySelector('.count').textContent = count; + }); +} +``` + +### Tag Filtering JS + +```js +const activeFilters = new Set(); + +function filterByTag(tag) { + if (activeFilters.has(tag)) { + activeFilters.delete(tag); + } else { + activeFilters.add(tag); + } + applyFilters(); + renderFilterPills(); +} + +function filterCards(query) { + const q = query.toLowerCase(); + document.querySelectorAll('.ticket-card').forEach(card => { + const text = card.textContent.toLowerCase(); + const matchesQuery = !q || text.includes(q); + const matchesTags = activeFilters.size === 0 || + Array.from(activeFilters).some(t => + (card.dataset.tags || '').split(',').includes(t) + ); + card.style.display = (matchesQuery && matchesTags) ? '' : 'none'; + }); +} + +function applyFilters() { + filterCards(document.getElementById('filter-input').value); + // Update tag active states + document.querySelectorAll('.tag').forEach(el => { + el.classList.toggle('active', activeFilters.has(el.textContent)); + }); +} + +function renderFilterPills() { + const container = document.getElementById('active-filters'); + container.innerHTML = ''; + activeFilters.forEach(tag => { + const pill = document.createElement('span'); + pill.className = 'filter-pill'; + pill.innerHTML = `${tag} `; + container.appendChild(pill); + }); +} +``` + +### Kanban Export JS + +```js +function copyAsMarkdown() { + const columns = ['now', 'next', 'later', 'cut']; + let md = '# Triage Results\n\n'; + columns.forEach(col => { + const cards = document.querySelectorAll(`#col-${col} .ticket-card`); + md += `## ${col.charAt(0).toUpperCase() + col.slice(1)} (${cards.length})\n\n`; + cards.forEach(card => { + const id = card.querySelector('.card-id').textContent; + const title = card.querySelector('.card-title').textContent; + const tags = Array.from(card.querySelectorAll('.tag')).map(t => t.textContent).join(', '); + md += `- **${id}**: ${title}`; + if (tags) md += ` [${tags}]`; + md += '\n'; + }); + md += '\n'; + }); + copyWithFeedback(event.target, md); +} + +function copyAsJSON() { + const columns = ['now', 'next', 'later', 'cut']; + const result = {}; + columns.forEach(col => { + result[col] = []; + document.querySelectorAll(`#col-${col} .ticket-card`).forEach(card => { + result[col].push({ + id: card.querySelector('.card-id').textContent, + title: card.querySelector('.card-title').textContent, + tags: Array.from(card.querySelectorAll('.tag')).map(t => t.textContent), + }); + }); + }); + copyWithFeedback(event.target, JSON.stringify(result, null, 2)); +} +``` + +--- + +## Editor Type 2: Feature Flag Editor + +Toggle flags on/off, see dependency warnings, export the diff or full state. + +### HTML Structure + +```html +
+

Feature Flags

+
+ + +
+
+ +
+ +
+``` + +### Flag Row HTML + +```html +
+ +
+ dark_mode + ui + Enable dark mode across the application + +
+
+``` + +### Flag Editor CSS + +```css +.flag-list { + padding: var(--sp-4) var(--sp-5); + max-width: 720px; + margin: 0 auto; +} + +.flag-row { + display: flex; + align-items: flex-start; + gap: var(--sp-4); + padding: var(--sp-4) 0; + border-bottom: 1px solid var(--border-subtle); +} +.flag-row:last-child { + border-bottom: none; +} + +.flag-info { + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--sp-2); +} +.flag-name { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.flag-area { + font: var(--type-caption); + padding: var(--sp-1) var(--sp-2); + border-radius: 999px; +} +.flag-desc { + width: 100%; + font: var(--type-small); + color: var(--text-muted); + margin-top: var(--sp-1); +} +.flag-deps { + width: 100%; + font: var(--type-small); + margin-top: var(--sp-1); +} + +/* Toggle Switch */ +.toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + margin-top: 2px; +} +.toggle input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} +.toggle-slider { + position: absolute; + inset: 0; + background: var(--border-default); + border-radius: 24px; + cursor: pointer; + transition: background 0.2s ease; +} +.toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: var(--shadow-sm); +} +.toggle input:checked + .toggle-slider { + background: var(--accent); +} +.toggle input:checked + .toggle-slider::before { + transform: translateX(20px); +} +.toggle input:focus-visible + .toggle-slider { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Changed-state indicator */ +.flag-row.changed { + background: color-mix(in srgb, var(--color-warning) 6%, transparent); + margin: 0 calc(-1 * var(--sp-3)); + padding-left: var(--sp-3); + padding-right: var(--sp-3); + border-radius: var(--radius-sm); +} +``` + +### Flag Editor JS + +```js +// Define flags as data. Populate from user's request. +const FLAGS = { + dark_mode: { area: 'ui', desc: 'Enable dark mode across the application', requires: [] }, + new_nav: { area: 'ui', desc: 'Redesigned navigation sidebar', requires: [] }, + ai_search: { area: 'search', desc: 'AI-powered search results', requires: ['embeddings_v2'] }, + embeddings_v2:{ area: 'infra', desc: 'Use v2 embedding model', requires: [] }, + beta_api: { area: 'api', desc: 'Enable beta API endpoints', requires: [] }, + rate_limit_v2:{ area: 'api', desc: 'New rate limiting algorithm', requires: ['beta_api'] }, +}; + +let initialState = {}; +let currentState = {}; + +function initFlags() { + const list = document.querySelector('.flag-list'); + Object.entries(FLAGS).forEach(([key, flag]) => { + initialState[key] = false; + currentState[key] = false; + + const row = document.createElement('div'); + row.className = 'flag-row'; + row.setAttribute('role', 'listitem'); + row.id = `row-${key}`; + row.innerHTML = ` + +
+ ${key} + ${flag.area} + ${flag.desc} + +
+ `; + list.appendChild(row); + }); +} + +function toggleFlag(input) { + const flag = input.dataset.flag; + currentState[flag] = input.checked; + + // Check dependency warnings + const deps = FLAGS[flag].requires || []; + const unmet = deps.filter(d => !currentState[d]); + const depsEl = document.getElementById(`deps-${flag}`); + if (unmet.length && input.checked) { + depsEl.textContent = `⚠ Requires: ${unmet.join(', ')}`; + depsEl.style.color = 'var(--color-warning)'; + } else { + depsEl.textContent = ''; + } + + // Warn dependents when disabling + if (!input.checked) { + Object.entries(FLAGS).forEach(([k, f]) => { + if (f.requires.includes(flag) && currentState[k]) { + const el = document.getElementById(`deps-${k}`); + el.textContent = `⚠ Dependency disabled: ${flag}`; + el.style.color = 'var(--color-danger)'; + } + }); + } + + // Mark changed rows + const row = document.getElementById(`row-${flag}`); + row.classList.toggle('changed', currentState[flag] !== initialState[flag]); + + updatePendingCount(); +} + +function updatePendingCount() { + const changed = Object.keys(currentState).filter(k => currentState[k] !== initialState[k]); + const badge = document.getElementById('flag-changes'); + if (changed.length) { + badge.textContent = `${changed.length} pending`; + badge.style.display = 'inline-block'; + } else { + badge.style.display = 'none'; + } +} +``` + +### Flag Export JS + +```js +function copyAsMarkdown() { + const changed = Object.entries(currentState) + .filter(([k, v]) => v !== initialState[k]); + if (!changed.length) { + copyWithFeedback(event.target, '(no changes)'); + return; + } + let md = '# Flag Changes\n\n'; + changed.forEach(([k, v]) => { + md += `- ${v ? '✅ Enable' : '❌ Disable'} \`${k}\`\n`; + const deps = FLAGS[k].requires || []; + if (deps.length) md += ` - Requires: ${deps.join(', ')}\n`; + }); + copyWithFeedback(event.target, md); +} + +function copyAsJSON() { + const diff = {}; + Object.entries(currentState).forEach(([k, v]) => { + if (v !== initialState[k]) { + diff[k] = { from: initialState[k], to: v }; + } + }); + const output = { + environment: document.getElementById('env-select')?.value || 'dev', + timestamp: new Date().toISOString(), + changes: diff, + full_state: { ...currentState }, + }; + copyWithFeedback(event.target, JSON.stringify(output, null, 2)); +} + +function resetState() { + Object.keys(currentState).forEach(k => { + currentState[k] = initialState[k]; + const input = document.querySelector(`[data-flag="${k}"]`); + if (input) input.checked = initialState[k]; + const row = document.getElementById(`row-${k}`); + if (row) row.classList.remove('changed'); + const deps = document.getElementById(`deps-${k}`); + if (deps) deps.textContent = ''; + }); + updatePendingCount(); + resetPendingCount(); +} +``` + +--- + +## Editor Type 3: Split-Pane Prompt Tuner + +Edit a template on the left, see live-rendered previews on the right with sample inputs. + +### HTML Structure + +```html +
+

Prompt Tuner

+
+ + 0 chars · + 0 tokens (est.) + +
+
+ +
+
+

Template

+
You are a {{role}} assistant. Help the user {{task}}. Respond in {{tone}} tone.
+
+
+
+

Previews

+
+
+
Sample 1
+
+
+
+
Sample 2
+
+
+
+
Sample 3
+
+
+
+
+
+``` + +### Tuner CSS + +```css +.tuner-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-5); + padding: var(--sp-5); + min-height: calc(100vh - 200px); +} +@media (max-width: 768px) { + .tuner-layout { + grid-template-columns: 1fr; + min-height: auto; + } +} + +.editor-pane h2, +.preview-pane h2 { + font: var(--type-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-3); +} + +.editable-area { + background: var(--bg-surface); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + padding: var(--sp-4); + min-height: 200px; + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + white-space: pre-wrap; + word-break: break-word; +} +.editable-area:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.token-counter { + font: var(--type-caption); + color: var(--text-muted); + background: var(--bg-muted); + padding: var(--sp-1) var(--sp-3); + border-radius: 999px; +} + +.variable-legend { + display: flex; + gap: var(--sp-2); + flex-wrap: wrap; + margin-top: var(--sp-3); +} +.variable-chip { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: var(--sp-1) var(--sp-2); + border-radius: var(--radius-xs); + font: var(--type-caption); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--accent); +} +.variable-chip.unmatched { + background: color-mix(in srgb, var(--color-danger) 10%, transparent); + color: var(--color-danger); +} + +.sample-cards { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} +.sample-card { + padding: var(--sp-3); +} +.sample-label { + font: var(--type-caption); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: var(--sp-2); +} +.sample-preview { + font: var(--type-small); + line-height: 1.6; + color: var(--text-primary); +} + +/* Variable slot highlight (inside rendered previews) */ +.var-filled { + background: color-mix(in srgb, var(--color-success) 15%, transparent); + padding: 1px 3px; + border-radius: 2px; +} +.var-missing { + border-bottom: 2px dashed var(--color-danger); + color: var(--color-danger); +} +.var-slot { + background: color-mix(in srgb, var(--accent) 12%, transparent); + padding: 1px 4px; + border-radius: 3px; + border-bottom: 2px dashed var(--accent); +} +``` + +### Tuner JS + +```js +const SAMPLES = [ + { role: 'coding', task: 'debug a React component', tone: 'concise' }, + { role: 'writing', task: 'draft a blog post', tone: 'friendly' }, + { role: 'data', task: 'analyze sales trends', tone: 'formal' }, +]; + +function renderPreviews() { + const template = document.getElementById('template-editor').textContent; + + // Render each sample + SAMPLES.forEach((sample, i) => { + let filled = escapeHtml(template); + // Fill known variables + Object.entries(sample).forEach(([k, v]) => { + const re = new RegExp(`\\{\\{${k}\\}\\}`, 'g'); + filled = filled.replace(re, `${escapeHtml(v)}`); + }); + // Highlight unrecognized variables + filled = filled.replace(/\{\{(\w+)\}\}/g, + '{{$1}}'); + document.getElementById(`preview-${i}`).innerHTML = filled; + }); + + updateTokenCount(template); + updateVariableLegend(template); + trackChange(); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function updateTokenCount(text) { + const chars = text.length; + // Rough estimate: ~4 chars per token for English + const tokens = Math.ceil(chars / 4); + document.getElementById('char-count').textContent = chars; + document.getElementById('token-estimate').textContent = tokens; +} + +function updateVariableLegend(text) { + const vars = [...text.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]); + const unique = [...new Set(vars)]; + const sampleKeys = Object.keys(SAMPLES[0]); + const legend = document.getElementById('variable-legend'); + legend.innerHTML = unique.map(v => { + const matched = sampleKeys.includes(v); + return `${matched ? '✓' : '?'} {{${v}}}`; + }).join(''); +} +``` + +### Tuner Export JS + +```js +function copyAsMarkdown() { + const template = document.getElementById('template-editor').textContent; + const vars = [...new Set([...template.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))]; + let md = '# Prompt Template\n\n'; + md += '```\n' + template + '\n```\n\n'; + md += `**Variables:** ${vars.map(v => '`{{' + v + '}}`').join(', ')}\n\n`; + md += '## Sample Outputs\n\n'; + SAMPLES.forEach((sample, i) => { + let filled = template; + Object.entries(sample).forEach(([k, v]) => { + filled = filled.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v); + }); + md += `### Sample ${i + 1}\n\n${filled}\n\n`; + }); + copyWithFeedback(event.target, md); +} + +function copyAsJSON() { + const template = document.getElementById('template-editor').textContent; + const vars = [...new Set([...template.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))]; + const output = { + template: template, + variables: vars, + samples: SAMPLES.map(s => { + let filled = template; + Object.entries(s).forEach(([k, v]) => { + filled = filled.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v); + }); + return { inputs: s, rendered: filled }; + }), + }; + copyWithFeedback(event.target, JSON.stringify(output, null, 2)); +} + +function resetState() { + document.getElementById('template-editor').textContent = + 'You are a {{role}} assistant. Help the user {{task}}. Respond in {{tone}} tone.'; + renderPreviews(); + resetPendingCount(); +} +``` + +--- + +## Pattern Selection Guide + +| Need | Editor Type | Key Patterns | +|---|---|---| +| Reorder / categorize items | Kanban triage | Drag-drop, columns, tag filters, count badges | +| Toggle binary settings | Flag editor | Toggle switches, dependency warnings, diff export | +| Edit text with live preview | Split-pane tuner | Contenteditable, variable highlighting, sample rendering | +| Pick from constrained options | Config editor | Select dropdowns, radio groups, validation, export | +| Curate a dataset | List editor | Inline edit, add/remove rows, bulk select, CSV export | + +--- + +## Shared Anti-Patterns + +| Pattern | Why Wrong | Do Instead | +|---|---|---| +| Editor without export | User made decisions but can't extract them | Always include the export bar | +| `` for rich editing | Can't highlight variables or format content | Use `contenteditable` with variable slot highlighting | +| Alert-based feedback ("Copied!") | Blocks interaction, jarring | Use inline visual feedback (button text change) | +| No reset button | User can't undo mistakes | Always include reset in export bar | +| Hardcoded data in HTML | Can't adapt to different content | Define data as a JS object, render dynamically | +| `
` for tag filters | Not keyboard accessible | Use ` + + 0 nodes +
+``` + +### Ring JS + +```js +const RING_RADIUS = 150; +const CENTER = 200; +let nodes = []; + +function addNode() { + const id = `node-${Date.now()}`; + const label = `N${nodes.length + 1}`; + nodes.push({ id, label, active: true }); + renderRing(); +} + +function removeNode() { + if (nodes.length > 0) { + nodes.pop(); + renderRing(); + } +} + +function renderRing() { + const svg = document.getElementById('ring-viz'); + // Clear previous + svg.innerHTML = ''; + + const count = nodes.length; + document.getElementById('ring-count').textContent = `${count} node${count !== 1 ? 's' : ''}`; + + if (count === 0) return; + + const angleStep = (2 * Math.PI) / count; + + // Position nodes + nodes.forEach((node, i) => { + const angle = angleStep * i - Math.PI / 2; // start from top + node.x = CENTER + RING_RADIUS * Math.cos(angle); + node.y = CENTER + RING_RADIUS * Math.sin(angle); + }); + + // Draw arcs between adjacent nodes + for (let i = 0; i < count; i++) { + const a = nodes[i]; + const b = nodes[(i + 1) % count]; + const arc = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + arc.setAttribute('x1', a.x); + arc.setAttribute('y1', a.y); + arc.setAttribute('x2', b.x); + arc.setAttribute('y2', b.y); + arc.setAttribute('stroke', 'var(--border-default)'); + arc.setAttribute('stroke-width', '1.5'); + arc.setAttribute('opacity', '0.5'); + svg.appendChild(arc); + } + + // Draw node circles + nodes.forEach(node => { + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.style.cursor = 'pointer'; + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', node.x); + circle.setAttribute('cy', node.y); + circle.setAttribute('r', '18'); + circle.setAttribute('fill', node.active ? 'var(--accent)' : 'var(--bg-muted)'); + circle.setAttribute('stroke', 'var(--bg-page)'); + circle.setAttribute('stroke-width', '3'); + + const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); + title.textContent = node.label; + circle.appendChild(title); + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', node.x); + text.setAttribute('y', node.y + 4); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('font-size', '11'); + text.setAttribute('font-weight', '600'); + text.setAttribute('fill', 'white'); + text.textContent = node.label; + + g.appendChild(circle); + g.appendChild(text); + g.addEventListener('click', () => { + node.active = !node.active; + renderRing(); + }); + svg.appendChild(g); + }); + + // Key indicator dot for active nodes + const activeCount = nodes.filter(n => n.active).length; + const indicator = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + indicator.setAttribute('x', CENTER); + indicator.setAttribute('y', CENTER + 5); + indicator.setAttribute('text-anchor', 'middle'); + indicator.setAttribute('font-size', '20'); + indicator.setAttribute('font-weight', '600'); + indicator.setAttribute('fill', 'var(--text-primary)'); + indicator.textContent = `${activeCount}/${count}`; + svg.appendChild(indicator); +} +``` + +### Ring CSS + +```css +.ring-controls { + display: flex; + align-items: center; + gap: var(--sp-3); + justify-content: center; + padding: var(--sp-4); +} +.ring-count { + font: var(--type-caption); + color: var(--text-muted); +} +``` + +--- + +## Interactive Tooltip + +Shared tooltip pattern for all chart types. Single tooltip element, repositioned on hover. + +### HTML + +```html + +``` + +### CSS + +```css +.chart-tooltip { + display: none; + position: fixed; + background: var(--bg-surface); + color: var(--text-primary); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-xs); + font: var(--type-caption); + font-weight: 500; + pointer-events: none; + z-index: 100; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-default); + white-space: nowrap; +} +``` + +### JS + +```js +function initTooltips() { + const tooltip = document.getElementById('chart-tooltip'); + + document.querySelectorAll('.data-point, .bar').forEach(el => { + el.style.cursor = 'pointer'; + + el.addEventListener('mouseenter', (e) => { + const titleEl = el.querySelector('title'); + if (!titleEl) return; + tooltip.textContent = titleEl.textContent; + tooltip.style.display = 'block'; + tooltip.setAttribute('aria-hidden', 'false'); + positionTooltip(e, tooltip); + }); + + el.addEventListener('mousemove', (e) => { + positionTooltip(e, tooltip); + }); + + el.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + tooltip.setAttribute('aria-hidden', 'true'); + }); + }); +} + +function positionTooltip(e, tooltip) { + const pad = 12; + let x = e.clientX + pad; + let y = e.clientY - pad - tooltip.offsetHeight; + // Keep tooltip on screen + if (x + tooltip.offsetWidth > window.innerWidth) { + x = e.clientX - pad - tooltip.offsetWidth; + } + if (y < 0) { + y = e.clientY + pad; + } + tooltip.style.left = x + 'px'; + tooltip.style.top = y + 'px'; +} + +// Initialize on load +document.addEventListener('DOMContentLoaded', initTooltips); +``` + +--- + +## Comparison Table with Visual Bars + +Tabular data with inline bar indicators for at-a-glance comparison. + +### HTML + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureScoreDistributionStatus
Performance85 +
+
+
+
Good
Reliability72 +
+
+
+
Fair
Security94 +
+
+
+
Good
+``` + +### Table CSS + +```css +.data-table { + width: 100%; + border-collapse: collapse; + font: var(--type-small); +} +.data-table th { + text-align: left; + font: var(--type-caption); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: var(--sp-2) var(--sp-3); + border-bottom: 2px solid var(--border-default); +} +.data-table td { + padding: var(--sp-3); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} +.data-table tr:hover td { + background: var(--bg-muted); +} +.data-table .mono { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; +} + +.bar-cell { + width: 180px; + height: 14px; + background: var(--bg-muted); + border-radius: 7px; + overflow: hidden; +} +.bar-fill { + height: 100%; + border-radius: 7px; + transition: width 0.4s ease; +} +``` + +--- + +## Metric Callout Cards + +Top-level KPIs rendered as a row of metric cards above charts. + +### HTML + +```html +
+
+ Total Users + 12,847 + ↑ 12.3% +
+
+ Avg Response + 142ms + ↑ 8ms +
+
+ Error Rate + 0.3% + ↓ 0.1% +
+
+ Uptime + 99.97% + +
+
+``` + +### Metric CSS + +```css +.metric-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sp-4); + margin-bottom: var(--sp-5); +} +@media (max-width: 768px) { + .metric-row { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 480px) { + .metric-row { grid-template-columns: 1fr; } +} + +.metric-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-1); +} +.metric-label { + font: var(--type-caption); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.metric-value { + font-family: var(--font-mono); + font-size: 28px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.1; +} +.metric-delta { + font: var(--type-caption); + font-weight: 600; +} +.metric-delta.positive { color: var(--color-success); } +.metric-delta.negative { color: var(--color-danger); } +.metric-delta.neutral { color: var(--text-muted); } +``` + +--- + +## Dashboard Layout + +Assembles metrics, charts, and tables into a single-page dashboard. + +### HTML + +```html +
+
+

Dashboard Title

+

Last updated:

+
+
+ + +
+
+ +
+ +
+
+ +
+
+ + +
+
+
+

Trend Over Time

+ +
+
+

Distribution

+ +
+
+
+ + +
+

Details

+ +
+
+``` + +### Dashboard CSS + +```css +.dash-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--sp-5); + border-bottom: 1px solid var(--border-subtle); + flex-wrap: wrap; + gap: var(--sp-3); +} +.dash-header h1 { + font: var(--type-h2); + color: var(--text-primary); +} +.dash-subtitle { + font: var(--type-small); + color: var(--text-muted); + margin-top: var(--sp-1); +} +.dash-filters { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.dashboard { + padding: var(--sp-5); +} +.dash-section { + margin-bottom: var(--sp-6); +} +.dash-section h2 { + font: var(--type-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-3); +} + +.dash-charts { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--sp-5); +} +@media (max-width: 768px) { + .dash-charts { grid-template-columns: 1fr; } +} + +.chart-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: var(--sp-4); +} +.chart-card h2 { + margin-bottom: var(--sp-3); +} +``` + +--- + +## Legend Patterns + +### Horizontal Legend (below chart) + +```html +
+
+ + TypeScript +
+
+ + Go +
+
+ + Python +
+
+``` + +### Vertical Legend (beside donut) + +```html +
+
+ + TypeScript + 40% +
+ +
+``` + +### Legend CSS + +```css +.legend { + display: flex; + gap: var(--sp-4); +} +.legend-horizontal { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + padding: var(--sp-3) 0; +} +.legend-vertical { + flex-direction: column; + gap: var(--sp-2); +} +.legend-item { + display: flex; + align-items: center; + gap: var(--sp-2); +} +.legend-swatch { + width: 12px; + height: 12px; + border-radius: 3px; + flex-shrink: 0; +} +.legend-label { + font: var(--type-caption); + color: var(--text-secondary); +} +.legend-value { + font: var(--type-caption); + font-family: var(--font-mono); + color: var(--text-muted); + margin-left: auto; +} +``` + +--- + +## Color Scales + +### Sequential (single-hue, light-to-dark) + +For ordered data (intensity, frequency, temperature). + +```css +/* Based on accent color with opacity steps */ +.seq-1 { fill: color-mix(in srgb, var(--accent) 15%, transparent); } +.seq-2 { fill: color-mix(in srgb, var(--accent) 30%, transparent); } +.seq-3 { fill: color-mix(in srgb, var(--accent) 50%, transparent); } +.seq-4 { fill: color-mix(in srgb, var(--accent) 70%, transparent); } +.seq-5 { fill: color-mix(in srgb, var(--accent) 90%, transparent); } +``` + +### Categorical (distinct hues) + +For unordered categories. Use the four semantic colors. + +```css +.cat-a { fill: var(--accent); } /* Primary series */ +.cat-b { fill: var(--color-info); } /* Secondary series */ +.cat-c { fill: var(--color-success); } /* Tertiary series */ +.cat-d { fill: var(--color-warning); } /* Quaternary series */ +/* Avoid color-danger for data; reserve for error states */ +``` + +### Diverging (positive/negative) + +For data with a meaningful midpoint (profit/loss, above/below average). + +```css +.div-negative { fill: var(--color-danger); } +.div-neutral { fill: var(--text-muted); } +.div-positive { fill: var(--color-success); } +``` + +--- + +## Chart Entry Animation + +Bars grow up from the x-axis; lines draw in from left. Disabled when `prefers-reduced-motion` is set (handled by the global CSS reset). + +### Bar Growth Animation + +```css +.bar { + transform-origin: bottom; + animation: bar-grow 0.5s ease-out both; +} +@keyframes bar-grow { + from { transform: scaleY(0); } + to { transform: scaleY(1); } +} + +/* Stagger each bar */ +.bar:nth-child(1) { animation-delay: 0.0s; } +.bar:nth-child(2) { animation-delay: 0.05s; } +.bar:nth-child(3) { animation-delay: 0.1s; } +.bar:nth-child(4) { animation-delay: 0.15s; } +.bar:nth-child(5) { animation-delay: 0.2s; } +.bar:nth-child(6) { animation-delay: 0.25s; } +.bar:nth-child(7) { animation-delay: 0.3s; } +.bar:nth-child(8) { animation-delay: 0.35s; } +``` + +Note: `transform-origin: bottom` requires the bars to be positioned using `y` and `height` attributes (not `transform`). For SVG `` elements, apply the animation to a `` wrapper or use JS-based animation. + +### JS-Based Bar Animation (more reliable for SVG) + +```js +function animateBars() { + const bars = document.querySelectorAll('.bar'); + bars.forEach((bar, i) => { + const targetHeight = parseFloat(bar.getAttribute('height')); + const targetY = parseFloat(bar.getAttribute('y')); + const baseY = 260; // x-axis position + bar.setAttribute('height', '0'); + bar.setAttribute('y', baseY); + + setTimeout(() => { + bar.style.transition = 'height 0.4s ease-out, y 0.4s ease-out'; + bar.setAttribute('height', targetHeight); + bar.setAttribute('y', targetY); + }, i * 60); + }); +} + +// Respect reduced motion +if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + document.addEventListener('DOMContentLoaded', animateBars); +} +``` + +### Line Draw-In Animation + +```css +.chart polyline { + stroke-dasharray: 1000; + stroke-dashoffset: 1000; + animation: line-draw 1s ease-out forwards; +} +@keyframes line-draw { + to { stroke-dashoffset: 0; } +} +``` + +### JS-Based Line Draw (accurate dasharray) + +```js +function animateLines() { + document.querySelectorAll('polyline').forEach(line => { + const length = line.getTotalLength(); + line.style.strokeDasharray = length; + line.style.strokeDashoffset = length; + line.style.transition = 'stroke-dashoffset 0.8s ease-out'; + // Trigger after a frame so the transition fires + requestAnimationFrame(() => { + line.style.strokeDashoffset = '0'; + }); + }); +} + +if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + document.addEventListener('DOMContentLoaded', animateLines); +} +``` + +--- + +## Hover States for Data Points + +```css +.data-point { + transition: r 0.15s ease, opacity 0.15s ease; + cursor: pointer; +} +.data-point:hover { + r: 7; + opacity: 1; +} + +.bar { + transition: opacity 0.15s ease; + cursor: pointer; +} +.bar:hover { + opacity: 1; + filter: brightness(1.1); +} +``` + +--- + +## Responsive Chart Behavior + +```css +/* Charts fill container width */ +.chart { + width: 100%; + height: auto; +} + +/* On mobile, force charts to minimum readable width with horizontal scroll */ +@media (max-width: 640px) { + .chart-card { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .chart { + min-width: 480px; + } + .dash-charts { + grid-template-columns: 1fr; + } + .metric-row { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 480px) { + .metric-row { + grid-template-columns: 1fr; + } +} +``` + +--- + +## Print Styles + +```css +@media print { + body { + background: white !important; + color: black !important; + } + .dash-header, + .dash-filters, + .chart-tooltip, + .btn-primary, + .btn-secondary { + display: none !important; + } + .chart-card, + .metric-card { + border: 1px solid #ccc !important; + box-shadow: none !important; + break-inside: avoid; + } + .bar { fill: #333 !important; } + polyline { stroke: #333 !important; } + .data-point { fill: #333 !important; } + .legend-swatch { print-color-adjust: exact; -webkit-print-color-adjust: exact; } +} +``` + +--- + +## Data Generation Helper + +Generate chart data from JS objects. Keeps data separate from rendering. + +```js +function generateBarChart(containerId, data, options = {}) { + const { width = 600, height = 300, color = 'var(--accent)' } = options; + const margin = { top: 20, right: 20, bottom: 40, left: 50 }; + const plotW = width - margin.left - margin.right; + const plotH = height - margin.top - margin.bottom; + + const maxVal = Math.max(...data.map(d => d.value)); + const barWidth = Math.min(40, (plotW / data.length) * 0.6); + const gap = plotW / data.length; + + let svg = ``; + + // Axes and grid + svg += ``; + svg += ``; + + // Grid lines and Y labels + for (let i = 0; i <= 3; i++) { + const y = margin.top + (plotH / 3) * i; + const val = Math.round(maxVal * (1 - i / 3)); + svg += ``; + svg += `${val}`; + } + + // Bars + data.forEach((d, i) => { + const x = margin.left + gap * i + (gap - barWidth) / 2; + const barH = (d.value / maxVal) * plotH; + const y = margin.top + plotH - barH; + svg += `${d.label}: ${d.value}`; + svg += `${d.label}`; + }); + + svg += ''; + document.getElementById(containerId).innerHTML = svg; +} +``` + +--- + +## Filter Controls + +Wire time-range or category filters to chart re-rendering. + +```js +function filterData(range) { + // Determine data subset based on range + const now = Date.now(); + const rangeMs = { '7d': 7, '30d': 30, '90d': 90 }[range] * 86400000; + const filtered = ALL_DATA.filter(d => (now - d.timestamp) <= rangeMs); + + // Re-render charts with filtered data + generateBarChart('bar-container', filtered.map(d => ({ label: d.date, value: d.value }))); + updateMetrics(filtered); + updateTable(filtered); + + // Update the last-updated timestamp + const el = document.getElementById('last-updated'); + if (el) el.textContent = new Date().toLocaleString(); +} +``` + +--- + +## Pattern Selection Guide + +| Need | Pattern | Key Elements | +|---|---|---| +| Compare values across categories | Bar chart | Bars, labels, grid, optional grouping | +| Show change over time | Line chart | Polyline, area fill, data points | +| Show part-of-whole | Donut chart | Stroke-dasharray circles, center label, legend | +| Top-level KPIs | Metric cards | Value, label, delta with color coding | +| Detailed ranked data | Comparison table | Table rows with inline bar fills | +| Relationships / network | Ring visualization | Positioned nodes, connecting lines | +| Full overview | Dashboard layout | Header + metrics + charts + table | + +--- + +## Anti-Patterns + +| Pattern | Why Wrong | Do Instead | +|---|---|---| +| Canvas for static charts | Not accessible, blurry on zoom, can't style with CSS | Use inline SVG | +| Chart.js / D3 CDN import | Breaks self-contained constraint | Build SVG with vanilla JS or static markup | +| Colors without meaning | Decorative gradients obscure data | Use categorical or sequential color scales from tokens | +| Missing `` on data elements | Screen readers can't access data values | Every bar, point, and segment needs a `<title>` | +| Charts without labels | Data is unreadable without context | Always include axis labels, a legend, and a chart title | +| Fixed-width charts | Break on mobile, ignore container | Use `viewBox` + `width: 100%` for responsiveness | +| Tooltips via `title` attribute only | Inconsistent across browsers, can't style | Use the JS tooltip pattern with positioned div | +| Animation without reduced-motion check | Triggers vestibular disorders | Guard all animation behind `prefers-reduced-motion` | +| Using color alone to convey meaning | Fails for color-blind users | Supplement with labels, patterns, or shapes | diff --git a/skills/meta/html-artifact/references/shape-design-prototype.md b/skills/meta/html-artifact/references/shape-design-prototype.md new file mode 100644 index 00000000..2f9bf282 --- /dev/null +++ b/skills/meta/html-artifact/references/shape-design-prototype.md @@ -0,0 +1,953 @@ +# Shape: Design Prototype + +Loaded when `detect-shape.py` returns `prototype`. For artifacts where the user adjusts parameters and sees results live: design systems, component variants, animation sandboxes, clickable flows. + +**Theme:** Interactive Warm (default). Clean surface, blue accent for actions, prominent shadows on controls. + +**Core principle:** Prototypes are INTERACTIVE. Every parameter the user can change updates the preview in real time. Every prototype ends with an export mechanism (Copy Parameters, Copy CSS, Copy Config). + +--- + +## Layout Patterns + +| Layout | Use When | Structure | +|---|---|---| +| Split panel | Controls + live preview | Left: controls panel (280-320px fixed), Right: preview (flex: 1) | +| Contact sheet | Variant comparison | Grid of equal cells, each with rendered preview + label | +| Sandbox | Animation tuning | Top: preview area with play/reset, Bottom: control panel | +| Swatch grid | Color/token display | Grid of clickable swatches with copy-on-click | + +### Split Panel (controls + preview) + +Primary layout. Controls on the left, live preview on the right. Stacks on mobile. + +```html +<div class="prototype-layout"> + <aside class="controls-panel" aria-label="Design controls"> + <h2>Controls</h2> + <!-- Sliders, selectors, toggles --> + <button class="export-btn" onclick="copyParams()">Copy Parameters</button> + </aside> + <section class="preview-panel" aria-label="Live preview"> + <div class="preview-surface" id="preview"> + <!-- Rendered output updates here --> + </div> + </section> +</div> +``` + +```css +.prototype-layout { + display: grid; + grid-template-columns: 300px 1fr; + gap: var(--sp-5); + min-height: 80vh; +} + +@media (max-width: 640px) { + .prototype-layout { + grid-template-columns: 1fr; + } +} + +.controls-panel { + padding: var(--sp-5); + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + overflow-y: auto; + max-height: 100vh; + position: sticky; + top: 0; +} + +.controls-panel h2 { + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-4); +} + +.preview-panel { + padding: var(--sp-6); + display: flex; + align-items: center; + justify-content: center; +} + +.preview-surface { + width: 100%; + min-height: 300px; + background: var(--bg-muted); + border-radius: var(--radius-md); + padding: var(--sp-6); + transition: all 0.2s ease; +} +``` + +### Sandbox (animation tuning) + +Preview area with play/reset buttons above a controls panel. + +```html +<div class="sandbox"> + <section class="sandbox-preview" aria-label="Animation preview"> + <div class="animated-element" id="target"> + <!-- The thing being animated --> + </div> + <div class="sandbox-actions"> + <button onclick="playAnimation()" aria-label="Play animation">▶ Play</button> + <button onclick="resetAnimation()" aria-label="Reset animation">↺ Reset</button> + </div> + </section> + <section class="sandbox-controls" aria-label="Animation controls"> + <!-- Sliders, selectors, checkboxes --> + </section> +</div> +``` + +```css +.sandbox { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.sandbox-preview { + background: var(--bg-muted); + border-radius: var(--radius-md); + padding: var(--sp-7); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-5); + min-height: 300px; +} + +.sandbox-actions { + display: flex; + gap: var(--sp-3); +} + +.sandbox-controls { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--sp-4); + padding: var(--sp-4); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); +} +``` + +```js +function playAnimation() { + const target = document.getElementById('target'); + const duration = document.getElementById('duration-slider').value; + const easing = document.getElementById('easing-select').value; + target.style.transition = 'all ' + duration + 'ms ' + easing; + target.classList.add('animated'); +} + +function resetAnimation() { + const target = document.getElementById('target'); + target.style.transition = 'none'; + target.classList.remove('animated'); +} +``` + +--- + +## Control Patterns + +### Slider with Live CSS Variable Update + +The fundamental prototype control. Label, range input, value readout. Changing the slider instantly updates a CSS custom property on the preview. + +```html +<div class="control-row"> + <label for="padding-slider">Padding</label> + <input type="range" id="padding-slider" min="4" max="64" value="16" + oninput="updateVar('--preview-padding', this.value + 'px'); updateReadout('padding-value', this.value + 'px')"> + <span id="padding-value" class="value-display" aria-live="polite">16px</span> +</div> +``` + +```css +.control-row { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) 0; +} + +.control-row label { + min-width: 120px; + font: var(--type-small); + color: var(--text-secondary); +} + +.control-row input[type="range"] { + flex: 1; + accent-color: var(--accent); +} + +.value-display { + min-width: 60px; + text-align: right; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-muted); + user-select: all; +} +``` + +```js +function updateVar(property, value) { + document.getElementById('preview').style.setProperty(property, value); +} + +function updateReadout(id, value) { + document.getElementById(id).textContent = value; +} +``` + +### Easing Curve Selector + +Dropdown for timing functions. Common easing options plus custom cubic-bezier for spring effects. + +```html +<div class="control-row"> + <label for="easing-select">Easing</label> + <select id="easing-select" onchange="updateEasing(this.value)"> + <option value="linear">Linear</option> + <option value="ease">Ease</option> + <option value="ease-in">Ease In</option> + <option value="ease-out" selected>Ease Out</option> + <option value="ease-in-out">Ease In-Out</option> + <option value="cubic-bezier(.34,1.56,.64,1)">Spring</option> + <option value="cubic-bezier(.16,1,.3,1)">Smooth Out</option> + <option value="cubic-bezier(.68,-.55,.27,1.55)">Back</option> + </select> +</div> +``` + +```js +function updateEasing(value) { + document.getElementById('target').style.transitionTimingFunction = value; +} +``` + +### Toggle Switch + +Boolean control for on/off states (e.g., "Show border", "Enable shadow"). + +```html +<div class="control-row"> + <label for="shadow-toggle">Shadow</label> + <label class="toggle-switch"> + <input type="checkbox" id="shadow-toggle" checked + onchange="toggleFeature('--preview-shadow', this.checked ? 'var(--shadow-md)' : 'none')"> + <span class="toggle-track" aria-hidden="true"></span> + </label> +</div> +``` + +```css +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-track { + position: absolute; + inset: 0; + background: var(--border-default); + border-radius: 12px; + cursor: pointer; + transition: background 0.2s ease; +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: var(--bg-surface); + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: transform 0.2s ease; +} + +.toggle-switch input:checked + .toggle-track { + background: var(--accent); +} + +.toggle-switch input:checked + .toggle-track::after { + transform: translateX(20px); +} + +.toggle-switch input:focus-visible + .toggle-track { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +``` + +```js +function toggleFeature(property, value) { + document.getElementById('preview').style.setProperty(property, value); +} +``` + +### Color Picker + +Inline color input with hex readout and copy-on-click. + +```html +<div class="control-row"> + <label for="accent-picker">Accent</label> + <input type="color" id="accent-picker" value="#5B8DEF" + oninput="updateVar('--accent', this.value); updateReadout('accent-value', this.value)"> + <span id="accent-value" class="value-display" onclick="navigator.clipboard.writeText(this.textContent)" + role="button" tabindex="0" aria-label="Copy color value">#5B8DEF</span> +</div> +``` + +### Control Group Separator + +Visually separates control groups within the panel. + +```html +<div class="control-group-label">Typography</div> +``` + +```css +.control-group-label { + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + padding: var(--sp-3) 0 var(--sp-2); + margin-top: var(--sp-3); + border-top: 1px solid var(--border-subtle); +} +``` + +--- + +## Component Variant Contact Sheet + +Grid of rendered component variants for visual comparison. Each cell shows one parameter combination with a label. + +```html +<section aria-label="Component variants"> + <h2>Button Variants</h2> + <div class="variant-grid"> + <div class="variant-cell"> + <div class="variant-preview"> + <!-- Rendered component at this variant --> + <button class="btn-sm btn-primary">Submit</button> + </div> + <div class="variant-label"> + <code>size=sm intent=primary</code> + </div> + </div> + <div class="variant-cell"> + <div class="variant-preview"> + <button class="btn-md btn-primary">Submit</button> + </div> + <div class="variant-label"> + <code>size=md intent=primary</code> + </div> + </div> + <!-- repeat for each variant combination --> + </div> +</section> +``` + +```css +.variant-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--sp-4); +} + +.variant-cell { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.variant-preview { + padding: var(--sp-5); + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-muted); + min-height: 80px; +} + +.variant-label { + padding: var(--sp-2) var(--sp-3); + border-top: 1px solid var(--border-subtle); + background: var(--bg-surface); +} + +.variant-label code { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); +} +``` + +--- + +## Design Token Display + +### Color Swatch Grid + +Clickable swatches that copy the hex value on click. + +```html +<section aria-label="Color palette"> + <h2>Colors</h2> + <div class="swatch-grid"> + <button class="swatch" style="--swatch-color: #D97757" + onclick="copySwatch(this, '#D97757')" aria-label="Copy color Clay #D97757"> + <span class="swatch-sample" style="background: var(--swatch-color)"></span> + <span class="swatch-name">Clay</span> + <span class="swatch-value">#D97757</span> + </button> + <button class="swatch" style="--swatch-color: #141413" + onclick="copySwatch(this, '#141413')" aria-label="Copy color Slate #141413"> + <span class="swatch-sample" style="background: var(--swatch-color)"></span> + <span class="swatch-name">Slate</span> + <span class="swatch-value">#141413</span> + </button> + <!-- more swatches --> + </div> +</section> +``` + +```css +.swatch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--sp-3); +} + +.swatch { + all: unset; + cursor: pointer; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; + text-align: center; + transition: box-shadow 0.15s ease; +} + +.swatch:hover { + box-shadow: var(--shadow-md); +} + +.swatch:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.swatch-sample { + display: block; + height: 64px; +} + +.swatch-name { + display: block; + font: var(--type-small); + font-weight: 500; + padding: var(--sp-2) var(--sp-2) 0; + color: var(--text-primary); +} + +.swatch-value { + display: block; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + padding: 0 var(--sp-2) var(--sp-2); +} +``` + +```js +function copySwatch(el, hex) { + navigator.clipboard.writeText(hex); + var name = el.querySelector('.swatch-name'); + var original = name.textContent; + name.textContent = 'Copied!'; + setTimeout(function() { name.textContent = original; }, 1200); +} +``` + +### Typography Scale Display + +```html +<section aria-label="Typography scale"> + <h2>Type Scale</h2> + <div class="type-specimen"> + <div class="type-row"> + <span class="type-label">Display</span> + <span class="type-sample" style="font: var(--type-display)">The quick brown fox</span> + </div> + <div class="type-row"> + <span class="type-label">H1</span> + <span class="type-sample" style="font: var(--type-h1)">The quick brown fox</span> + </div> + <div class="type-row"> + <span class="type-label">H2</span> + <span class="type-sample" style="font: var(--type-h2)">The quick brown fox</span> + </div> + <div class="type-row"> + <span class="type-label">Body</span> + <span class="type-sample" style="font: var(--type-body)">The quick brown fox</span> + </div> + <div class="type-row"> + <span class="type-label">Small</span> + <span class="type-sample" style="font: var(--type-small)">The quick brown fox</span> + </div> + <div class="type-row"> + <span class="type-label">Caption</span> + <span class="type-sample" style="font: var(--type-caption)">THE QUICK BROWN FOX</span> + </div> + </div> +</section> +``` + +```css +.type-specimen { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.type-row { + display: flex; + align-items: baseline; + gap: var(--sp-4); + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.type-label { + min-width: 80px; + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +``` + +### Spacing Scale Display + +```html +<section aria-label="Spacing scale"> + <h2>Spacing</h2> + <div class="spacing-specimen"> + <div class="spacing-row"> + <span class="spacing-label">sp-1 (4px)</span> + <div class="spacing-bar" style="width: 4px;"></div> + </div> + <div class="spacing-row"> + <span class="spacing-label">sp-2 (8px)</span> + <div class="spacing-bar" style="width: 8px;"></div> + </div> + <!-- sp-3 through sp-8 --> + </div> +</section> +``` + +```css +.spacing-specimen { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.spacing-row { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.spacing-label { + min-width: 100px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); +} + +.spacing-bar { + height: 24px; + background: var(--accent); + border-radius: var(--radius-xs); + opacity: 0.6; +} +``` + +--- + +## Animation Patterns + +### Staggered Keyframe Sequence + +Multi-step choreographed animation. Each step fires at a specific delay. Use for completion sequences, loading choreography, or multi-element entrances. + +```css +/* Stagger delays for choreographed sequence */ +.step-1 { animation-delay: 0ms; } +.step-2 { animation-delay: 80ms; } +.step-3 { animation-delay: 120ms; } +.step-4 { animation-delay: 200ms; } +.step-5 { animation-delay: 600ms; } + +/* Spring easing — "feels alive" bounce */ +.spring { + transition-timing-function: cubic-bezier(.34, 1.56, .64, 1); +} + +/* Smooth deceleration — natural stop */ +.smooth-out { + transition-timing-function: cubic-bezier(.16, 1, .3, 1); +} + +/* Snap — quick response, no overshoot */ +.snap { + transition-timing-function: cubic-bezier(0, 0.7, 0.3, 1); +} +``` + +### CSS Keyframe Templates + +```css +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes check-draw { + from { stroke-dashoffset: 24; } + to { stroke-dashoffset: 0; } +} + +@keyframes fill-bar { + from { width: 0; } + to { width: var(--fill-target, 100%); } +} +``` + +### Reduced Motion Override + +Already handled by the global CSS reset in design-system.md. But for prototype-specific animation controls, offer an explicit toggle: + +```html +<div class="control-row"> + <label for="motion-toggle">Reduced Motion</label> + <label class="toggle-switch"> + <input type="checkbox" id="motion-toggle" + onchange="document.body.classList.toggle('reduce-motion', this.checked)"> + <span class="toggle-track" aria-hidden="true"></span> + </label> +</div> +``` + +```css +body.reduce-motion *, +body.reduce-motion *::before, +body.reduce-motion *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; +} +``` + +--- + +## Drag-to-Reorder + +Interactive flow or sequence reordering. Items are draggable, drop zones highlight on dragover. + +```html +<div class="reorder-list" id="flow-steps" aria-label="Reorderable flow steps"> + <div class="flow-step" draggable="true" + ondragstart="dragStart(event)" ondragover="dragOver(event)" + ondrop="drop(event)" ondragend="dragEnd(event)"> + <span class="grip" aria-hidden="true">⋮⋮</span> + <span class="step-content">Step 1: Validate input</span> + </div> + <div class="flow-step" draggable="true" + ondragstart="dragStart(event)" ondragover="dragOver(event)" + ondrop="drop(event)" ondragend="dragEnd(event)"> + <span class="grip" aria-hidden="true">⋮⋮</span> + <span class="step-content">Step 2: Transform data</span> + </div> + <div class="flow-step" draggable="true" + ondragstart="dragStart(event)" ondragover="dragOver(event)" + ondrop="drop(event)" ondragend="dragEnd(event)"> + <span class="grip" aria-hidden="true">⋮⋮</span> + <span class="step-content">Step 3: Persist to store</span> + </div> +</div> +``` + +```css +.reorder-list { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.flow-step { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + cursor: default; + transition: opacity 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.flow-step[dragging] { + opacity: 0.35; + transform: rotate(2deg); +} + +.flow-step.drag-over { + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.grip { + cursor: grab; + color: var(--text-muted); + font-size: 14px; + line-height: 1; + user-select: none; +} + +.grip:hover { + color: var(--text-secondary); +} + +.step-content { + font: var(--type-small); + flex: 1; +} +``` + +```js +var draggedEl = null; + +function dragStart(e) { + draggedEl = e.currentTarget; + draggedEl.setAttribute('dragging', ''); + e.dataTransfer.effectAllowed = 'move'; +} + +function dragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + var target = e.currentTarget; + if (target !== draggedEl) { + target.classList.add('drag-over'); + } +} + +function drop(e) { + e.preventDefault(); + var target = e.currentTarget; + target.classList.remove('drag-over'); + if (target !== draggedEl && draggedEl) { + var list = target.parentNode; + var items = Array.from(list.children); + var draggedIdx = items.indexOf(draggedEl); + var targetIdx = items.indexOf(target); + if (draggedIdx < targetIdx) { + list.insertBefore(draggedEl, target.nextSibling); + } else { + list.insertBefore(draggedEl, target); + } + } +} + +function dragEnd(e) { + e.currentTarget.removeAttribute('dragging'); + document.querySelectorAll('.drag-over').forEach(function(el) { + el.classList.remove('drag-over'); + }); + draggedEl = null; +} +``` + +--- + +## Export: Copy Parameters Button + +**CRITICAL: Every prototype MUST include an export button.** The user tunes parameters interactively, then copies the result as structured data. + +### Copy as JSON + +```html +<button class="export-btn" onclick="copyParams()"> + Copy Parameters +</button> +``` + +```css +.export-btn { + width: 100%; + margin-top: var(--sp-4); + padding: var(--sp-3) var(--sp-4); + background: var(--accent); + color: #FFFFFF; + border: none; + border-radius: var(--radius-sm); + font: var(--type-small); + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.export-btn:hover { + background: var(--color-primary-hover, var(--accent)); + box-shadow: var(--shadow-interactive-hover, var(--shadow-md)); +} + +.export-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.export-btn.copied { + background: var(--color-success); +} +``` + +```js +function copyParams() { + var params = {}; + + // Collect all range inputs + document.querySelectorAll('.controls-panel input[type="range"]').forEach(function(input) { + params[input.id] = input.value; + }); + + // Collect all selects + document.querySelectorAll('.controls-panel select').forEach(function(select) { + params[select.id] = select.value; + }); + + // Collect all checkboxes + document.querySelectorAll('.controls-panel input[type="checkbox"]').forEach(function(cb) { + params[cb.id] = cb.checked; + }); + + // Collect all color inputs + document.querySelectorAll('.controls-panel input[type="color"]').forEach(function(color) { + params[color.id] = color.value; + }); + + navigator.clipboard.writeText(JSON.stringify(params, null, 2)); + + // Visual feedback + var btn = document.querySelector('.export-btn'); + var original = btn.textContent; + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(function() { + btn.textContent = original; + btn.classList.remove('copied'); + }, 1500); +} +``` + +### Copy as CSS Variables + +Alternative export for design token prototypes. Emits `:root { ... }` block. + +```js +function copyCSSVars() { + var lines = [':root {']; + document.querySelectorAll('.controls-panel input[type="range"]').forEach(function(input) { + var prop = input.dataset.cssVar; // data-css-var="--sp-4" + var unit = input.dataset.unit || 'px'; + if (prop) { + lines.push(' ' + prop + ': ' + input.value + unit + ';'); + } + }); + document.querySelectorAll('.controls-panel input[type="color"]').forEach(function(input) { + var prop = input.dataset.cssVar; + if (prop) { + lines.push(' ' + prop + ': ' + input.value + ';'); + } + }); + lines.push('}'); + navigator.clipboard.writeText(lines.join('\n')); +} +``` + +--- + +## Accessibility Requirements + +| Requirement | Implementation | +|---|---| +| Slider labels | Every `<input type="range">` has a `<label>` with matching `for` | +| Live readouts | Value displays use `aria-live="polite"` for screen reader updates | +| Keyboard nav | All controls reachable via Tab; sliders adjustable via arrow keys (native) | +| Focus visible | `:focus-visible` ring on all interactive elements | +| Color swatches | Use `<button>` not `<div onclick>`; include `aria-label` with color name and hex | +| Drag-and-drop | Provide keyboard reorder alternative (up/down buttons) for flow reordering | +| Toggle switches | Underlying `<input type="checkbox">` provides native semantics; visible track is `aria-hidden` | +| Reduced motion | Global reset covers animations; prototype offers explicit toggle for testing | +| Touch targets | All buttons and interactive controls minimum 44x44px hit area | + +--- + +## Shape Selection Guidance + +Use **prototype** shape when the user's request matches any of: + +| Signal | Example Request | +|---|---| +| Tune/adjust parameters | "Let me try different border radius values" | +| Animation sandbox | "Build an animation playground for this transition" | +| Component variants | "Show all button states in a contact sheet" | +| Design system | "Create a living design system preview" | +| Color exploration | "Let me pick colors and see the palette" | +| Flow reordering | "Let me drag steps into different orders" | +| CSS variable tuning | "Build a tool to adjust spacing tokens" | + +Do NOT use prototype when: +- The user wants a static report (use **report**) +- The user wants to edit content, not design parameters (use **editor**) +- The user wants data charts (use **data-viz**) diff --git a/skills/meta/html-artifact/references/shape-report-research.md b/skills/meta/html-artifact/references/shape-report-research.md new file mode 100644 index 00000000..029c62c4 --- /dev/null +++ b/skills/meta/html-artifact/references/shape-report-research.md @@ -0,0 +1,1193 @@ +# Shape: Report & Research + +Loaded when `detect-shape.py` returns `report`. For artifacts that synthesize information: status reports, incident timelines, feature explainers, concept tutorials, research summaries. + +**Theme:** Birchline (status reports, general), Minimal Document (long-form explainers, research). Match to content length and formality. + +**Core principle:** Reports are SCANNABLE. Every report opens with a TL;DR. Every section has one job. Information density is high — no filler, no decorative whitespace without purpose. + +--- + +## Layout Patterns + +| Layout | Use When | Structure | +|---|---|---| +| Single column + sticky TOC | Long explainers, tutorials | Sticky sidebar nav, main content column (max 680px) | +| Metrics dashboard | Status reports, weekly updates | Metric cards top, sectioned content below | +| Timeline | Incidents, changelogs, postmortems | Vertical timeline with dots, timestamps, content | +| Split: visualization + glossary | Concept explainers | Interactive viz left, reference sidebar right | +| Accordion | Step-by-step guides, how-tos | Numbered collapsible sections | + +### Single Column + Sticky TOC + +For long-form content. TOC floats on the left (desktop) or collapses to top (mobile). + +```html +<div class="report-layout"> + <nav class="toc" aria-label="Table of contents"> + <h3>On this page</h3> + <a href="#tldr">TL;DR</a> + <a href="#how-it-works">How it works</a> + <a href="#gotchas">Gotchas</a> + <a href="#faq">FAQ</a> + </nav> + <main class="report-content"> + <section id="tldr">...</section> + <section id="how-it-works">...</section> + <section id="gotchas">...</section> + <section id="faq">...</section> + </main> +</div> +``` + +```css +.report-layout { + display: grid; + grid-template-columns: 180px 1fr; + gap: var(--sp-7); + max-width: 960px; + margin: 0 auto; + padding: var(--sp-6) var(--sp-4); +} + +@media (max-width: 640px) { + .report-layout { + grid-template-columns: 1fr; + gap: var(--sp-4); + } + .toc { + position: static !important; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: var(--sp-4); + margin-bottom: var(--sp-4); + } +} + +.toc { + position: sticky; + top: var(--sp-5); + align-self: start; +} + +.toc h3 { + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-3); +} + +.toc a { + display: block; + padding: var(--sp-1) 0; + font: var(--type-small); + color: var(--text-secondary); + text-decoration: none; + border-left: 2px solid transparent; + padding-left: var(--sp-3); + transition: color 0.15s ease, border-color 0.15s ease; +} + +.toc a:hover { + color: var(--text-primary); +} + +.toc a.active { + color: var(--accent); + border-left-color: var(--accent); + font-weight: 500; +} + +.toc a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-xs); +} + +.report-content { + max-width: 680px; +} + +.report-content section { + margin-bottom: var(--sp-7); +} +``` + +**Scroll-spy for active TOC link** (optional, for long documents): + +```js +var tocLinks = document.querySelectorAll('.toc a'); +var sections = document.querySelectorAll('.report-content section[id]'); + +var observer = new IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + if (entry.isIntersecting) { + tocLinks.forEach(function(link) { link.classList.remove('active'); }); + var activeLink = document.querySelector('.toc a[href="#' + entry.target.id + '"]'); + if (activeLink) activeLink.classList.add('active'); + } + }); +}, { rootMargin: '-20% 0px -80% 0px' }); + +sections.forEach(function(section) { observer.observe(section); }); +``` + +### Metrics Dashboard Layout + +Metric cards across the top, content sections below. + +```html +<header class="report-header"> + <h1>Weekly Status</h1> + <p class="report-date">Week of May 5, 2026</p> +</header> +<div class="metrics-row"> + <!-- metric cards --> +</div> +<main class="report-sections"> + <section>...</section> +</main> +``` + +```css +.report-header { + margin-bottom: var(--sp-6); +} + +.report-header h1 { + font: var(--type-h1); + margin-bottom: var(--sp-2); +} + +.report-date { + font: var(--type-small); + color: var(--text-muted); +} + +.report-sections section { + margin-bottom: var(--sp-7); +} + +.report-sections section h2 { + font: var(--type-h2); + margin-bottom: var(--sp-4); + padding-bottom: var(--sp-2); + border-bottom: 1px solid var(--border-subtle); +} +``` + +### Split: Visualization + Glossary Sidebar + +For concept explainers with interactive diagrams. + +```html +<div class="explainer-layout"> + <section class="visualization" aria-label="Interactive diagram"> + <svg id="diagram" width="400" height="400" role="img" aria-label="Concept diagram"> + <!-- Dynamic visualization --> + </svg> + <div class="viz-controls"> + <button onclick="addNode()">+ Add Node</button> + <button onclick="removeNode()">− Remove</button> + <button onclick="resetDiagram()">Reset</button> + </div> + </section> + <aside class="glossary-sidebar" aria-label="Glossary"> + <h3>Glossary</h3> + <dl> + <dt id="term-hash">Hash function</dt> + <dd>Maps keys to positions on the ring uniformly.</dd> + <dt id="term-vnode">Virtual node</dt> + <dd>Multiple points per server to improve distribution.</dd> + </dl> + </aside> +</div> +``` + +```css +.explainer-layout { + display: grid; + grid-template-columns: 1fr 280px; + gap: var(--sp-6); + align-items: start; +} + +@media (max-width: 640px) { + .explainer-layout { + grid-template-columns: 1fr; + } +} + +.visualization { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.viz-controls { + display: flex; + gap: var(--sp-2); + flex-wrap: wrap; +} + +.glossary-sidebar { + position: sticky; + top: var(--sp-5); + padding: var(--sp-4); + background: var(--bg-muted); + border-radius: var(--radius-sm); +} + +.glossary-sidebar h3 { + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-3); +} + +.glossary-sidebar dt { + font: var(--type-small); + font-weight: 600; + color: var(--text-primary); + margin-top: var(--sp-3); +} + +.glossary-sidebar dd { + font: var(--type-small); + color: var(--text-secondary); + margin-top: var(--sp-1); + padding-left: 0; +} +``` + +--- + +## Content Blocks + +### TL;DR Summary Box + +**CRITICAL: Every report opens with a TL;DR.** Placed immediately after the title, before any other content. + +```html +<div class="tldr-box" role="region" aria-label="Summary"> + <h2 class="tldr-heading">TL;DR</h2> + <p>Key takeaway in 1-2 sentences. What happened, what it means, what to do.</p> +</div> +``` + +```css +.tldr-box { + background: var(--bg-muted); + border-left: 3px solid var(--accent); + padding: var(--sp-4) var(--sp-5); + border-radius: var(--radius-sm); + margin-bottom: var(--sp-7); +} + +.tldr-heading { + margin: 0 0 var(--sp-2); + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.tldr-box p { + margin: 0; + font: var(--type-body); + line-height: 1.6; +} +``` + +### Metric Callout Cards + +Row of key numbers at the top of status reports. Each card: big number, label, optional trend indicator. + +```html +<div class="metrics-row" role="region" aria-label="Key metrics"> + <div class="metric-card"> + <span class="metric-value">14</span> + <span class="metric-label">PRs Merged</span> + <span class="metric-trend positive" aria-label="Up 3 from last week">↑ 3</span> + </div> + <div class="metric-card"> + <span class="metric-value">1</span> + <span class="metric-label">Incidents</span> + <span class="metric-trend negative" aria-label="Up 1 from last week">↑ 1</span> + </div> + <div class="metric-card"> + <span class="metric-value">97%</span> + <span class="metric-label">Uptime</span> + <span class="metric-trend neutral" aria-label="No change from last week">— 0</span> + </div> + <div class="metric-card"> + <span class="metric-value">3</span> + <span class="metric-label">Blockers</span> + <span class="metric-trend positive" aria-label="Down 2 from last week">↓ 2</span> + </div> +</div> +``` + +```css +.metrics-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--sp-4); + margin-bottom: var(--sp-6); +} + +.metric-card { + text-align: center; + padding: var(--sp-5); + background: var(--bg-surface); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.metric-value { + display: block; + font-size: 36px; + font-weight: 600; + line-height: 1.1; + color: var(--text-primary); +} + +.metric-label { + display: block; + font: var(--type-small); + color: var(--text-muted); + margin-top: var(--sp-1); +} + +.metric-trend { + display: block; + font: var(--type-caption); + margin-top: var(--sp-2); +} + +.metric-trend.positive { color: var(--color-success); } +.metric-trend.negative { color: var(--color-danger); } +.metric-trend.neutral { color: var(--text-muted); } +``` + +### Collapsible Sections (native `<details>`) + +No JS required. Use for step-by-step guides, expandable details, or sections the user may want to skip. + +```html +<details class="expandable-section" open> + <summary> + <span class="step-number">1</span> + Configure the rate limiter + </summary> + <div class="step-content"> + <p>Detailed explanation here. Can include code, diagrams, or sub-steps.</p> + <pre><code>rate_limit: + window: 60s + max_requests: 100</code></pre> + </div> +</details> + +<details class="expandable-section"> + <summary> + <span class="step-number">2</span> + Add middleware to the route + </summary> + <div class="step-content"> + <p>Wire the limiter into your HTTP handler chain.</p> + </div> +</details> +``` + +```css +details.expandable-section { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: var(--sp-3); +} + +details.expandable-section summary { + padding: var(--sp-3) var(--sp-4); + cursor: pointer; + font: var(--type-body); + font-weight: 500; + display: flex; + align-items: center; + gap: var(--sp-3); + list-style: none; + user-select: none; +} + +/* Remove default marker */ +details.expandable-section summary::-webkit-details-marker { display: none; } + +details.expandable-section summary::after { + content: '+'; + margin-left: auto; + font-size: 18px; + color: var(--text-muted); + transition: transform 0.15s ease; +} + +details.expandable-section[open] summary::after { + content: '−'; +} + +details.expandable-section[open] summary { + border-bottom: 1px solid var(--border-subtle); +} + +details.expandable-section summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: var(--radius-sm); +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent); + color: #FFFFFF; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} + +.step-content { + padding: var(--sp-4) var(--sp-5); +} + +.step-content p { + margin-bottom: var(--sp-3); + line-height: 1.6; +} + +.step-content pre { + background: var(--bg-muted); + padding: var(--sp-3) var(--sp-4); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: 14px; + overflow-x: auto; + margin: var(--sp-3) 0; +} +``` + +### Tabbed Code Snippets + +Multiple code examples behind tabs. Use when showing the same concept in different languages, configs, or layers. + +```html +<div class="code-tabs" role="tablist" aria-label="Code examples"> + <div class="tab-bar"> + <button class="tab active" role="tab" aria-selected="true" + id="tab-yaml" aria-controls="panel-yaml" + onclick="showTab(this, 'yaml')">YAML</button> + <button class="tab" role="tab" aria-selected="false" + id="tab-route" aria-controls="panel-route" + onclick="showTab(this, 'route')">Route</button> + <button class="tab" role="tab" aria-selected="false" + id="tab-client" aria-controls="panel-client" + onclick="showTab(this, 'client')">Client</button> + </div> + <div class="tab-panel active" id="panel-yaml" role="tabpanel" aria-labelledby="tab-yaml"> + <pre><code>rate_limit: + window: 60s + max_requests: 100 + burst: 20</code></pre> + </div> + <div class="tab-panel" id="panel-route" role="tabpanel" aria-labelledby="tab-route" hidden> + <pre><code>app.use('/api', rateLimiter({ + window: '60s', + max: 100 +}))</code></pre> + </div> + <div class="tab-panel" id="panel-client" role="tabpanel" aria-labelledby="tab-client" hidden> + <pre><code>// Client retries on 429 +if (res.status === 429) { + await sleep(res.headers['retry-after']) + return retry(req) +}</code></pre> + </div> +</div> +``` + +```css +.code-tabs { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; + margin: var(--sp-4) 0; +} + +.tab-bar { + display: flex; + background: var(--bg-muted); + border-bottom: 1px solid var(--border-subtle); +} + +.tab { + all: unset; + padding: var(--sp-2) var(--sp-4); + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.tab:hover { + color: var(--text-primary); +} + +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; +} + +.tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; +} + +.tab-panel pre { + margin: 0; + padding: var(--sp-4); + background: var(--bg-surface); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + overflow-x: auto; +} +``` + +```js +function showTab(btn, panelId) { + var container = btn.closest('.code-tabs'); + + // Deactivate all tabs + container.querySelectorAll('.tab').forEach(function(t) { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); + + // Hide all panels + container.querySelectorAll('.tab-panel').forEach(function(p) { + p.classList.remove('active'); + p.setAttribute('hidden', ''); + }); + + // Activate selected tab and panel + btn.classList.add('active'); + btn.setAttribute('aria-selected', 'true'); + var panel = document.getElementById('panel-' + panelId); + panel.classList.add('active'); + panel.removeAttribute('hidden'); +} + +// Keyboard navigation for tabs (arrow keys) +document.querySelectorAll('.tab-bar').forEach(function(bar) { + bar.addEventListener('keydown', function(e) { + var tabs = Array.from(bar.querySelectorAll('.tab')); + var current = tabs.indexOf(document.activeElement); + if (current < 0) return; + + var next = -1; + if (e.key === 'ArrowRight') next = (current + 1) % tabs.length; + if (e.key === 'ArrowLeft') next = (current - 1 + tabs.length) % tabs.length; + + if (next >= 0) { + e.preventDefault(); + tabs[next].focus(); + tabs[next].click(); + } + }); +}); +``` + +### Incident Timeline + +Vertical timeline with timestamp, severity dot, and content. For incident reports, changelogs, and postmortems. + +```html +<section aria-label="Incident timeline"> + <h2>Timeline</h2> + <div class="timeline"> + <div class="timeline-event"> + <div class="timeline-time">14:32 UTC</div> + <div class="timeline-dot critical" aria-label="Critical severity"></div> + <div class="timeline-body"> + <h4>Alert fired: API latency > 2s</h4> + <p>PagerDuty triggered. On-call engineer acknowledged within 3 minutes.</p> + <pre class="log-excerpt">ERROR: connection pool exhausted (max=50, active=50, waiting=312)</pre> + </div> + </div> + + <div class="timeline-event"> + <div class="timeline-time">14:38 UTC</div> + <div class="timeline-dot warning" aria-label="Warning severity"></div> + <div class="timeline-body"> + <h4>Investigation started</h4> + <p>Identified spike in database connections correlating with deploy at 14:28.</p> + </div> + </div> + + <div class="timeline-event"> + <div class="timeline-time">14:52 UTC</div> + <div class="timeline-dot resolved" aria-label="Resolved"></div> + <div class="timeline-body"> + <h4>Rollback deployed</h4> + <p>Reverted to previous release. Connection pool returned to normal within 2 minutes.</p> + </div> + </div> + + <div class="timeline-event"> + <div class="timeline-time">15:10 UTC</div> + <div class="timeline-dot resolved" aria-label="Resolved"></div> + <div class="timeline-body"> + <h4>All clear</h4> + <p>Latency back to baseline. Monitoring confirmed stable for 15 minutes.</p> + </div> + </div> + </div> +</section> +``` + +```css +.timeline { + position: relative; + padding-left: 130px; +} + +.timeline::before { + content: ''; + position: absolute; + left: 118px; + top: 0; + bottom: 0; + width: 2px; + background: var(--border-default); +} + +.timeline-event { + position: relative; + margin-bottom: var(--sp-6); +} + +.timeline-time { + position: absolute; + left: -130px; + width: 108px; + text-align: right; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-muted); + padding-top: 2px; +} + +.timeline-dot { + position: absolute; + left: -18px; + top: 4px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--bg-page, var(--color-ivory, #FAF9F5)); +} + +.timeline-dot.critical { background: var(--color-danger); } +.timeline-dot.warning { background: var(--color-warning); } +.timeline-dot.resolved { background: var(--color-success); } +.timeline-dot.info { background: var(--color-info); } + +.timeline-body h4 { + font: var(--type-body); + font-weight: 600; + margin-bottom: var(--sp-1); +} + +.timeline-body p { + font: var(--type-small); + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: var(--sp-2); +} + +.log-excerpt { + background: var(--bg-muted); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-danger); + overflow-x: auto; + white-space: pre; + margin-top: var(--sp-2); +} + +/* Mobile: stack timestamp above content */ +@media (max-width: 640px) { + .timeline { padding-left: 24px; } + .timeline::before { left: 5px; } + .timeline-time { + position: static; + width: auto; + text-align: left; + margin-bottom: var(--sp-1); + } + .timeline-dot { + left: -24px; + } +} +``` + +--- + +## Interactive Elements for Reports + +### Hover-Linked Glossary Terms + +Inline terms that highlight on hover and link to a glossary sidebar or footnote. + +```html +<p>The <a href="#term-hash" class="glossary-link">hash function</a> maps each key to a position on the +<a href="#term-ring" class="glossary-link">ring</a>, ensuring even distribution.</p> +``` + +```css +.glossary-link { + color: var(--text-primary); + text-decoration: none; + border-bottom: 1px dashed var(--color-info); + cursor: help; + transition: background 0.15s ease; +} + +.glossary-link:hover { + background: color-mix(in srgb, var(--color-info) 10%, transparent); +} + +.glossary-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-xs); +} +``` + +### FAQ Accordion + +Uses native `<details>` elements. No JS required. Renders as a list of expandable questions. + +```html +<section class="faq" aria-label="Frequently asked questions"> + <h2>FAQ</h2> + <details> + <summary>Why not just use Redis rate limiting?</summary> + <div class="faq-answer"> + <p>Redis works for single-region deployments, but introduces a network hop + for every request and a single point of failure. The local token bucket + eliminates both issues at the cost of cross-node coordination.</p> + </div> + </details> + <details> + <summary>What happens when the limit is exceeded?</summary> + <div class="faq-answer"> + <p>The client receives a <code>429 Too Many Requests</code> response with a + <code>Retry-After</code> header indicating when to retry.</p> + </div> + </details> +</section> +``` + +```css +.faq details { + border-bottom: 1px solid var(--border-subtle); +} + +.faq details:last-child { + border-bottom: none; +} + +.faq summary { + padding: var(--sp-3) 0; + cursor: pointer; + font: var(--type-body); + font-weight: 500; + color: var(--text-primary); + list-style: none; +} + +.faq summary::-webkit-details-marker { display: none; } + +.faq summary::before { + content: '▸ '; + color: var(--text-muted); + transition: transform 0.15s ease; + display: inline-block; +} + +.faq details[open] summary::before { + content: '▾ '; +} + +.faq summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-xs); +} + +.faq-answer { + padding: 0 0 var(--sp-4) var(--sp-4); + color: var(--text-secondary); + line-height: 1.6; +} + +.faq-answer code { + background: var(--bg-muted); + padding: 2px 6px; + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: 14px; +} +``` + +### Callout / Admonition Boxes + +For warnings, notes, tips, and important information. + +```html +<div class="callout callout-warning" role="note"> + <strong>Warning:</strong> This will drop all existing connections. +</div> + +<div class="callout callout-info" role="note"> + <strong>Note:</strong> Rate limits reset at the start of each window. +</div> + +<div class="callout callout-danger" role="alert"> + <strong>Breaking change:</strong> The <code>v1</code> endpoint is removed in this release. +</div> +``` + +```css +.callout { + padding: var(--sp-3) var(--sp-4); + border-radius: var(--radius-sm); + font: var(--type-small); + line-height: 1.6; + margin: var(--sp-4) 0; + border-left: 3px solid; +} + +.callout strong { + font-weight: 600; +} + +.callout-info { + background: color-mix(in srgb, var(--color-info) 8%, transparent); + border-color: var(--color-info); + color: var(--text-primary); +} + +.callout-warning { + background: color-mix(in srgb, var(--color-warning) 8%, transparent); + border-color: var(--color-warning); + color: var(--text-primary); +} + +.callout-danger { + background: color-mix(in srgb, var(--color-danger) 8%, transparent); + border-color: var(--color-danger); + color: var(--text-primary); +} + +.callout-success { + background: color-mix(in srgb, var(--color-success) 8%, transparent); + border-color: var(--color-success); + color: var(--text-primary); +} +``` + +### Inline Code and Code Blocks + +```css +/* Inline code */ +code { + background: var(--bg-muted); + padding: 2px 6px; + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: 0.875em; +} + +/* Code blocks */ +pre { + background: var(--bg-muted); + padding: var(--sp-4); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + overflow-x: auto; + margin: var(--sp-4) 0; +} + +pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: inherit; +} +``` + +--- + +## SVG Diagrams in Reports + +### Flow Diagram (horizontal process) + +```html +<svg viewBox="0 0 720 120" xmlns="http://www.w3.org/2000/svg" role="img" + aria-label="Request flow: Client to Rate Limiter to API to Database"> + <style> + .box { fill: var(--bg-surface, #FFF); stroke: var(--border-default, #D1CFC5); stroke-width: 1.5; rx: 10; } + .box-accent { fill: color-mix(in srgb, var(--accent, #D97757) 10%, var(--bg-surface, #FFF)); stroke: var(--accent, #D97757); stroke-width: 1.5; rx: 10; } + .arrow { stroke: var(--border-default, #D1CFC5); stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); } + .label { font: 12px var(--font-sans, system-ui, sans-serif); fill: var(--text-primary, #333); text-anchor: middle; } + .sublabel { font: 11px var(--font-mono, monospace); fill: var(--text-muted, #888); text-anchor: middle; } + </style> + <defs> + <marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"> + <path d="M0,0 L8,3 L0,6" fill="var(--border-default, #D1CFC5)"/> + </marker> + </defs> + <rect class="box" x="10" y="30" width="120" height="60"/> + <text class="label" x="70" y="58">Client</text> + <text class="sublabel" x="70" y="74">HTTP</text> + <line class="arrow" x1="130" y1="60" x2="190" y2="60"/> + <rect class="box-accent" x="190" y="30" width="140" height="60"/> + <text class="label" x="260" y="58">Rate Limiter</text> + <text class="sublabel" x="260" y="74">token bucket</text> + <line class="arrow" x1="330" y1="60" x2="390" y2="60"/> + <rect class="box" x="390" y="30" width="120" height="60"/> + <text class="label" x="450" y="58">API</text> + <text class="sublabel" x="450" y="74">handler</text> + <line class="arrow" x1="510" y1="60" x2="570" y2="60"/> + <rect class="box" x="570" y="30" width="130" height="60"/> + <text class="label" x="635" y="58">Database</text> + <text class="sublabel" x="635" y="74">PostgreSQL</text> +</svg> +``` + +### Before/After Comparison + +Side-by-side status indicators for reports showing improvement. + +```html +<div class="comparison-row"> + <div class="comparison-before"> + <h4>Before</h4> + <div class="stat-line"> + <span class="stat-label">p99 latency</span> + <span class="stat-value danger">2.4s</span> + </div> + <div class="stat-line"> + <span class="stat-label">Error rate</span> + <span class="stat-value danger">4.2%</span> + </div> + </div> + <div class="comparison-arrow" aria-hidden="true">→</div> + <div class="comparison-after"> + <h4>After</h4> + <div class="stat-line"> + <span class="stat-label">p99 latency</span> + <span class="stat-value success">180ms</span> + </div> + <div class="stat-line"> + <span class="stat-label">Error rate</span> + <span class="stat-value success">0.1%</span> + </div> + </div> +</div> +``` + +```css +.comparison-row { + display: flex; + align-items: center; + gap: var(--sp-5); + margin: var(--sp-5) 0; +} + +@media (max-width: 640px) { + .comparison-row { + flex-direction: column; + gap: var(--sp-3); + } + .comparison-arrow { + transform: rotate(90deg); + } +} + +.comparison-before, +.comparison-after { + flex: 1; + padding: var(--sp-4); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.comparison-before { + background: color-mix(in srgb, var(--color-danger) 4%, var(--bg-surface)); +} + +.comparison-after { + background: color-mix(in srgb, var(--color-success) 4%, var(--bg-surface)); +} + +.comparison-before h4, +.comparison-after h4 { + font: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--sp-3); +} + +.comparison-arrow { + font-size: 24px; + color: var(--text-muted); + flex-shrink: 0; +} + +.stat-line { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.stat-line:last-child { + border-bottom: none; +} + +.stat-label { + font: var(--type-small); + color: var(--text-secondary); +} + +.stat-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; +} + +.stat-value.success { color: var(--color-success); } +.stat-value.danger { color: var(--color-danger); } +``` + +--- + +## Print Styles + +Reports may be printed or saved as PDF. Include basic print overrides. + +```css +@media print { + body { + background: white; + color: black; + font-size: 12pt; + } + + .toc, + .viz-controls, + button { + display: none; + } + + details { + display: block; + } + + details[open] summary ~ * { + display: block; + } + + .timeline::before { + background: #ccc; + } + + .metric-card { + border: 1px solid #ccc; + break-inside: avoid; + } + + a { + color: inherit; + text-decoration: none; + } + + pre { + white-space: pre-wrap; + border: 1px solid #ccc; + } +} +``` + +--- + +## Accessibility Requirements + +| Requirement | Implementation | +|---|---| +| Heading hierarchy | h1 (title) > h2 (sections) > h3 (subsections), never skip levels | +| Section landmarks | Use `<section>`, `<nav>`, `<main>`, `<aside>` with `aria-label` | +| TOC navigation | TOC links are `<a href="#id">` pointing to sections with matching `id` | +| Tab panels | Proper `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`, `aria-controls` | +| Timeline | Severity dots include `aria-label`; timestamps are real text, not images | +| Metric trends | Trend indicators use `aria-label` to convey direction and magnitude | +| Code blocks | Use `<pre><code>` for code; `overflow-x: auto` for horizontal scroll | +| Callouts | Use `role="note"` or `role="alert"` (for breaking/danger only) | +| Focus visible | All interactive elements (links, tabs, summaries) have `:focus-visible` styles | +| Print | Reports remain readable when printed (no dark backgrounds, hidden nav) | + +--- + +## Shape Selection Guidance + +Use **report** shape when the user's request matches any of: + +| Signal | Example Request | +|---|---| +| Status update | "Write a weekly status report" | +| Incident report | "Create a postmortem for the outage" | +| Feature explainer | "Explain how rate limiting works" | +| Concept tutorial | "Teach consistent hashing visually" | +| Research summary | "Summarize the findings from the research" | +| Changelog | "Show what changed in this release" | +| Meeting notes | "Format these meeting notes" | +| How-to guide | "Step-by-step guide for setting up X" | + +Do NOT use report when: +- The user wants to tune/adjust parameters interactively (use **prototype**) +- The user wants to edit, reorder, or triage content (use **editor**) +- The user wants charts from raw data (use **data-viz**) +- The user wants to compare N approaches with grids (use **spec**) diff --git a/skills/meta/html-artifact/references/shape-spec-exploration.md b/skills/meta/html-artifact/references/shape-spec-exploration.md new file mode 100644 index 00000000..4fc4950d --- /dev/null +++ b/skills/meta/html-artifact/references/shape-spec-exploration.md @@ -0,0 +1,1066 @@ +# Shape Reference: Spec / Exploration + +> **Shape**: spec | **Signal words**: plan, explore, compare, brainstorm, approach, option, tradeoff, direction +> **Gallery basis**: 01 (Three code approaches), 02 (Visual design directions), 16 (Implementation plan) +> **Generated**: 2026-05-08 + +--- + +## Design System Tokens (Birchline) + +All patterns below reference these variables. The html-builder agent embeds them in a `<style>` block at the top of every artifact. + +```css +:root { + /* Birchline palette */ + --color-clay: #D97757; + --color-slate: #141413; + --color-ivory: #FAF9F5; + --color-primary: var(--color-clay); + --color-bg: var(--color-ivory); + --color-text: var(--color-slate); + + /* Grays */ + --color-gray-100: #F0EFEB; + --color-gray-200: #E2E0DA; + --color-gray-300: #C8C5BC; + --color-gray-400: #A9A69D; + --color-gray-500: #7A776F; + --color-gray-600: #52504A; + + /* Semantic */ + --color-danger: #B04A4A; + --color-warning: #C78E3F; + --color-success: #788C5D; + --color-info: #5B7FA5; + + /* Spacing scale (4px base) */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 20px; --sp-6: 24px; --sp-7: 32px; --sp-8: 40px; + --sp-9: 48px; --sp-10: 64px; + + /* Typography */ + --font-body: 'Instrument Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --type-hero: clamp(2rem, 4vw, 3rem); + --type-h2: clamp(1.25rem, 2.5vw, 1.75rem); + --type-h3: 1.125rem; + --type-body: 0.9375rem; + --type-small: 0.8125rem; + --type-caption: 0.75rem; + + /* Radius */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; +} +``` + +--- + +## Page Shell + +Every spec/exploration artifact uses this outer structure: + +```html +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title><!-- Artifact title --> + + + + +
+ +
+
+

Generated

+
+ + + +``` + +```css +*, *::before, *::after { box-sizing: border-box; margin: 0; } +body { + font-family: var(--font-body); + font-size: var(--type-body); + line-height: 1.6; + color: var(--color-text); + background: var(--color-bg); + padding: var(--sp-7) var(--sp-5); + max-width: 1200px; + margin: 0 auto; +} +.page-header { margin-bottom: var(--sp-9); } +.page-header h1 { + font-size: var(--type-hero); + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; +} +.subtitle { + margin-top: var(--sp-2); + color: var(--color-gray-500); + font-size: var(--type-body); +} +.page-footer { + margin-top: var(--sp-10); + padding-top: var(--sp-5); + border-top: 1px solid var(--color-gray-200); + color: var(--color-gray-400); + font-size: var(--type-caption); +} +``` + +--- + +## Pattern 1: Comparison Grid + +Use when the request says "compare N approaches", "explore options", "pros and cons". + +### HTML + +```html +
+

Approaches

+
+ +
+
+ 01 +

Approach Name

+ Recommended +
+

One-sentence description of what this approach does.

+
+
+

Strengths

+
    +
  • First advantage
  • +
  • Second advantage
  • +
+
+
+

Tradeoffs

+
    +
  • First tradeoff
  • +
  • Second tradeoff
  • +
+
+
+
+
// Key implementation snippet
+const result = await doThing();
+
+
+ Complexity: Low + Testability: High + Migration: Easy +
+
+ + +
+
+``` + +### CSS + +```css +/* --- Comparison Grid --- */ +.comparison-section { margin-bottom: var(--sp-10); } +.comparison-section h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-6); +} +.comparison-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--sp-5); +} + +/* --- Approach Card --- */ +.approach-card { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-md); + padding: var(--sp-6); + background: white; + transition: border-color 0.15s ease; +} +.approach-card:hover { + border-color: var(--color-gray-400); +} + +/* Recommended highlight */ +.approach-card:has(.approach-tag) { + border-color: var(--color-primary); + border-width: 2px; +} + +.approach-header { + display: flex; + align-items: baseline; + gap: var(--sp-3); + margin-bottom: var(--sp-4); + flex-wrap: wrap; +} +.approach-number { + font-family: var(--font-mono); + font-size: var(--type-h2); + font-weight: 700; + color: var(--color-gray-300); + line-height: 1; +} +.approach-header h3 { + font-size: var(--type-h3); + font-weight: 600; + flex: 1; +} +.approach-tag { + font-size: var(--type-caption); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + padding: 2px 8px; + border-radius: var(--radius-xs); +} +.approach-summary { + color: var(--color-gray-600); + margin-bottom: var(--sp-5); +} + +/* --- Pros / Cons --- */ +.pros-cons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-4); + margin-bottom: var(--sp-5); +} +.pros-cons h4 { + font-size: var(--type-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--sp-2); +} +.pros h4 { color: var(--color-success); } +.cons h4 { color: var(--color-danger); } +.pros-cons ul { + list-style: none; + padding: 0; +} +.pros-cons li { + font-size: var(--type-small); + padding: var(--sp-1) 0; + padding-left: var(--sp-5); + position: relative; +} +.pros li::before { + content: '+'; + position: absolute; + left: 0; + color: var(--color-success); + font-weight: 700; + font-family: var(--font-mono); +} +.cons li::before { + content: '\2212'; /* minus sign */ + position: absolute; + left: 0; + color: var(--color-danger); + font-weight: 700; + font-family: var(--font-mono); +} + +/* --- Code Example --- */ +.code-example { + margin-bottom: var(--sp-5); +} +.code-example pre { + background: var(--color-slate); + color: var(--color-ivory); + padding: var(--sp-4); + border-radius: var(--radius-sm); + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--type-small); + line-height: 1.5; +} + +/* --- Metadata Badges --- */ +.approach-meta { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + padding-top: var(--sp-4); + border-top: 1px solid var(--color-gray-100); +} +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-xs); + background: var(--color-gray-100); + font-size: var(--type-caption); + font-weight: 500; + color: var(--color-gray-600); +} + +/* --- Responsive: 3-col -> 2-col -> 1-col --- */ +@media (max-width: 1024px) { + .comparison-grid { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } +} +@media (max-width: 640px) { + .comparison-grid { grid-template-columns: 1fr; } + .pros-cons { grid-template-columns: 1fr; } +} +``` + +--- + +## Pattern 2: Recommendation Section + +Always include after the comparison grid. Makes the artifact actionable. + +### HTML + +```html +
+

Recommendation

+

Approach 2 — because it balances complexity and testability.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CriterionApproach 1Approach 2Approach 3
ComplexityLowLowHigh
TestabilityMediumHighHigh
Migration effortEasyModerateHard
+
+``` + +### CSS + +```css +/* --- Recommendation --- */ +.recommendation { + border-left: 3px solid var(--color-primary); + padding-left: var(--sp-6); + margin-top: var(--sp-9); +} +.recommendation h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-4); +} +.verdict { + font-size: var(--type-h3); + margin-bottom: var(--sp-6); +} + +/* --- Tradeoff Matrix --- */ +.tradeoff-matrix { + width: 100%; + border-collapse: collapse; + font-size: var(--type-small); +} +.tradeoff-matrix th, +.tradeoff-matrix td { + padding: var(--sp-3) var(--sp-4); + text-align: left; + border-bottom: 1px solid var(--color-gray-200); +} +.tradeoff-matrix th { + font-weight: 600; + font-size: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); +} +.tradeoff-matrix td:first-child { + font-weight: 500; +} + +/* Cell coloring */ +.cell-success { color: var(--color-success); font-weight: 600; } +.cell-warning { color: var(--color-warning); font-weight: 600; } +.cell-danger { color: var(--color-danger); font-weight: 600; } + +@media (max-width: 640px) { + .tradeoff-matrix { font-size: var(--type-caption); } + .tradeoff-matrix th, .tradeoff-matrix td { padding: var(--sp-2) var(--sp-3); } +} +``` + +--- + +## Pattern 3: Implementation Plan + +Use for "implementation plan", "migration plan", "rollout strategy". Combines timeline, data-flow diagram, code snippets, and risk table. + +### HTML — Hero + TL;DR + +```html +
+

Implementation Plan: Auth Migration

+
+ TL;DR — Migrate from localStorage tokens to httpOnly cookies in 3 phases + over 2 sprints. Zero downtime. Backward compatible during transition. +
+
+``` + +```css +.tldr { + background: var(--color-gray-100); + border-radius: var(--radius-sm); + padding: var(--sp-4) var(--sp-5); + margin-top: var(--sp-5); + font-size: var(--type-body); + line-height: 1.5; +} +.tldr strong { + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: var(--type-caption); +} +``` + +### HTML — Timeline + +```html +
+

Milestones

+
+
+
+
+ Phase 1 +

Cookie infrastructure

+

Set up httpOnly cookie issuance on login, refresh, and logout endpoints.

+ Sprint 14 — Week 1 +
+
+
+
+
+ Phase 2 +

Dual-read middleware

+

Auth middleware reads cookie first, falls back to Authorization header. Both paths valid.

+ Sprint 14 — Week 2 +
+
+
+
+
+ Phase 3 +

Header deprecation

+

Remove localStorage token writes. Log header-only requests for one sprint. Remove fallback.

+ Sprint 15 +
+
+
+
+``` + +### CSS — Timeline + +```css +.timeline-section { margin: var(--sp-9) 0; } +.timeline-section h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-6); +} +.timeline { + position: relative; + padding-left: var(--sp-8); +} +/* Vertical connecting line */ +.timeline::before { + content: ''; + position: absolute; + left: 11px; + top: 4px; + bottom: 4px; + width: 2px; + background: var(--color-gray-200); +} +.milestone { + position: relative; + padding-bottom: var(--sp-7); +} +.milestone:last-child { padding-bottom: 0; } + +/* Dot marker */ +.milestone-marker { + position: absolute; + left: calc(-1 * var(--sp-8) + 4px); + top: 4px; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--color-gray-300); + background: white; + z-index: 1; +} +.milestone.completed .milestone-marker { + background: var(--color-success); + border-color: var(--color-success); +} +.milestone.current .milestone-marker { + background: var(--color-primary); + border-color: var(--color-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 20%, transparent); +} +.milestone.upcoming .milestone-marker { + background: white; + border-color: var(--color-gray-300); +} + +.milestone-label { + font-size: var(--type-caption); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); +} +.milestone-content h3 { + font-size: var(--type-h3); + font-weight: 600; + margin: var(--sp-1) 0 var(--sp-2); +} +.milestone-content p { + color: var(--color-gray-600); + font-size: var(--type-small); + margin-bottom: var(--sp-2); +} +.milestone-date { + font-size: var(--type-caption); + color: var(--color-gray-400); +} +``` + +### HTML — Risk Table + +```html +
+

Risks

+ + + + + + + + + + + + + + + + + + + + + + + + + +
RiskLevelMitigation
CSRF with cookie authHIGHSameSite=Strict + CSRF token on state-changing requests
Dual-read auth confusionMEDStructured logging to track which path each request uses
Client-side cookie size limitsLOWToken payload is <300 bytes; 4KB limit is not a concern
+
+``` + +### CSS — Risk Table + +```css +.risk-section { margin: var(--sp-9) 0; } +.risk-section h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-6); +} +.risk-table { + width: 100%; + border-collapse: collapse; + font-size: var(--type-small); +} +.risk-table th, +.risk-table td { + padding: var(--sp-3) var(--sp-4); + text-align: left; + border-bottom: 1px solid var(--color-gray-200); +} +.risk-table th { + font-weight: 600; + font-size: var(--type-caption); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-gray-500); +} + +/* Row tinting */ +.risk-high td:first-child { border-left: 3px solid var(--color-danger); padding-left: calc(var(--sp-4) - 3px); } +.risk-med td:first-child { border-left: 3px solid var(--color-warning); padding-left: calc(var(--sp-4) - 3px); } +.risk-low td:first-child { border-left: 3px solid var(--color-success); padding-left: calc(var(--sp-4) - 3px); } + +.risk-level { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-xs); + font-size: var(--type-caption); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.risk-level.high { background: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); } +.risk-level.med { background: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); } +.risk-level.low { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); } + +@media (max-width: 640px) { + .risk-table { font-size: var(--type-caption); } + .risk-table th:nth-child(3), .risk-table td:nth-child(3) { display: none; } +} +``` + +--- + +## Pattern 4: SVG Data-Flow Diagram + +Self-contained inline SVG for architecture / data-flow visualization. No external dependencies. + +### HTML + +```html +
+

Data Flow

+
+ + + + + + + + + + + + + + + FRONTEND + Browser / SPA + + + + API + Auth Middleware + + + + DATABASE + PostgreSQL + + + + POST /login + + + + SELECT user + + + + Set-Cookie (httpOnly) + + + + + Synchronous request + + Async / real-time + + + +
+
+``` + +### CSS + +```css +.diagram-section { margin: var(--sp-9) 0; } +.diagram-section h2 { + font-size: var(--type-h2); + font-weight: 600; + margin-bottom: var(--sp-6); +} +.diagram-container { + background: white; + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-md); + padding: var(--sp-5); + overflow-x: auto; +} +.diagram-container svg { + width: 100%; + height: auto; + min-width: 600px; /* prevent squishing on mobile — scrolls horizontally */ +} +``` + +### SVG Construction Rules + +| Element | Pattern | Notes | +|---|---|---| +| Box | `` | Rounded corners, 1.5px stroke | +| Label | `` | Monospace, 11px | +| Sync arrow | Solid line + `marker-end="url(#arrow)"` | Gray stroke | +| Async arrow | Dashed line `stroke-dasharray="6 4"` + blue arrow marker | Info color | +| Layer color | `color-mix(in srgb, 8%, white)` fill, full color stroke | Frontend=info, API=primary, DB=success | +| Legend | Group at bottom with line samples + labels | Always include | + +--- + +## Pattern 5: Light/Dark Preview Toggle + +Use for design direction artifacts (gallery example 02). Lets the reader see how a design holds up in both modes. + +### HTML + +```html +
+ +
+ +
+ +
+

Heading Sample

+

Body text sample for contrast verification.

+ +
+
+``` + +### CSS + +```css +/* --- Toggle Button --- */ +.preview-controls { + display: flex; + justify-content: flex-end; + margin-bottom: var(--sp-4); +} +.toggle-btn { + display: flex; + align-items: center; + gap: var(--sp-2); + background: none; + border: none; + cursor: pointer; + font-family: var(--font-body); + font-size: var(--type-small); + color: var(--color-gray-500); + padding: var(--sp-2); + border-radius: var(--radius-sm); +} +.toggle-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} +.toggle-track { + display: inline-block; + width: 36px; + height: 20px; + background: var(--color-gray-200); + border-radius: 10px; + position: relative; + transition: background 0.2s ease; +} +.toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + background: white; + border-radius: 50%; + transition: transform 0.2s ease; + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} + +/* Active state */ +.toggle-btn[aria-pressed="true"] .toggle-track { background: var(--color-slate); } +.toggle-btn[aria-pressed="true"] .toggle-thumb { transform: translateX(16px); } + +/* --- Preview Frame --- */ +.preview-frame { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-md); + padding: var(--sp-7); + transition: background 0.3s ease, color 0.3s ease; +} +.preview-frame.preview-dark { + background: #1a1a19; + color: var(--color-ivory); + border-color: #333; +} +.preview-frame.preview-dark .preview-cta { + background: var(--color-primary); + color: white; +} + +@media (prefers-reduced-motion: reduce) { + .toggle-thumb, .preview-frame { transition: none; } +} +``` + +### JS + +```js +(function () { + const toggle = document.getElementById('theme-toggle'); + const frame = document.getElementById('preview-frame'); + + if (!toggle || !frame) return; + + toggle.addEventListener('click', () => { + const isDark = toggle.getAttribute('aria-pressed') === 'true'; + toggle.setAttribute('aria-pressed', String(!isDark)); + frame.classList.toggle('preview-dark', !isDark); + }); + + // Keyboard: Enter and Space activate the toggle + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle.click(); + } + }); +})(); +``` + +--- + +## Pattern 6: Section Code Snippet with Context + +For implementation plans that include inline code examples with explanatory context. + +### HTML + +```html +
+
+

Cookie issuance on login

+

The /login endpoint sets an httpOnly cookie alongside the existing JSON response. + This maintains backward compatibility during the dual-read phase.

+
+
+
+ src/auth/login.ts + TypeScript +
+
export async function handleLogin(req: Request, res: Response) {
+  const { email, password } = req.body;
+  const user = await authenticate(email, password);
+
+  const token = signJWT({ sub: user.id, role: user.role });
+
+  // Phase 1: Set httpOnly cookie
+  res.cookie('session', token, {
+    httpOnly: true,
+    secure: true,
+    sameSite: 'strict',
+    maxAge: 7 * 24 * 60 * 60 * 1000,
+  });
+
+  // Backward compat: still return token in body
+  res.json({ token, user: sanitize(user) });
+}
+
+
+``` + +### CSS + +```css +.code-section { + margin: var(--sp-7) 0; +} +.code-context { + margin-bottom: var(--sp-4); +} +.code-context h3 { + font-size: var(--type-h3); + font-weight: 600; + margin-bottom: var(--sp-2); +} +.code-context p { + color: var(--color-gray-600); + font-size: var(--type-small); +} +.code-context code { + font-family: var(--font-mono); + background: var(--color-gray-100); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.875em; +} +.code-block { + border: 1px solid var(--color-gray-200); + border-radius: var(--radius-sm); + overflow: hidden; +} +.code-block-header { + display: flex; + justify-content: space-between; + padding: var(--sp-2) var(--sp-4); + background: var(--color-gray-100); + border-bottom: 1px solid var(--color-gray-200); + font-size: var(--type-caption); +} +.code-filename { + font-family: var(--font-mono); + font-weight: 500; + color: var(--color-gray-600); +} +.code-lang { + color: var(--color-gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} +.code-block pre { + background: var(--color-slate); + color: var(--color-ivory); + padding: var(--sp-5); + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--type-small); + line-height: 1.6; + margin: 0; +} + +/* Syntax highlight tokens */ +.kw { color: #C792EA; } /* keyword — purple */ +.fn { color: #82AAFF; } /* function — blue */ +.st { color: #C3E88D; } /* string — green */ +.cm { color: #6A737D; } /* comment — gray */ +.nr { color: #F78C6C; } /* number — orange */ +``` + +--- + +## Composition Guide + +Assemble spec/exploration artifacts by selecting patterns above: + +| Request Shape | Patterns to Combine | +|---|---| +| "Compare N approaches" | Shell + Comparison Grid + Recommendation | +| "Design directions" | Shell + Comparison Grid + Light/Dark Toggle + Recommendation | +| "Implementation plan" | Shell + TL;DR + Timeline + Code Snippets + Risk Table + SVG Diagram | +| "Explore tradeoffs" | Shell + Comparison Grid (2 approaches) + Recommendation | +| "Architecture options" | Shell + Comparison Grid + SVG Diagram + Recommendation | + +### Section Ordering + +1. Page header with title + subtitle +2. TL;DR block (if implementation plan) +3. SVG diagram (if architecture / data flow) +4. Comparison grid OR timeline +5. Code snippets (if implementation detail needed) +6. Risk table (if plan or migration) +7. Recommendation (always last before footer) +8. Footer with timestamp + +--- + +## Accessibility Checklist + +- [ ] All `` elements have `role="img"` and `aria-label` +- [ ] Toggle buttons use `aria-pressed` state +- [ ] Focus-visible outlines on all interactive elements +- [ ] Color is never the sole indicator (badges have text labels, risk levels have text + color) +- [ ] `prefers-reduced-motion` disables transitions +- [ ] Table headers use `` with scope implied by position +- [ ] Semantic elements: `
`, `
`, `
`, `
`, `
+ + Test Artifact + + + +

Hello

+ +""" + +MINIMAL_VALID = """ + +X +

content

+""" + + +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 = "T

x

" + 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 = "

x

" + 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 = "

x

" + 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 = ( + "T" + '' + "

x

" + ) + 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 = ( + "T" + '

x

' + ) + 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 = "T

x

" + 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 = "T " + 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 = "T

content

" + 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() + + +@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("

not valid html

") + 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() diff --git a/skills/meta/html-artifact/scripts/validate-artifact.py b/skills/meta/html-artifact/scripts/validate-artifact.py new file mode 100644 index 00000000..75daf830 --- /dev/null +++ b/skills/meta/html-artifact/scripts/validate-artifact.py @@ -0,0 +1,201 @@ +#!/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 +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +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 (case-insensitive).""" + stripped = content.lstrip() + passed = stripped.lower().startswith("") + result.checks["has_doctype"] = passed + if not passed: + result.errors.append("Missing at start of file.") + + +def _check_title(content: str, result: ValidationResult) -> None: + """Must contain tag with non-empty content.""" + match = re.search(r"<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 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 validate_artifact(file_path: Path) -> ValidationResult: + """Run all validation checks on an HTML artifact file. + + Args: + file_path: Path to the .html file to validate. + + 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) + + 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).") + 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) + 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()