--- feature: CoreComponentSpecs date: 2026-05-26 status: planned project: crash-test-dev tags: [components, article-card, code-block, audio-player, callout, post-header, nav-bar] related: [AESTHETIC-GUIDE.md, design-tokens-2026-05-26.md] --- # Component Specifications — Crash Test Dev Six components designed against the established token system. Every value traces to a named token. Every choice traces to the aesthetic guide. **Governing principle:** These components are surfaces on the workbench, not floating UI panels. They use the same material vocabulary as the page — paper weight differences, score lines, inset materials, ink — never elevation, glow, or blur. --- ## 1. article-card **Purpose:** An index card laid on the desk. Lists a post, pattern, or lesson with enough context to decide whether to pick it up and read. ### Layout ``` ┌─────────────────────────────────────────────────────────┐ │ [PATTERN] 12 min read │ ← badge + meta row │ │ │ Webpack Tree-Shaking Is Lying to You │ ← title │ │ │ That bundle analyzer showing 100% tree-shaken? │ ← summary │ The runtime tells a different story. Here's how to │ │ catch the lie and fix it. │ │ │ │ May 14, 2026 │ ← date └─────────────────────────────────────────────────────────┘ ``` ### Anatomy The card is a `
` element containing an `` that wraps the entire card (the card IS the link). Internal structure is a vertical stack. - **Container:** `max-width: var(--width-prose)` (720px). Full-bleed within the prose column. - **Background:** `var(--surface-card)` — white card stock laid on the paper base. - **Border:** `1px solid var(--border-subtle)` — a score line, not a frame. - **Border radius:** `var(--rounded-lg)` (6px) — worn edges. - **Shadow:** `var(--shadow-sm)` — barely perceptible. Card sitting on desk, not hovering above it. - **Padding:** `var(--space-3)` (24px) all sides. ### Typography & Content | Element | Font | Size | Weight | Color | Line-height | |---|---|---|---|---|---| | Content type badge | `var(--font-structure)` | `var(--type-xs)` (12px) | 600 (semibold) | `var(--accent-primary)` | `var(--leading-normal)` (1.5) | | Reading time | `var(--font-structure)` | `var(--type-xs)` (12px) | 400 | `var(--text-tertiary)` | `var(--leading-normal)` (1.5) | | Title | `var(--font-structure)` | `var(--type-xl)` (28px) | 600 (semibold) | `var(--text-primary)` | `var(--leading-snug)` (1.25) | | Summary | `var(--font-prose)` | `var(--type-base)` (18px) | 400 | `var(--text-secondary)` | `var(--leading-prose)` (1.7) | | Date | `var(--font-structure)` | `var(--type-sm)` (14px) | 400 | `var(--text-tertiary)` | `var(--leading-normal)` (1.5) | ### Spacing (Internal) | Between | Value | Token | |---|---|---| | Badge row → Title | `var(--space-1.5)` | 12px | | Title → Summary | `var(--space-1)` | 8px | | Summary → Date | `var(--space-2)` | 16px | | Between cards (vertical stack) | `var(--space-2)` | 16px | **Badge row:** Flexbox, `justify-content: space-between`, `align-items: center`. Badge left, reading time right. **Badge styling:** Text only — uppercase via `text-transform: uppercase`, `letter-spacing: 0.05em`. No background pill, no border. The terracotta color IS the badge. Consistent with "nothing is decorative for its own sake." ### States | State | Change | Tokens | |---|---|---| | **Default** | As described above | — | | **Hover** | Shadow deepens; title color shifts to accent | `var(--shadow-md)`, title → `var(--accent-primary)` | | **Active (mousedown)** | Shadow returns to sm; subtle inward shift | `var(--shadow-sm)`, `transform: translateY(1px)` | | **Focus-visible** | 2px outline in terracotta, 2px offset | `outline: 2px solid var(--focus-ring)`, `outline-offset: 2px` | **Transition:** `transition: box-shadow var(--duration-fast) var(--ease-default), transform var(--duration-fast) var(--ease-default)`. Title color transition: `var(--duration-fast)`. ### Accessibility - Entire card is a single `` wrapping a `
`. Screen readers announce the title as the link text. - Badge text uses `aria-label` on the article to provide context: e.g., `aria-label="Pattern: Webpack Tree-Shaking Is Lying to You"`. - Focus ring visible only on keyboard navigation (`:focus-visible`). - Touch target: entire card surface (well above 44×44px minimum). ### Why this design A card sitting on a desk has contact shadow, not drop shadow — `shadow-sm` at rest, `shadow-md` on hover is the full depth vocabulary of this system, and the card uses both ends of it because it's the most interactive surface on the index page. --- ## 2. code-block **Purpose:** The evidence container. A different material embedded in the page — heavier paper, slightly recessed, with a terracotta accent marking where proof begins. ### Layout ``` ┌─ filename/language label │ │▌ src/bundler/tree-shake.ts │ │▌ │ │▌ export function analyzeExports(module: Module) { │ │▌ const used = new Set(); │ │▌ for (const ref of module.references) { │ │▌ if (ref.type === 'import') { │ │▌ used.add(ref.name); │ │▌ } │ │▌ } │ │▌ return module.exports.filter(e => !used.has(e.name)); │ │▌ } │ │▌ │ │ │ ▲ └── 3px terracotta accent line ``` ### Anatomy The code block is a `
` containing an optional `
` (filename/language) and a `
` block. It breaks wider than prose.

- **Container:** `max-width: var(--width-code)` (840px). Centered. When the viewport is narrower, it respects `var(--gutter)` (24px) side padding.
- **Background:** `var(--surface-code)` — heavier paper weight. The material shift IS the signal.
- **Border:** `1px solid var(--border-subtle)` top, right, and bottom. **Left border:** `3px solid var(--accent-primary)` — the terracotta accent line.
- **Border radius:** `var(--rounded-md)` (4px).
- **Shadow:** `none` — code blocks are inset, not elevated. They sit *in* the desk, not *on* it.
- **Padding:** `var(--space-3)` (24px) all sides. Left padding accounts for the 3px accent (so `calc(var(--space-3) - 3px)` on the left, or simply treat the border as outside the padding box).
- **Vertical margin:** `var(--space-5)` (40px) above and below — exhibited artifact spacing.
- **Overflow:** `overflow-x: auto` for horizontal scroll on long lines.

### Typography

| Element | Font | Size | Weight | Color | Line-height |
|---|---|---|---|---|---|
| Code text | `var(--font-evidence)` | 1rem (16px) | 400 | `var(--code-text)` | `var(--leading-relaxed)` (1.6) |
| Filename/language label | `var(--font-structure)` | `var(--type-xs)` (12px) | 500 (medium) | `var(--text-tertiary)` | `var(--leading-normal)` (1.5) |

**Filename label:** Right-aligned within the code block, sitting at the top. Padding-bottom `var(--space-1)` (8px) separating it from code content. If no filename, no label — the code speaks for itself.

### States

| State | Change | Tokens |
|---|---|---|
| **Default** | As described above | — |
| **Horizontal overflow** | Subtle fade-out on the right edge (CSS mask gradient) to indicate scrollable content. Scrollbar styled warm. | — |
| **Focus (keyboard tab into scrollable region)** | `outline: 2px solid var(--focus-ring)`, `outline-offset: -2px` (inset, since the block is inset) | `var(--focus-ring)` |

**Scrollbar styling (where supported):**
- Track: `var(--surface-code)` (invisible against background)
- Thumb: `var(--border-default)` — warm, quiet
- Thumb hover: `var(--text-tertiary)` — slightly more visible
- Width: 6px, border-radius: `var(--rounded-full)`

### Syntax Highlighting Palette Direction

The syntax theme should stay within the warm vocabulary. No neon, no saturated primaries.

| Token type | Color direction | Notes |
|---|---|---|
| Keywords | `var(--accent-primary)` (terracotta) | Control flow, declarations |
| Strings | `var(--accent-secondary)` (tarnished brass) | Literal values |
| Comments | `var(--text-tertiary)` | Quiet, receding |
| Functions/methods | `var(--text-primary)` | Bold weight (600) for emphasis, not color |
| Types/classes | `var(--accent-primary)` at 80% opacity | Slightly softer than keywords |
| Numbers | `var(--accent-secondary)` | Groups with strings as "literal values" |
| Punctuation | `var(--text-secondary)` | Present but not prominent |

### Accessibility

- `
` has `tabindex="0"` when content overflows horizontally, making it keyboard-scrollable.
- `role="region"` with `aria-label` describing the code (e.g., `aria-label="Code: src/bundler/tree-shake.ts"`).
- Filename in `
` is programmatically associated via `aria-labelledby`. - Syntax highlighting uses color + context (not color alone) — keywords are identifiable by position, not just hue. - All syntax colors maintain minimum 4.5:1 contrast on `surface-code`. ### Why this design The narrow→wide→narrow rhythm as you scroll through a post mirrors the register shift between narrative and evidence — your eye physically moves to a wider surface when the voice changes from serif prose to mono proof, then returns to the narrower reading column. --- ## 3. audio-player **Purpose:** A companion to reading, not a separate interface. Like a sticky note near the top of the page: "You can also listen to this." ### Layout — Collapsed (Default) ``` ┌──────────────────────────┐ │ ▶ Listen · 8 min │ └──────────────────────────┘ ``` A pill. Inline with prose flow, sitting at the top of the article just below the post-header. - **Container:** Inline-flex pill. `border-radius: var(--rounded-full)` (9999px). - **Background:** `var(--surface-inset)` — slightly recessed, like a different stock weight. - **Border:** `1px solid var(--border-subtle)`. - **Padding:** `var(--space-0.5)` (4px) vertical, `var(--space-2)` (16px) horizontal. - **Shadow:** `none` — it's inset, resting. - **Height:** Determined by content. Approximately 36px (above 44px touch target when accounting for the click area — see accessibility note). | Element | Font | Size | Weight | Color | |---|---|---|---|---| | Play icon (▶) | — | 14px (optical) | — | `var(--accent-primary)` | | "Listen" label | `var(--font-structure)` | `var(--type-sm)` (14px) | 500 (medium) | `var(--text-secondary)` | | Duration | `var(--font-structure)` | `var(--type-sm)` (14px) | 400 | `var(--text-tertiary)` | **Internal spacing:** `var(--space-1)` (8px) between icon and "Listen", `var(--space-0.5)` (4px) between "Listen" and `·` separator, same before duration. ### Layout — Expanded (Playing/Paused) ``` ┌─────────────────────────────────────────────────────────┐ │ ❚❚ ━━━━━━━━━━━━━━━━━━━━━━━━━●━━━━━━━ 2:14 / 8:02 │ │ ▲ progress bar 1× │ └─────────────────────────────────────────────────────────┘ ``` Expands to `max-width: var(--width-prose)` (720px). Same pill shape gives way to a rounded-rectangle. - **Container:** `max-width: var(--width-prose)`. `border-radius: var(--rounded-lg)` (6px). - **Background:** `var(--surface-inset)`. - **Border:** `1px solid var(--border-subtle)`. - **Padding:** `var(--space-1.5)` (12px) vertical, `var(--space-2)` (16px) horizontal. - **Layout:** Two rows. - **Row 1:** Play/pause button | Progress bar | Time display. Flexbox, `align-items: center`. - **Row 2:** Right-aligned speed control. Only visible in expanded state. | Element | Font | Size | Weight | Color | |---|---|---|---|---| | Play/pause icon | — | 18px (optical) | — | `var(--accent-primary)` | | Time (current / total) | `var(--font-evidence)` | `var(--type-xs)` (12px) | 400 | `var(--text-tertiary)` | | Speed button ("1×") | `var(--font-evidence)` | `var(--type-xs)` (12px) | 400 | `var(--text-secondary)` | **Progress bar:** - Track: `var(--border-subtle)`, height 3px, `border-radius: var(--rounded-full)`. - Fill (elapsed): `var(--accent-primary)` (terracotta). - Scrubber knob: 12px circle, `var(--accent-primary)`, `border: 2px solid var(--surface-inset)`. Only visible on hover/focus. - Track clickable height: 24px (transparent hit area above 44px minimum on mobile with padding). ### Spacing | Between | Value | Token | |---|---|---| | Play/pause → progress bar | `var(--space-1.5)` | 12px | | Progress bar → time | `var(--space-1.5)` | 12px | | Row 1 → Row 2 | `var(--space-0.5)` | 4px | | Player → first prose paragraph | `var(--space-4)` | 32px | ### States | State | Change | |---|---| | **Collapsed (default)** | Pill form as described. Click anywhere to expand and play. | | **Collapsed hover** | Background shifts to `var(--surface-code)` — slightly warmer. Cursor pointer. | | **Expanded — playing** | Pause icon shown. Progress bar animates. Time updates. | | **Expanded — paused** | Play icon shown. Progress bar static. | | **Speed button hover** | Color shifts to `var(--accent-primary)`. | | **Speed button active** | Cycles: 1× → 1.25× → 1.5× → 2× → 1×. | | **Focus-visible (any interactive element)** | `outline: 2px solid var(--focus-ring)`, `outline-offset: 2px`. | | **Loading** | Collapsed pill shows "Loading…" in place of duration. Subtle opacity pulse on the label (opacity 0.6→1.0, `var(--duration-slow)` cycle). | | **Error** | Pill shows "Unavailable" in `var(--text-tertiary)`. Play icon replaced with a dash. Not clickable. | **Expand/collapse transition:** Height and border-radius animate over `var(--duration-slow)` (250ms) with `var(--ease-in-out)`. This is the one layout animation in the system — justified because the shape change is meaningful (compact → full controls). ### Accessibility - Play/pause is a `