Following the W3C Design Tokens standard and systems like IBM Carbon, Material Design 3, and Open Props, our tokens are organized in three tiers. Each tier adds context to the tier below it.
Tier 1 — Primitive tokens define what options exist. Raw palette colors, spacing units, font stacks. These are never used directly in components.
Tier 2 — Semantic tokens define why a value is used. They reference primitives and carry contextual meaning: "this is the text color," "this is the card background." This is the layer that enables theming.
Tier 3 — Component tokens define where a value is applied to a specific component. Used sparingly — only when a component needs to deviate from semantics. Most components should reference Tier 2 directly.
The flow is always one-directional: primitives → semantics → components. A component token references a semantic token. A semantic token references a primitive. A primitive holds the raw value. Changing a primitive ripples through the entire system.
/* Tier 1: Primitive — the raw value */ --color-ink: #0C0B09; /* Tier 2: Semantic — the meaning */ --color-text-primary: var(--color-ink); /* Tier 3: Component — the specific override (rare) */ --card-headline-color: var(--color-text-primary);
The raw color values. These define what colors exist in the system. Named by what they are, not what they do.
Used for: breaking news, urgency, errors, unread indicators, call-to-action emphasis. The base value (500) is our primary red. Lighter steps replace all rgba(184,50,37,...) patterns.
| Token | Value | Usage |
|---|---|---|
| --color-red-50 | #FCF6F3 | Tint background: update-breaking block bg, alert surfaces |
| --color-red-100 | #F9ECE9 | Hover/selected: breaking badge hover bg, velocity pill bg |
| --color-red-200 | #F1D5D1 | Borders: accent borders on red-tinted surfaces |
| --color-red-300 | #E3ACA6 | Muted: disabled state, placeholder illustrations |
| --color-red-400 | #CD6F66 | Secondary: icons, soft emphasis, chart accents |
| --color-red-500 | #B83225 | Base: breaking badge, unread dot, status-breaking |
| --color-red-600 | #9C2A1F | Emphasis: section rules, accent borders |
| --color-red-700 | #84241B | Hover: button hover, interactive hover on red |
| --color-red-800 | #6B1D15 | Strong: text on red-100, high contrast |
| --color-red-900 | #531611 | Darkest: text on red-50, maximum contrast on tints |
Used for: interactive elements, links, focus rings, selected states, new-info updates, coverage center, navigation. The base value (500) is our primary navy. Lighter steps replace all rgba(26,58,92,...) patterns.
| Token | Value | Usage |
|---|---|---|
| --color-navy-50 | #F8F8F7 | Tint background: update-new block bg, info surfaces |
| --color-navy-100 | #EDEEEF | Hover/selected: chip hover bg, focus-ring fills |
| --color-navy-200 | #D6DBDF | Borders: dividers on navy-tinted surfaces |
| --color-navy-300 | #AFB9C4 | Muted: disabled interactive, placeholder icons |
| --color-navy-400 | #6A7F94 | Secondary: secondary links, chart labels, soft icons |
| --color-navy-500 | #1A3A5C | Base: links, focus rings, interactive, updated-badge |
| --color-navy-600 | #16314E | Emphasis: strong links, active tab indicators |
| --color-navy-700 | #132A42 | Hover: button/link hover, interactive-hover |
| --color-navy-800 | #0F2235 | Strong: text on navy-100, high contrast |
| --color-navy-900 | #0C1A29 | Darkest: text on navy-50, maximum contrast on tints |
Gold, green, teal, and purple are used less frequently and don't need full 10-step scales yet. If any of these become primary brand colors in the future, build a full scale following the same pattern.
| Token | Value | Notes |
|---|---|---|
| --color-white | #FFFEFC | Warmest white. Card surfaces. |
| --color-white-1 | #FEFDF9 | 12% toward paper |
| --color-white-2 | #FCFAF7 | 25% toward paper |
| --color-white-3 | #FAF7F2 | 50% toward paper |
| --color-white-4 | #F8F4ED | 75% toward paper |
| --color-white-5 | #F5F0E8 | = --color-paper |
| --color-paper | #F5F0E8 | Page background |
| --color-paper-2 | #ECE7DD | Recessed surfaces |
| --color-paper-3 | #E1DCD1 | Deep recessed / disabled |
| --color-rule | #D2C9BC | Borders, dividers |
| --color-ink | #0C0B09 | Darkest text |
| --color-ink-mid | #36332C | Body text |
| --color-ink-light | #66605A | Secondary text |
| --color-ink-faint | #9E9890 | Tertiary / metadata |
| --color-red-50…900 | see scale above | 10-step red family |
| --color-navy-50…900 | see scale above | 10-step navy family |
| --color-gold | #8A6E0A | Warnings, revised status |
| --color-green | #1A5230 | Success, confirmed |
| --color-teal | #186060 | Secondary accent |
| --color-purple | #503890 | Tertiary accent |
| --color-ask-bg | #16192A | Dark surface for ask panel |
These define how colors are used. Components reference these tokens, not primitives. If we ever theme The Dossyer (dark mode, alternate brand), we swap this layer only.
| Token | References | Usage |
|---|---|---|
| --color-bg-page | var(--color-paper) | Page background |
| --color-bg-card | var(--color-white) | Card / block surfaces |
| --color-bg-card-read | var(--color-white-2) | Read state card surface |
| --color-bg-recessed | var(--color-paper-2) | Inset areas, table stripes |
| --color-bg-dark | var(--color-ink) | Dark bars, headers, footer |
| --color-bg-ask | var(--color-ask-bg) | Ask surface background |
| --color-bg-red-tint | var(--color-red-50) | Breaking update bg, alert surfaces |
| --color-bg-navy-tint | var(--color-navy-50) | Info update bg, new-info surfaces |
| --color-text-primary | var(--color-ink) | Headlines, strong text |
| --color-text-secondary | var(--color-ink-mid) | Body prose, descriptions |
| --color-text-tertiary | var(--color-ink-light) | Bylines, less important |
| --color-text-muted | var(--color-ink-faint) | Timestamps, metadata |
| --color-text-on-dark | var(--color-white) | Text on dark backgrounds |
| --color-text-on-dark-muted | rgba(246,241,233,.4) | Secondary text on dark |
| --color-border-default | var(--color-rule) | Card borders, dividers |
| --color-border-subtle | var(--color-paper-2) | Inner dividers, table rows |
| --color-interactive | var(--color-navy-500) | Links, buttons, focus rings |
| --color-interactive-hover | var(--color-navy-700) | Hover state for interactive |
| --color-interactive-bg | var(--color-navy-50) | Hover bg on interactive elements |
| --color-interactive-border | var(--color-navy-200) | Focus ring, input border active |
| --color-status-breaking | var(--color-red-500) | Breaking news, urgency |
| --color-status-breaking-bg | var(--color-red-50) | Breaking update block bg |
| --color-status-breaking-border | var(--color-red-500) | Breaking accent border |
| --color-status-updated | var(--color-navy-500) | New information status |
| --color-status-revised | var(--color-gold) | Revised content |
| --color-status-success | var(--color-green) | Confirmed, positive |
| --color-unread-dot | var(--color-red-500) | Unread indicator |
| --color-cov-left | #2563eb | Coverage bar: left-leaning |
| --color-cov-center | var(--color-navy-500) | Coverage bar: center |
| --color-cov-right | #dc2626 | Coverage bar: right-leaning |
Used sparingly. Only when a component needs a value that doesn't map cleanly to a semantic token.
| Token | References | Component |
|---|---|---|
| --badge-breaking-bg | var(--color-red-500) | Breaking news badge bg |
| --badge-breaking-text | var(--color-text-on-dark) | Breaking news badge text |
| --badge-updated-bg | var(--color-navy-50) | Updated badge bg |
| --badge-updated-text | var(--color-navy-800) | Updated badge text |
| --chip-bg | var(--color-navy-50) | Suggest chip background |
| --chip-border | var(--color-navy-200) | Suggest chip border |
| --chip-hover-bg | var(--color-navy-100) | Suggest chip hover |
| --velocity-hot-bg | var(--color-red-100) | Velocity pill (hot) |
| --velocity-hot-text | var(--color-red-700) | Velocity pill text (hot) |
| --market-up-bg | #d4edda | Market pill (green bg) |
| --market-up-text | #155724 | Market pill (green text) |
| --market-dn-bg | var(--color-red-100) | Market pill (red bg) |
| --market-dn-text | var(--color-red-800) | Market pill (red text) |
The color scales replace all scattered rgba() opacity tints with named tokens. This table maps the most common patterns currently in the CSS to their scale equivalents.
| Old Pattern | New Token | Where Used |
|---|---|---|
rgba(26,58,92,.03) | var(--color-navy-50) | Update block bg, tint surfaces |
rgba(26,58,92,.04) | var(--color-navy-50) | Chip backgrounds |
rgba(26,58,92,.06) | var(--color-navy-50) | Focus ring fill |
rgba(26,58,92,.08) | var(--color-navy-100) | Hover states, badge bg |
rgba(26,58,92,.1) | var(--color-navy-100) | Velocity pill, pressed states |
rgba(26,58,92,.12) | var(--color-navy-200) | Border on tinted surfaces |
rgba(26,58,92,.22) | var(--color-navy-200) | Strong hover borders |
| Old Pattern | New Token | Where Used |
|---|---|---|
rgba(184,50,37,.03) | var(--color-red-50) | Breaking update block bg |
rgba(184,50,37,.1) | var(--color-red-100) | Velocity hot pill bg |
When implementing: search the CSS for rgba(26,58,92 and rgba(184,50,37 and replace each with the closest scale token. The scale steps produce slightly different exact colors than the rgba values — that's expected and correct. The scale values are perceptually designed to be harmonious with each other, which hand-picked opacities aren't.
| Token | Stack | Role |
|---|---|---|
| --font-display | Georgia, 'Times New Roman', serif | Headlines, story titles |
| --font-prose | Georgia, 'Times New Roman', serif | Long-form reading |
| --font-sans | 'DM Sans', system-ui, sans-serif | UI, cards, navigation |
| --font-mono | 'Inconsolata', 'Courier New', monospace | Labels, metadata, code |
A rationalized scale that covers every size currently in use. Each step has a name and a purpose.
| Token | Value | Specimen |
|---|---|---|
| --size-hed-xl | 32px | Story Headline |
| --size-hed-lg | 22px | Modal Headline |
| --size-hed-md | 19px | Section Title |
| --size-hed-sm | 17px | Intro Opener |
| --size-body | 16px | Dossier prose reading |
| --size-body-sm | 15px | Intro body, descriptions |
| --size-ui | 14px | Interface text, navigation |
| --size-ui-sm | 13px | Chips, secondary UI |
| --size-caption | 13px | Trend items, social topics |
| --size-caption-sm | 12px | Sub-descriptions, fine print |
| --size-meta | 12px | Timestamps, source counts |
| --size-label | 11px | SECTION LABELS |
| --size-nano | 10px | HEADER BAR LABELS |
| --size-micro | 10px | BADGE MICRO TEXT |
| Token | Value | Usage |
|---|---|---|
| --weight-regular | 400 | Body text, descriptions |
| --weight-medium | 500 | UI emphasis, sub-headers |
| --weight-semibold | 600 | Card headlines, strong labels |
| --weight-bold | 700 | Display headlines, intro opener |
| --weight-black | 900 | Trend rank numbers only |
| Token | Value | Usage |
|---|---|---|
| --lh-tight | 1.12 | Large display headlines |
| --lh-hed | 1.22 | Story headlines |
| --lh-snug | 1.3 | Card headlines, compact text |
| --lh-ui | 1.5 | Interface text |
| --lh-body | 1.7 | Card descriptions, UI prose |
| --lh-prose | 1.88 | Long-form dossier reading |
| Token | Value | Usage |
|---|---|---|
| --tracking-tight | 1px | Compact mono labels |
| --tracking-normal | 1.5px | Small labels, micro badges |
| --tracking-wide | 2px | Update labels, footnotes |
| --tracking-label | 3px | Section labels, bar headers |
| --tracking-display | 4px | Cover/masthead labels |
Composite type styles that combine family, size, weight, line-height, and tracking into named roles. Components should use these roles, not assemble typography from individual tokens.
--font-display · --size-hed-xl · --weight-bold · --lh-tight
--font-display · --size-hed-md · --weight-semibold · --lh-snug
--font-prose · --size-body · --weight-regular · --lh-prose · max-width: --measure-prose
--font-mono · --size-nano · --weight-medium · --tracking-label · uppercase
--font-sans · --size-ui · --weight-regular · --lh-ui
| Style Name | Family | Size | Weight | Line-height | Tracking |
|---|---|---|---|---|---|
| type-headline-xl | display | hed-xl | bold | tight | — |
| type-headline-lg | display | hed-lg | bold | hed | — |
| type-headline-card | display | hed-md | semibold | snug | — |
| type-headline-sm | display | hed-sm | bold | snug | — |
| type-prose | prose | body | regular | prose | — |
| type-body | sans | body-sm | regular | body | — |
| type-ui | sans | ui | regular | ui | — |
| type-ui-strong | sans | ui | semibold | ui | — |
| type-caption | sans | caption | regular | snug | — |
| type-meta | mono | meta | regular | ui | — |
| type-label | mono | nano | medium | 1 | label |
| type-label-sm | mono | micro | medium | 1 | normal |
A 4px base grid. Every spacing value in the system is a multiple of 4.
| Token | Value | Visual |
|---|---|---|
| --space-1 | 4px | |
| --space-2 | 8px | |
| --space-3 | 12px | |
| --space-4 | 16px | |
| --space-5 | 20px | |
| --space-6 | 24px | |
| --space-8 | 32px | |
| --space-10 | 40px | |
| --space-12 | 48px | |
| --space-16 | 64px |
| Token | References | Usage |
|---|---|---|
| --space-inset-xs | var(--space-1) | Tight inner padding (badges) |
| --space-inset-sm | var(--space-2) | Compact component padding |
| --space-inset | var(--space-4) | Default component padding |
| --space-inset-lg | var(--space-6) | Modal / card inner padding |
| --space-stack-sm | var(--space-3) | Small vertical gap between items |
| --space-stack | var(--space-5) | Default vertical gap |
| --space-stack-lg | var(--space-8) | Section vertical gap |
| --space-inline-sm | var(--space-2) | Small horizontal gap (badge row) |
| --space-inline | var(--space-4) | Default horizontal gap |
| Token | Value | Usage |
|---|---|---|
| --shadow-xs | 0 1px 2px rgba(12,11,9,0.04) | Flat cards, read state |
| --shadow-sm | 0 1px 3px rgba(12,11,9,0.06), 0 1px 2px rgba(12,11,9,0.04) | Default cards |
| --shadow | 0 4px 16px rgba(12,11,9,0.07), 0 1px 3px rgba(12,11,9,0.05) | Hover cards, elevated panels |
| --shadow-lg | 0 12px 40px rgba(12,11,9,0.12), 0 4px 12px rgba(12,11,9,0.06) | Dropdowns, notifications |
| --shadow-xl | 0 20px 60px rgba(12,11,9,0.16), 0 8px 20px rgba(12,11,9,0.08) | Modals |
| Token | References | Usage |
|---|---|---|
| --elevation-flat | var(--shadow-xs) | Read cards, dimmed surfaces |
| --elevation-card | var(--shadow-sm) | Default card resting state |
| --elevation-raised | var(--shadow) | Hovered cards, popovers |
| --elevation-overlay | var(--shadow-xl) | Modal overlays |
| Token | Value | Visual |
|---|---|---|
| --radius-sm | 4px | |
| --radius | 6px | |
| --radius-lg | 10px | |
| --radius-xl | 14px | |
| --radius-full | 50% |
| Token | References | Usage |
|---|---|---|
| --radius-badge | var(--radius-sm) | Badges, status pills |
| --radius-card | var(--radius) | Cards, blocks, containers |
| --radius-input | var(--radius-lg) | Inputs, chips, user bubbles |
| --radius-modal | var(--radius-lg) | Modal boxes |
| --radius-circle | var(--radius-full) | Dots, avatars, indicators |
Border tokens combine width + style + color into named patterns.
| Token | Value | Usage |
|---|---|---|
| --border-default | 1px solid var(--color-border-default) | Card borders, dividers |
| --border-subtle | 1px solid var(--color-border-subtle) | Inner dividers, table rows |
| --border-strong | 2px solid var(--color-border-default) | Section dividers, emphasis |
| --border-accent | 3px solid var(--color-interactive) | Update highlights left border |
| Token | Value | Feel |
|---|---|---|
| --ease-out | cubic-bezier(0.16, 1, 0.3, 1) | Snappy deceleration. Default for UI. |
| --ease-in-out | cubic-bezier(0.4, 0, 0.2, 1) | Smooth entrance/exit. Modals, overlays. |
| --duration-fast | 0.15s | Hover states, color transitions |
| --duration-normal | 0.25s | Default UI transitions |
| --duration-slow | 0.4s | Modals, overlays, expand/collapse |
| Token | Value | Usage |
|---|---|---|
| --transition-hover | all var(--duration-fast) var(--ease-out) | Hover color/shadow changes |
| --transition-ui | all var(--duration-normal) var(--ease-out) | Default UI transitions |
| --transition-overlay | opacity var(--duration-normal) var(--ease-in-out) | Modal backdrop fade |
| --transition-expand | max-height var(--duration-slow) var(--ease-out) | Accordion, collapse/expand |
Named z-index layers prevent magic number collisions.
| Token | Value | Usage |
|---|---|---|
| --z-base | 0 | Default layer |
| --z-sticky | 10 | Sticky headers within scroll areas |
| --z-header | 50 | Masthead |
| --z-notification | 85 | Live update bar |
| --z-overlay | 200 | Modal overlays |
| Token | Value | Usage |
|---|---|---|
| --measure-prose | 64ch | Maximum prose line width |
| --layout-page-max | 1220px | Page max-width |
| --layout-page-gap | var(--space-8) | Gap between main + sidebar |
| --layout-modal-wide | 760px | Story dossier modal max-width |
| --layout-modal-narrow | 600px | Snap modal max-width |
| --layout-chat-column | 780px | Ask surface conversation width |
| --layout-sidebar-min | 310px | Sidebar minimum width |
Buttons are the most duplicated pattern in the codebase. The system defines three base button types and two modifier axes (surface and size). All buttons share a common reset; variants differ only in color, padding, and border treatment.
.btn { font-family: var(--font-sans); font-weight: var(--weight-medium); border: none; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); transition: var(--transition-hover); line-height: 1; -webkit-font-smoothing: antialiased; } .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
For the main action in a context: send message, refresh, submit. Strong background, white text.
| Token / Class | Value | Usage |
|---|---|---|
| .btn-primary | bg: var(--interactive), text: var(--text-on-dark) | Chat send buttons, CTA actions |
| .btn-primary:hover | bg: var(--interactive-hover), scale: 1.04 | Hover lift effect |
| .btn-primary:disabled | bg: var(--color-rule), no scale | Disabled state |
| .btn-primary.btn-danger | bg: var(--color-red-500) | Destructive primary: ask send button (red brand) |
.btn-primary { background: var(--interactive); color: var(--text-on-dark); border-radius: var(--radius-input); padding: var(--space-2) var(--space-4); font-size: var(--size-ui); } .btn-primary:hover { background: var(--interactive-hover); transform: scale(1.04); } .btn-primary.btn-danger { background: var(--color-red-500); } .btn-primary.btn-danger:hover { background: var(--color-red-700); }
For secondary actions: navigation tabs, refresh, toggles. Transparent background with border or text-only, highlights on hover.
| Token / Class | Value | Usage |
|---|---|---|
| .btn-ghost | bg: transparent, border: var(--color-rule) | Light surface ghost buttons |
| .btn-ghost:hover | bg: var(--color-paper) | Hover fill |
| .btn-ghost-dark | bg: transparent, border: var(--border-on-dark) | Dark surface ghost: nav tabs, refresh |
| .btn-ghost-dark:hover | bg: var(--bg-on-dark-hover) | Hover highlight on dark |
.btn-ghost { background: transparent; border: 1px solid var(--color-rule); color: var(--text-secondary); border-radius: var(--radius-card); padding: var(--space-2) var(--space-3); font-size: var(--size-ui-sm); } .btn-ghost:hover { background: var(--color-paper); color: var(--text-primary); } .btn-ghost-dark { background: transparent; border: 1px solid var(--border-on-dark); color: var(--text-on-dark-muted); border-radius: var(--radius-sm); padding: var(--space-1) var(--space-3); font-size: var(--size-label); } .btn-ghost-dark:hover { background: var(--bg-on-dark-hover); color: var(--text-on-dark-mid); }
For suggest chips, source tags, filter pills, preloaded questions. Tinted background, compact padding, pill radius.
| Token / Class | Value | Usage |
|---|---|---|
| .btn-chip | bg: var(--color-navy-50), border: var(--color-navy-200) | Light surface chips: preloads, sources, suggest |
| .btn-chip:hover | bg: var(--color-navy-100), lift -1px | Hover |
| .btn-chip-dark | bg: var(--border-on-dark), border: var(--text-on-dark-faint) | Dark surface chips: since-banner, ask hints |
.btn-chip { background: var(--color-navy-50); border: 1px solid var(--color-navy-200); color: var(--interactive); border-radius: var(--radius-input); padding: var(--space-2) var(--space-3); font-size: var(--size-caption); line-height: var(--lh-snug); } .btn-chip:hover { background: var(--color-navy-100); border-color: var(--color-navy-200); transform: translateY(-1px); }
Buttons that contain only an icon (no text label). Built by combining the base .btn with .btn-icon and a type modifier.
| Class | Dimensions | Icon Size | Usage |
|---|---|---|---|
| .btn-icon-sm | 28×28px | --size-icon-sm (16px) | Close buttons, dismiss |
| .btn-icon | 34×34px | --size-icon (20px) | Default: chat send, actions |
| .btn-icon-lg | 40×40px | --size-icon-lg (24px) | Feature actions, hero CTAs |
.btn-icon { width: 34px; height: 34px; padding: 0; border-radius: var(--radius-input); } .btn-icon .icon { width: var(--size-icon); height: var(--size-icon); } .btn-icon-sm { width: 28px; height: 28px; border-radius: var(--radius-card); } .btn-icon-sm .icon { width: var(--size-icon-sm); height: var(--size-icon-sm); }
Combine .btn-icon with a type to get the right surface treatment:
| Combination | Example | Usage |
|---|---|---|
.btn .btn-icon .btn-primary | Navy circle with white arrow icon | Chat send button (story, snap) |
.btn .btn-icon .btn-primary .btn-danger | Red circle with white arrow icon | Ask surface send button |
.btn .btn-icon-sm .btn-ghost | Transparent with × icon | Close button on light modals |
.btn .btn-icon-sm .btn-ghost-dark | Transparent with × icon on dark bar | Modal close, banner dismiss |
<!-- Primary icon button (chat send) --> <button class="btn btn-icon btn-primary"> <i data-lucide="arrow-right" class="icon"></i> </button> <!-- Ghost close button on dark bar --> <button class="btn btn-icon-sm btn-ghost-dark"> <i data-lucide="x" class="icon"></i> </button> <!-- Chip with icon + text --> <button class="btn btn-chip"> <i data-lucide="refresh-cw" class="icon-sm"></i> Refresh </button>
Map the 15 existing button patterns to the new base+modifier system:
| Old Class | New Classes | Notes |
|---|---|---|
| .ask-send-btn | .btn .btn-icon .btn-primary .btn-danger | Red send button |
| .chat-btn | .btn .btn-icon .btn-primary | Navy send button |
| .bar-dark-close | .btn .btn-icon-sm .btn-ghost-dark | × close on dark header |
| .since-x | .btn .btn-icon-sm .btn-ghost-dark | × dismiss on banner |
| .mnav-btn | .btn .btn-ghost-dark | Nav tab |
| .mast-refresh | .btn .btn-ghost-dark | Refresh + icon |
| .schip | .btn .btn-chip-dark | Since-banner chip |
| .suggest-chip | .btn .btn-chip-dark | Suggest on dark surface |
| .suggest-chip-light | .btn .btn-chip | Suggest on light surface |
| .chat-preload-chip | .btn .btn-chip | Preloaded question chip |
| .src-chip | .btn .btn-chip | Source tag chip |
| .social-toggle | .btn .btn-ghost | Expand/collapse toggle |
The new system reduces 15 independent button implementations to 3 base types × 2 surface variants × 3 sizes = 18 possible combinations from one set of composable classes. Each existing button can be expressed by combining 2–3 classes.
Token names follow a consistent structure across all categories:
/* Pattern: --{category}-{property}-{variant} */ /* Color */ --color-text-primary --color-bg-card --color-border-default --color-status-breaking /* Typography */ --font-display --size-hed-xl --weight-semibold --lh-prose --tracking-label /* Spacing */ --space-4 /* primitive: 16px */ --space-inset /* semantic: default padding */ /* Elevation */ --shadow-sm /* primitive */ --elevation-card /* semantic */
Rules: always kebab-case, always lowercase. Primitives use numeric or scale names. Semantics use descriptive purpose names. Component tokens prefix with the component name.
The Dossyer uses Lucide Icons — an open-source, MIT-licensed SVG icon library. Lucide is a fork of Feather Icons with broader coverage (1,500+ icons), active community development, and a clean editorial aesthetic that matches our warm, minimal brand.
Instead of a CDN dependency, our single-file architecture uses an inline SVG icon registry — a JS object mapping icon names to SVG path data, plus an icon(name, class) function that returns complete SVG markup. This means:
createIcons() init stepicon('name') in template literals// Icon registry — SVG paths keyed by name const ICONS = { 'arrow-right': '<path d="M5 12h14M12 5l7 7-7 7"/>', 'logo-x': '<path d="M18.244 2.25h3.308l-7.227..."/>', ... }; // Render function — returns inline SVG string function icon(name, extraClass) { // stroke-based for UI, fill-based for logos }
<!-- Direct inline SVG in static HTML --> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg> <!-- Via JS icon() function in dynamic content --> element.innerHTML = icon('arrow-right', 'icon-sm');
| Token | Value | Usage |
|---|---|---|
| .icon-xs | 11px | Velocity badges, spotlight labels, any text at 9–10px (--size-nano, --size-label). Rule: icons ~1.2× companion text size. Minimum text size in the system is 10px. |
| --size-icon-sm | 16px | Inline with small text (11–13px), platform logos, metadata icons |
| --size-icon | 20px | Default icon size — sidebar items, cards |
| --size-icon-lg | 24px | Feature icons, culture section, headers |
.icon { width: var(--size-icon); height: var(--size-icon); stroke: currentColor; stroke-width: 1.75; flex-shrink: 0; vertical-align: middle; } .icon-sm { width: var(--size-icon-sm); height: var(--size-icon-sm); } .icon-lg { width: var(--size-icon-lg); height: var(--size-icon-lg); }
Replace all emoji characters with Lucide icon elements. Key mappings:
| Old (Emoji) | New (Lucide) | Context |
|---|---|---|
| 🏀 | trophy | Sports / March Madness |
| 🏰 | building-2 | Entertainment / Disney |
| 🌡️ | thermometer | Weather |
| 🔥 | flame | Velocity: hot/breaking |
| 𝕏 | twitter | X / Twitter platform |
| 📕 | book-open | |
| 🟠 | code-2 | Hacker News |
| 💼 | briefcase | |
| 🦋 | cloud | Bluesky |
| 🎵 | music | TikTok |
| 📰 | file-text | Substack |
| ★ | star | Market spotlight |
Text on dark backgrounds (masthead, footer, ask surface, dark header bars) uses a warm-tinted white at various opacities. These tokens replace scattered rgba(246,241,233,...) values.
| Token | Value | Usage |
|---|---|---|
| --text-on-dark | var(--color-white) | Primary text on dark: headlines, strong labels |
| --text-on-dark-hi | rgba(246,241,233,.8) | High emphasis: active nav items, bright labels |
| --text-on-dark-mid | rgba(246,241,233,.6) | Medium emphasis: hover states, secondary text |
| --text-on-dark-muted | rgba(246,241,233,.4) | Muted: bar labels, timestamps, metadata |
| --text-on-dark-faint | rgba(246,241,233,.2) | Faint: decorative text, version labels, rules |
| --border-on-dark | rgba(246,241,233,.12) | Borders on dark surfaces |
| --bg-on-dark-hover | rgba(246,241,233,.06) | Hover highlight on dark surfaces |
These replace the ~23 raw rgba(246,241,233,...) patterns currently in the CSS. The mapping:
| Old Pattern | New Token |
|---|---|
rgba(246,241,233,.8) | var(--text-on-dark-hi) |
rgba(246,241,233,.65) / .7 | var(--text-on-dark-mid) |
rgba(246,241,233,.35) / .4) / .45) | var(--text-on-dark-muted) |
rgba(246,241,233,.2) / .25) / .3) | var(--text-on-dark-faint) |
rgba(246,241,233,.1) / .12) / .15) | var(--border-on-dark) |
rgba(246,241,233,.06) / .08) | var(--bg-on-dark-hover) |
One component, three placements: ask surface drawer, story dossier modal, snap modal. No avatars — just right-aligned user bubble and left-aligned prose response that matches the story text.
| Class | Purpose |
|---|---|
| .td-chat | Container — border-top divider, inherits parent padding |
| .td-chat__label | "ASK ABOUT THIS STORY" header (label-mono) |
| .td-chat__hints | Contextual prompt chips container (flex-wrap) |
| .td-chat__chip | Individual prompt chip — one class for all contexts |
| .td-chat__msgs | Scrollable message area (max-height: 0 → 400px) |
| .td-chat__input-row | Input + send button row |
| .td-chat__input | Text input field |
| Element | Class | Specs |
|---|---|---|
| User question | .cmsg.user > .bub-u | Right-aligned, DM Sans var(--size-ui), paper-2 bg, subtle border, max-width: 80% |
| Response | .cmsg > .bub-tb | Left-aligned, Georgia var(--size-body), var(--lh-prose), var(--text-secondary). Matches story prose exactly. |
| Typing | .cmsg > .chat-typing | No bubble — italic Georgia, var(--text-muted), transparent bg, animated dots |
The chat component has zero side padding — it inherits from its parent container (story-modal-body at 96px sides, snap-body at 40px sides, ask-drawer at its own max-width). The border-top divider aligns with the content edges.
A unified registry pattern ensuring every citation — from story prose or chat responses — shares one sequential numbering system, one render component, and one collapsible section.
| Component | Purpose |
|---|---|
| createCitationRegistry(footnotes, sourceLinks) | Factory. Creates a registry seeded with story footnotes. Holds a lookup for deduplication and a fuzzy link resolver. |
| registry.resolve(sourceText) | Returns {num, isNew}. Existing → same number. New → next sequential. Tries fuzzy match against sourceLinks for URL. |
| renderCitationItem({num, text, link}) | Single render function for ALL citations. Link or no link. Story or chat. One component. |
| buildFootnotesHTML(footnotes, id, sources, srcUrlMap) | Builds the collapsible section. Citations (numbered, linked) + additional coverage (not numbered, linked where available). |
| textToHtmlChat(text, registry) | Converts [Source, Date] → globally-numbered superscripts via registry. Returns {html, newEntries}. |
| appendCitationsToDOM(fnSectionId, newEntries) | Inserts new citations above coverage line. Updates count badge. Uses renderCitationItem. |
| Type | What it is | Has number? | Has superscript in text? |
|---|---|---|---|
| Citation | A specific claim attributed to a specific source | Yes — sequential [1], [2], etc. | Yes — inline in prose |
| Additional source | An outlet that covered the story but isn't directly cited | No — no fake numbers | No — no superscript |
Collapsed by default. Toggle reads "SOURCES & CITATIONS" with chevron-right icon (rotates 90° on expand) and a count badge showing the number of numbered citations. Additional coverage appears below citations as "Also covered by: ..." with comma-separated linked source names.
All citations follow the format [Source Name, Month Year] — e.g. [Reuters, March 2026]. The date is required because it enables the registry's fuzzy link resolver to match against sourceLinks keys.
When applying this design system to the codebase:
:root — including brand color scales, dark surface text tokens, icon sizing, and semantic elevation/transition/z-index tokensrgba(26,58,92,...) patterns with navy scale tokensrgba(184,50,37,...) patterns with red scale tokensrgba(246,241,233,...) patterns with dark surface text tokens#fff with var(--text-on-dark).label-mono utility class across all 20 selectors that repeat the mono+uppercase+tracking pattern--size-icon tokens and .icon / .icon-sm / .icon-lg utility classes:root, no raw font-size, no brand rgba patterns, no emojis in UIComponents and patterns added since v1.03 that are now part of the system.
Unified badge system replacing the old unread-dot and updated-badge. Three states tied to the since banner's visual language:
| Class | Appearance | When used |
|---|---|---|
.card-status--new | Red bg, white text | Unread stories. Matches since banner "New" badge. |
.card-status--read | Paper bg, faint text | After markAsRead() swaps class + text. |
.card-status--updated | Navy-100 bg, navy text | Story updated after being read. |
One concept, two surfaces. They never overlap and share the same navy color:
| Surface | Purpose | Position |
|---|---|---|
| Since banner | Catch-up: "what happened while you were away" | Top, below masthead. Fixed single line, chip overflow → "N more" label. |
| Update tab | Real-time doorbell: "something changed while reading" | Hanging from masthead. Clicking scrolls to top + reveals since banner. |
The .td-chat__input-row is the styled container (background, border, border-radius). The <input> inside has no border/background. The send button sits at the right edge. Corner radius rule: container uses --radius-input (10px), nested button uses --radius (6px) — one step down.
| Utility | Purpose |
|---|---|
a11yInteractive(selector) | Adds role="button", tabindex="0", Enter/Space handler to clickable non-button elements |
a11yModalOpen/Close | Stores trigger element, returns focus on modal close |
a11yUpdateCollapsed(selector, isExpanded) | Sets tabindex=-1 on children of collapsed containers, restores on expand |
announceToSR(text) | Pushes text to aria-live region for screen reader announcements |
.sr-only | Visually hidden, accessible to assistive tech |
.skip-link | Skip navigation link, visible on Tab focus |
All hover shadows use --elevation-raised. No raw var(--shadow) on hover states. Resting states use --elevation-card or --elevation-flat.
No text below 10px. The smallest token is --size-micro: 10px. This is a hard rule. If something needs to be smaller, it probably doesn't need to be text.
| Surface | Focus treatment |
|---|---|
| Light backgrounds | outline: 2px solid var(--color-navy-500) |
| Dark backgrounds (masthead, bars) | outline-color: var(--color-white) |
| Inside overflow:hidden (cards, sidebar) | box-shadow: inset 0 0 0 2px var(--color-navy-500) |
| Since banner children | box-shadow: inset 0 0 0 2px var(--color-white) |
| Token | Value | Purpose |
|---|---|---|
| --color-blue-600 | #2563eb | Coverage bar: left |
| --color-red-ext-500 | #dc2626 | Coverage bar: right (distinct from brand red) |
| --color-green-50 | #d4edda | Market up background |
| --color-green-900 | #155724 | Market up text |
| --radius-btn | 10px | Buttons — ends --radius-input borrowing |
| --z-masthead | 90 | Masthead stacking (was hardcoded) |
--size-micro is now an explicit alias of --size-nano (duplicate resolved; one source of truth).
| Name | Kind | Notes |
|---|---|---|
velBadge(level, label, icon?) | JS component | Single source for velocity badges. Levels: hot / up / watch. Icons from registry. |
.btn-alert | Button variant | Red action button extending .btn (replaces since-update-btn one-off). |
.td-chat__error + .td-chat__retry | Chat state | Error surface with retry, light + dark variants. |
.img-fallback | Component | Styled placeholder replacing any failed image (global capture-phase listener). |
.ticker-symbol, .updates-container, .cov-note, .live-edition-note | Utilities | Replace former inline styles; honest coverage note for live stories. |
scrollLock | JS utility | Reference-counted body scroll lock (acquire/release). |
trapFocus / releaseFocusTrap | JS utility | Modal focus containment, wired into a11yModalOpen/Close. |
escapeAttr, stripHtml | JS utility | Attribute-safe escaping; DOM-based HTML→text. |
initIconography() | JS init | Renders .js-brand-icon and .js-vel placeholders from the ICONS registry on load. |
Removed: .text-prose, .icon-brand, .btn-icon-lg (dead code, zero usages).
New utilities/components: .cov-caption (estimate label under live coverage bars), .img-fallback--{markets|tech|world|politics|culture} topic-tinted placeholder variants + .img-fallback-label, .live-edition-note, fmtET()/fmtETAt() (all timestamps in real Eastern Time), topicPlaceholder(). Behavior rules: every live card renders a visual (photo or topic placeholder); the markets spotlight populates from live data or hides — stale data never displays; market values/percents are normalized app-side. Main markets table carries #mkt-main-table; renderers must scope to owned DOM (class selectors leak across component instances — see handoff v01.52).