📐 Sibyl Dashboard — Canonical Component Styleguide

The single source of truth for the dashboard's reusable component vocabulary. This catalog is Sibyl-owned and hand-maintained — it is no longer a Prometheus port (the sync script is retired). When applying the styleguide to the app or building a new component, this page is authoritative: the app should change to match this, not vice-versa.

How to read each section: a guidance block (what it is · when / when-not to use · sizing & spacing · variants · do/don't · which inconsistent in-app patterns it supersedes), then the canonical markup, then a live rendered example. Icons: emoji for wayfinding (sidebar/section/breadcrumb), flat Lucide SVG for all buttons.

Action toolbar · Avatar / user chip · Batch action bar · Breadcrumb page header · Buttons · Cards · Charts (Chart.js) · Code & markdown surfaces · Collapsible content sections · Color tokens · Confirmation gate · Expandable table rows · Forms · Inline divider · Layout grids · Loading & empty states · Modal · Mode switch · Nav count badge · Numeric formatting · Pill-filter row · Pills & status badges · Progress bars · Search + dropdown filter bar · Segmented stat cards · Side panel / drawer · Sidebar navigation — 4-level expandable header system · Sparklines · Stat grid · Status dot · Sticky apply bar · Tables · Tabs · Toast · Typography

Action toolbar — CANONICAL page-level action bar

The horizontal action bar at the top of a page — Back on the left, a vertical divider, then per-record actions grouped by kind, with an optional utility affordance on the right.

What
A single .card row of outline buttons with a flat single-color Lucide SVG icon on each. Layout: Back (blue outline, ←) → a .button-toolbar-divider (1px vertical line) → action buttons grouped by kind in fixed order (lifecycle → note → investigation → read) → optional right-side affordance (Generate summary, Export, etc.). The Back divider is the only inter-button separator; kind groups inside the action span carry no visible divider.
When
Top of a page that operates on a single record (item detail, schedule detail, dig detail).
Placement
Default: directly below the page title / breadcrumb header. If the page has tabs, the toolbar lives under the tab row instead — it becomes the first thing the operator sees after switching tabs. That way, each tab can swap in its own action set (a Notes tab surfaces note actions, a Digs tab surfaces investigation actions, etc.) without making the toolbar above the tabs choose a one-size-fits-all action list.
When not
List/index pages → no toolbar (those use header-row buttons via the breadcrumb header's right-side action slot). Modals → use a .button-row.spread footer. Per-row table actions → see Tables → Row actions.
Sizing
card padding 10px 14px · margin-bottom 12px · flex, align-items center, flex-wrap wrap, gap 6px · each button padding 6px 14px · radius 4px · 12px font · transparent background, 1px colored border, color matches border (outline-only). Icons use .icon (14px, flat single-color Lucide SVG, stroke="currentColor" so they inherit the button's kind color). The Back divider is .button-toolbar-divider (1px × 22px, var(--border)).
Variants
Pick whatever color reads as appropriate for the action — both the role-based palette (.primary blue, .danger red, .ghost muted) and raw color tokens (var(--blue)/--green/--yellow/--red/--muted/--text) are fair game. One useful default scheme — used in the example below and in item-detail.js — keys color by action kind:
  • lifecyclevar(--blue) — Back, Triage, Start, Block, Unblock, Defer, Close
  • notevar(--text) — Add note, Mark notes seen, Mark notes processed
  • investigationvar(--green) — Spawn dig, Synthesize, Re-triage, Replay probe
  • readvar(--muted) — Copy link, Open in CLI, Dependent items, Probe target
A right-side affordance (Generate summary, Regenerate summary…) typically carries the muted-border style of a disabled-ish read button when inactive, and a stronger color when active. In-flight states render disabled with cursor:not-allowed and a "doing X…" label.
Do
✓ Always lead with Back followed by a .button-toolbar-divider · use a flat Lucide SVG on every button · use outline style (transparent bg + colored border + colored text) for every button in the bar
Don't
✗ Use emoji icons (⛔ ✅ 📝 🔬 🔗 etc.) instead of flat SVG icons
Canonical — supersedes: ad-hoc per-page top-of-detail action rows; emoji-prefixed action buttons. Live in sibyl-reviews/src/public/pages/item-detail.js:_idRenderActionBarCard (the Back / Block / Defer / Close / Add note / … / Generate summary bar at the top of every item-detail page).
<div class="card" style="padding:10px 14px;margin-bottom:12px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
  <button style="…outline blue…"><svg class="icon" …>…arrow-left…</svg> Back</button>
  <span class="button-toolbar-divider"></span>
  <!-- lifecycle group (blue) -->
  <button style="…outline blue…"><svg class="icon" …>…ban…</svg> Block</button>
  <button style="…outline blue…"><svg class="icon" …>…pause…</svg> Defer</button>
  <button style="…outline blue…"><svg class="icon" …>…check…</svg> Close</button>
  <!-- note group (text color) -->
  <button style="…outline text…"><svg class="icon" …>…file-plus…</svg> Add note</button>
  <!-- investigation group (green) -->
  <button style="…outline green…"><svg class="icon" …>…flask…</svg> Spawn dig</button>
  <!-- read group (muted) -->
  <button style="…outline muted…"><svg class="icon" …>…link…</svg> Copy link</button>
  <!-- right-side affordance -->
  <button style="…outline muted…">Generate summary</button>
</div>

Back sits alone on the left, separated by a 1px .button-toolbar-divider. The right-side Generate summary affordance uses the muted-border treatment until a summary exists.

Avatar / user chip

28px circular identity chip — photo if avatar_path is set, otherwise an initial on var(--blue).

What
28px border-radius:50% chip. Photo variant: <img> with object-fit:cover. Initial fallback: uppercase first letter on a solid var(--blue) background, white text, centered.
Seen at
sidebar user block index.html:212; Users admin users.js:121–124, users.js:214.
J A Jjdog@sibyl.llc

Batch action bar

Header strip above a table that appears only when one or more checkbox-selected rows exist — shows a contextual action button.

What
Flex row, initially display:none; flips to display:flex when selection count > 0. Carries action button(s) labeled with the count ("Investigate Selected (3)").
Seen at
whale-watcher.js:599, whale-watcher.js:766, whale-watcher.js:1469.
Distinct from
The action toolbar (page-level, always present); this bar is table-local and selection-driven.
3 selected

Buttons — role-based + Lucide SVG

Outline-only, semantic color roles, flat single-color SVG icon on the left.

What
Buttons grouped in .button-row (or .button-toolbar for detail-page action bars). Color = meaning.
When
green submit/.success = commit (Save/Run) · blue .primary = forward/additive (Add/Open) · default = secondary (Refresh) · red .danger = destructive (Delete/Cancel) · ghost = reversible tertiary (Archive).
When not
Never solid-fill buttons; never inline-styled buttons; never the old .btn-action or per-module button DSLs.
Sizing
padding 4px 11px · radius 5px · 12px/500 · icon 14px inheriting currentColor · 8px gap in a row.
Variants
.button-row.spread (secondary left / primary right) · .button-row.right · .button-toolbar + .button-toolbar-divider.
Do
✓ Icon left via renderIcon(); pick role by intent
Don't
background:var(--blue);color:#fff solid buttons (7 files do this)
Canonical — supersedes: ~33 files of inline-styled buttons, the undefined .btn/.btn-sm usage, and the 7 private _wwBtn* constants in whale-watcher.js:219.
<div class="button-row">
  <button type="submit">${renderIcon('save')} Save</button>
  <button class="primary">${renderIcon('plus')} Add</button>
  <button class="danger">${renderIcon('trash')} Delete</button>
  <button class="ghost">${renderIcon('archive')} Archive</button>
</div>

Cards

General-purpose container for grouped content.

What
.card — surface panel with border + radius + padding. Optional .card-header / .card-footer slots.
When
Group related content where a full-width table is overkill; KPI tiles via the .label/.value/.sub children.
When not
Full-width tabular data → use .table-wrap. A grid of metrics → use the .stats-grid.
Sizing
padding 16px · radius 8px · border 1px var(--border) · bg var(--surface). In a grid use minmax(240px,1fr) columns, 12px gap.
Variants
.card-header (flex title/action row) · .card-footer (top-bordered action row) · .label/.value/.sub KPI children.
Do
✓ Use .card for any bordered surface panel
Don't
✗ Hand-roll style="background:var(--surface);border:1px solid var(--border);border-radius:8px"
Canonical — supersedes: ~15 ad-hoc inline panel divs (whale-watcher.js:891, users.js:687, platform-config.js:218, backtest.js:614).
<div class="card">
  <div class="card-header"><h3>Title</h3><span class="pill yes">active</span></div>
  <div class="label">Net P&L</div><div class="value">$12,480</div>
</div>

Bare card — surface, border, padding.

With header

active
Net P&L
$12,480
last 24h

With footer

Body content.

Charts (Chart.js)

Chart.js conventions: dark theme, disabled legend by default, custom tooltips, fixed-height container. Heavy usage across 20+ pages but no canonical spec yet.

Types in use
line, bar (stacked + grouped), scatter / bubble, doughnut.
Conventions
Dark canvas; grid lines = var(--border); ticks = var(--muted); legend usually disabled; tooltip callbacks.label overridden for unit-aware formatting; canvas wrapped in a fixed-height position:relative div.
Seen at
line advanced-analytics.js:415; bar advanced-analytics.js:446; doughnut advanced-analytics.js:543; bubble advanced-analytics.js:795, signal-quality.js:271.
[Chart.js canvas placeholder — fixed-height container, dark theme, disabled legend]

Live preview omitted; the styleguide page doesn't bundle Chart.js. Add a working preview once we settle on the canonical chart-options blob.

Code & markdown surfaces

Inline <code>, monospace <pre> blocks, and the .tc-md wrapper used to render LLM markdown.

Inline code
<code> for config keys, parameter names, short literal values. Seen at strategy.js:49, backtest.js:899.
Pre block
Multi-line output (raw JSON, smoke-test logs) — dark bordered surface, Courier New monospace, sometimes with a copy-to-clipboard affordance. Seen at smoke-test.js:309, strategy-analyst.js:1161.
Markdown wrapper
.tc-md = marked.parse() output with scoped overrides for blockquote, code, pre, details/summary, and heading sizes. Used for LLM-produced content. Seen at triage-console.js:244–277, item-detail.js:3236, claude-sessions.js:68.

Inline: tune HOUR_WEIGHT_PROFILE via the COE.

{
  "change_kind": "synthesis_decision",
  "confidence_band": "high",
  "scope": "SHARED"
}

Collapsible content sections

Native <details>/<summary> blocks used outside the sidebar — for collapsible logs, hidden detail panels, expert-mode toggles.

What
Native HTML disclosure block. Sometimes styled with a chevron and inner CSS scope; sometimes bare. The sidebar nav already uses this internally, but standalone content usage is a separate pattern.
Seen at
crypto-probe.js:1443, tuning-center.js:419, whale-watcher.js:975, triage-console.js:1757.
Expert-only · raw decision brief JSON
Click the summary to expand/collapse this content.

Color tokens

The full palette. Always use the CSS variable, never a raw hex.

What
The dashboard's design tokens, defined on :root. Values here are aligned 1:1 with the live app shell.
When
Every color reference in CSS or inline styles.
When not
Never hardcode hex (e.g. #3fb950) — it breaks theming and diverges over time.
Variants
--surface/--blue/--green/--yellow/--red are the canonical names; --panel/--accent/--ok/--warn/--err are legacy aliases (same values) kept only for ported CSS.
Do
color: var(--green)
Don't
color: #3fb950
Canonical — supersedes: ~40 hardcoded hex values in whale-watcher.js (#3fb950/#f85149/#d29922/#f0883e) and scattered inline colors.
--bg #0d1117 --surface #161b22 --text #e6edf3 --muted #8b949e --blue #58a6ff --green #3fb950 --yellow #d29922 --red #f85149

Confirmation gate

Native confirm('…') guard used uniformly before destructive operations — delete, promote, rollback, mass-approve.

What
One-line guard: if (!confirm('Delete user X? This cannot be undone.')) return;. Message phrasing pattern: action verb + object + consequence note.
Seen at
platform-config.js:390, whale-watcher.js:1889, users.js:604, regime-tuner.js:431 (15+ call sites total).
Distinct from
The custom modal (used for multi-step interactions); native confirm is the canonical lower-fi guard for "are you sure?" before destructive verbs.

Example call sites (not rendered — confirm() is a browser dialog):

if (!confirm('Delete user ' + name + '? This cannot be undone.')) return;
if (!confirm('Promote whale candidate ' + id + ' to live tracking?')) return;
if (!confirm('Roll back to revision ' + rev + '? Newer changes will be lost.')) return;

Expandable table rows

Click a row to insert a full-width colspan detail row below it; click again to collapse. Toggle state tracked in a JS Set.

What
Row gets a chevron/caret cell; click toggles insertion of an immediately-following <tr> with one <td colspan=N> holding the detail panel.
Seen at
advanced-analytics.js:1215, overview.js:196, optimization-lab.js:1034, calibration.js:519.
Distinct from
The drilldown-into-detail-page navigation (which jumps to a new route); expandable rows keep the operator on the same page.
MarketEdgeStatus
BTC > $80k EOD4.1%open
Detail panel — last 5 fills, slippage stats, source attribution. Closes when the parent row's caret is clicked again.
ETH > $4k EOD2.3%SIM
Fed cut March-1.0%skipped

Forms

One canonical input/select treatment — surface bg, border, 4px radius. Gallery below shows every common control so we can iterate on what stays / changes.

What
Text-class inputs (text, number, search, email, date…), <textarea>, <select>, plus the native widgets (checkbox, radio, range, file, color). Text/select fields share one treatment: var(--surface) background, var(--border) 1px border, 4px radius, 12px text. Native widgets keep their browser-default chrome (checkbox/radio dots, range track, file button) and inherit the page font.
When
All form controls. One token only for field backgrounds — var(--surface).
When not
Don't mix --bg/--bg2 backgrounds (today three different tokens are used interchangeably).
Sizing
text/select padding 4px 8px · radius 4px · 12px text · label 11px muted uppercase above the field with 4px gap · helper/error text 11px below the field with 4px gap. Textareas: same field treatment + min-height: 80px and resize:vertical only.
States
:disabled drops opacity to ~.55 and switches cursor to not-allowed; readonly keeps full opacity but removes the focus ring. aria-invalid="true" swaps the border to var(--red). Required fields lead the label with an asterisk styled color:var(--red).
Do
background:var(--surface);border:1px solid var(--border) for every text and select field · keep labels on their own line above the field (not inline) · use the helper-text slot below the field for unit hints / format examples
Don't
--bg/--bg2/--fg field tokens · floating labels · inline placeholder-as-label (placeholders are examples, not labels)
Canonical — supersedes: 3 select background tokens used interchangeably (--surface vs --bg vs --bg2); the backtest-scoped .bt-form-input leaked into users.js.
Text input
Text input — filled
Required field Asterisk in the label · validate on submit (not on blur).
Number input
Search input type="search" · browser clear-X · debounce in the handler.
Email input
URL input
Password input
Date input
Time input
Prefix · USD
$
Prefix · units
min
Textarea
Select — single
Select — with optgroups
Select — multiple
Checkbox group
Radio group
Toggle switch (off)
Toggle switch (on)
Segmented (3-way)
Range slider — 75%
File input
Color picker
Disabled input
Read-only input
Error state Enter a valid email address.
With helper text Threshold in percent (0–100). Defaults to 2.5%.

Gallery is a sandbox — every variant is a candidate, not a commitment. Call out which look right, which to change, and which to drop.

Inline divider

1px vertical rule for separating groups of inline controls — used in filter bars, toolbars, header strips.

What
<span style="width:1px;height:16px;background:var(--border);margin:0 8px"> — or the named .button-toolbar-divider already in style.css for the action toolbar.
Seen at
whale-watcher.js:581, whale-watcher.js:588, whale-watcher.js:595; .button-toolbar-divider usage in the action toolbar.
Open question
Two parallel implementations (inline span vs .button-toolbar-divider class) — pick one canonical form during review.
7d · 30d SIM · LIVE All instances

Layout grids

.two-col / .three-col / .cards-N — the responsive grid wrappers that size cards across a page.

What
.two-col is a 1fr 1fr grid (gap 12px) that collapses to 1 column on narrow viewports; .three-col is 1fr 1fr 1fr; .cards-2 through .cards-6 are column-count modifiers on the .cards grid.
Seen at
strategy.js:151, backtest.js:603, kpi.js:211, whale-watcher.js:2697 (two-col); advanced-analytics.js:971 (three-col).
Win Rate
61.4%
Edge
3.2%

Col 1

Col 2

Col 3

Loading & empty states

One loading pattern, one empty pattern — no more 3-way split.

What
Loading: .loading-state + .spinner. Empty: .empty.
When
Loading while a page/section fetches; empty when a fetch returns no rows.
When not
Never use .empty as a loading state; never raw inline padding loaders.
Sizing
spinner 22px · loading-state column, 14px gap, 80px/40px padding, muted 13px · empty 24px padding centered muted.
Do
.loading-state+.spinner for every loader
Don't
class="loading", .empty-state, or inline padding:40px loaders
Canonical — supersedes: .loading plain text (6 files), .empty misused as loader (ab-testing.js:53, regime-tuner.js:602), undefined .empty-state (whale-watcher.js:680), inline loaders (settings.js:184).
Loading…
No results for this filter.

Mode switch

Binary or 2–3-way pill toggle for an inline state choice — distinct from tabs (page navigation) and pill-filter rows (category narrowing).

What
Button group inside a bordered rounded container; the active button gets background:var(--blue) and white text.
Seen at
overview.js:542, kpi.js:151, settlement.js:112, whale-watcher.js:2104, positions.js:33.
Distinct from
Tabs switch views; mode-switch flips a single parameter (e.g. ROI vs P&L, SIM vs LIVE, 7d vs 30d).

Numeric formatting

Cross-page conventions for displaying numbers, relative time, and signed deltas. Bundles three closely-related patterns the audit flagged.

Monospace value
font-family:'Courier New',monospace on headline numbers (KPI tiles, table-cell amounts) so digits align across rows.
Time-relative labels
Standardized "Xs ago" / "Xm ago" / "Xh ago" / "Xd Xh ago" via global helpers in app.js:106–131 (fmtAge, fmtAgo, fmtDuration). Reviews has a parallel local variant at item-detail.js:1069.
Signed-delta coloring
Positive = leading + + var(--green); negative = var(--red); neutral = var(--muted). Applied to ROI / P&L / win-rate deltas. Seen at kpi.js:1264, strategy-lab.js:591–601, backtest.js:648–651.
$12,480.55 3m ago +4.1% -2.3% ±0.0%

Pill-filter row

Clickable pill row for one-axis filtering — via renderCategoryBar() in _shared.js.

What
A row of .pill-filter toggles; .active marks the current selection.
When
Category/segment filtering above a table or chart. Prefer the shared renderCategoryBar() helper for category fields.
When not
Non-interactive status → .pill. Many options / free text → a <select>.
Sizing
padding 3px 10px · radius 12px · 11px · 6px gap between pills, 12px margin-bottom on the row.
Do
✓ Reuse renderCategoryBar() output shape
Don't
✗ Re-implement a pill-filter row inline per page
Canonical — supersedes: 6+ hand-rolled pill-filter rows (calibration.js:400, stock-probe.js:182, profile-attribution.js:108).
All Crypto Weather Politics Sports

Pills & status badges

Compact inline status chips with semantic modifier classes.

What
.pill + a semantic modifier (.yes/.no/.sim/.live/.off and the .bt-* bet-type set).
When
Inline state/flags in tables, headers, list rows.
When not
Clickable filters → .pill-filter (next section), not .pill.
Sizing
padding 1px 7px · radius 10px · 11px/600. Tinted bg at ~15% alpha, solid token text color.
Variants
.yes green · .no red · .sim yellow · .live red · .off muted · .bt-* bet types.
Do
✓ Pick the modifier by meaning, not color
Don't
✗ Inline border-radius chip spans or a local _catPill copy
Canonical — supersedes: 41 inline chip spans (whale-watcher.js:115, users.js:139), the duplicated _catPill in kpi.js:116.
active failed SIM LIVE disabled

Progress bars

Two related patterns: a plain linear fill bar, and a composite label-+-bar-+-value score row.

Linear bar
6–8px tall, var(--border) background, 3–4px radius, colored fill width:%. Used for score bars, coverage, AB-test progress, readiness scores. Seen at strategy.js:140, whale-watcher.js:207, advanced-analytics.js:1418, backtest.js:274.
Score-bar composite
Three-part flex row: fixed-width muted label → flex fill track + colored fill → right-aligned monospace numeric value. Seen at strategy.js:138–144, whale-watcher.js:207–225, advanced-analytics.js:1000.
Distinct from
The segmented bar inside segmented stat cards is a different pattern (multi-segment proportional).
Coverage · 72%
Regime confidence
88%
Signal quality
42%

Search + dropdown filter bar — CANONICAL multi-axis table filter

Horizontal toolbar combining free-text search with one or more single-select dropdowns — multi-axis filtering without consuming vertical real estate.

What
A .card row with: a type="search" input on the left (debounced, drives a q= query param), then one or more single-select <select> dropdowns whose default option is an icon-prefixed "All …" sentinel ("🏷️ All kinds", "🎯 All priorities").
When
Filtering a table where the operator wants to combine quick-typed text refinement (title / id / description) with structured dropdown narrowing (kind / priority / component / system). Each filter contributes one query parameter to the server-paged table below.
When not
Few mutually exclusive categories that should be one click → pill-filter row. No text dimension and only one or two booleans → an inline mode-switch.
Sizing
row card padding 12px · min-height 56px · 8px gap · flex-wrap · align-items center · search input 240px wide, 6px 10px padding · selects 6px padding · 12px font on every control · 4px radius · 1px border var(--border) · bg var(--bg) (NOT --surface) so controls read as inputs against the card.
Variants
Default sentinel options always lead with a category icon ("🏷️ All kinds", "🎯 All priorities", "📦 All components", "📋 All systems"). Per-option labels carry their own icon ("🔴 HIGH", "🐛 finding"). A boolean refinement always becomes its own two-option dropdown ("All / Only X") — never a trailing checkbox + label, which eats horizontal real estate for one on/off bit.
Do
✓ Use type="search" (gets the browser clear-X for free) · debounce the input handler · icon-prefix every dropdown sentinel · keep all controls on one wrapping row
Don't
✗ Stack the search above the dropdowns · drop a "Filter" submit button (filtering should be live) · use surface-colored controls (they blend into the card) · add a trailing checkbox + caption for a boolean filter (promote it to a 2-option dropdown instead)
Canonical — supersedes: ad-hoc per-page filter rows that mix label-above-input layouts with bare un-iconed selects. Live in sibyl-reviews/src/public/pages/triage-console.js (decisions / items / revisits sub-tabs all share this exact bar — see _tcRenderInboxDecisionsView).
<div class="card" style="padding:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;min-height:56px">
  <input type="search" placeholder="Search title, rationale, recommendation…"
         style="padding:6px 10px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:240px;font-size:12px">
  <select style="padding:6px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px" title="Item kind">
    <option>🏷️ All kinds</option>
    <option>🐛 finding</option>
    <option>📋 spec</option>
    <option>📡 probe</option>
  </select>
  <!-- additional dropdowns: priority, component, system… -->
</div>

Segmented stat cards — CANONICAL drill-through header row

Compact card showing a total, a proportional segmented bar, and a clickable bubble row that deep-links to a filtered table.

What
A .card with four stacked rows: title row (label on the left, total right-justified) → one-line muted caption8px segmented bar where each segment's width = its share of the total → wrap row of color-coded badge bubbles, one per segment, each clickable to jump to a pre-filtered view of the linked table.
When
Showing a population (open backlog, 24h activity, errors by class) AND letting the operator drill into any slice in one click. Two to four cards make a "command-center" header above a table.
When not
Single headline number → bare .card with .label/.value. 4–8 unrelated KPIs → the stat grid. Time-series → chart.
Sizing
card flex-basis 200–240px · padding 8px 12px · 6px gap between rows · label 10px uppercase muted with .04em tracking · total 12px/700 right-justified · caption 10px muted with margin-top:-4px · bar 8px tall, 4px radius, 1px border, var(--bg) fill behind segments · bubbles 2px 8px · 10px/600 · 10px radius · 6px gap.
Variants
Bar segment + bubble share one canonical palette per dimension (priority, kind, activity bucket). Bubbles carry a count prefix ("12 high") and an onclick that filters the linked table; zero-count bubbles stay clickable and jump to an empty filtered view (the affordance is the point).
Do
✓ Right-justify the total on the label line · use one shared palette across bar + bubbles · keep the bar at exactly 8px tall
Don't
✗ Pile the total on its own line · use different colors in the bar than in the bubbles · invent a third row of mini-numbers below the bubbles
Canonical — supersedes: 12-tile .stats-grid headers that flatten priority/kind into equal-weight boxes. Live in sibyl-reviews/src/public/pages/triage-console.js:_tcRenderInboxStats (above the Decisions table).
Open Items 87 total
not closed · excludes outcome checks
4 high 12 medium 51 normal 14 low 6 info
Open by Kind 142 total
actionable backlog + outcome tracking
68 find 14 spec 5 probe 55 outcome

Side panel / drawer

Right-edge full-height fixed panel for conversational / contextual content. Distinct from modal (centered overlay).

What
position:fixed;right:0;top:0;bottom:0 panel, 360–500px wide, border-left:1px solid var(--border), drop shadow. Slides in over main content; dismissed by clicking backdrop or a close button.
Seen at
chat panel at triage-console.js:1022–1038, triage-console.js:1765.
Distinct from
The modal (centered, modal-blocking); the drawer is edge-anchored and full-height for ongoing context.
Main content area…
Chat session
Conversation messages…

Sparklines

Inline SVG mini-charts (~60×14) for time-series data in compact cards — no Chart.js dependency.

What
Tiny normalized <svg><polyline> rendered by a local helper. Distinct from full Chart.js canvas because they sit inline with text and don't need axes or legends.
Seen at
system-health.js:1518–1529, strategy.js:96, whale-watcher.js:2606.
Latency 42ms Errors 17

Stat grid

The canonical "row of headline numbers" — via renderParamsGrid() in _shared.js.

What
.stats-grid of .stat-box tiles, each .s-label + .s-value (+ optional .s-sub).
When
3–8 related metrics at the top of a page/section. Prefer the shared renderParamsGrid() helper.
When not
A single number → a .card with .label/.value. Tabular data → table.
Sizing
grid repeat(4,1fr), gap 12px · tile padding 14px · value 18px/700 mono.
Do
✓ Route through renderParamsGrid()
Don't
✗ Use the legacy .label/.value/.sub-in-card pattern for metric rows
Canonical — supersedes: 4 rival conventions — ad-hoc .stat-box (alpha-hunter.js:184), .label/.value/.sub tiles in 18 files, inline font-size:32px numbers (system-health.js:170).
Win Rate
61.4%
312 settled
Net P&L
$2,140
+4.1%
Open Risk
$780
6 positions
Edge
3.2%
avg

Status dot

8px solid color-coded health indicator — sits next to a label as a textless cue.

What
.dot class with .fresh (green) / .stale (yellow) / .dead (red) modifiers; 8px circle, flex-shrink:0. Also appears with inline backgrounds for per-instance colored dots.
Seen at
definition at style.css + index.html:386–389; usage at orchestrator.js:131, platform-config.js:453, instance-selector.js:76.
Distinct from pills
Pills carry text; dots are pure-color cues that sit before a separate label.
fresh stale dead Instance 17

Sticky apply bar

A position:sticky;top:0 strip that appears above content when there are unsaved settings changes — count + Save + Revert.

What
Sticky bar with pending-change count, a primary "Save" button, and (on platform-config) a revert affordance. Hidden when zero changes pending.
Seen at
settings.js:221–246, platform-config.js:210.
Distinct from
The action toolbar (always present on detail pages); this bar appears/disappears based on dirty state.
3 unsaved changes

Tables

Data grid — always wrapped in .table-wrap and wired via makeTableInteractive().

What
.table-wrap > <table> with <th class="sortable" data-col> headers; makeTableInteractive(id, …) adds sort + pagination + sticky header.
When
Every tabular dataset — even read-only ones (still wrap + register so scroll/paging is uniform).
When not
A handful of metrics → .stats-grid. Key/value pairs → a .card.
Sizing
12px cells · uppercase 11px muted headers · radius 8px on the wrap · horizontal scroll on overflow.
Variants
Sort via createSortState() · server paging via the remote option · noPagination for tiny tables.
Row actions
Per-row buttons (Edit, Delete, Investigate…) live in a single far-right "Actions" column — never scattered inline with data cells. The column is shrunk to fit the buttons via width:1% on the <th> (table-auto layout treats this as "as narrow as content allows" and gives leftover width to the data columns); cells inherit the table's white-space:nowrap so the button row never wraps. With the column shrunk, text-align:center on the header centers "Actions" directly over the button row. Wrap the buttons in a plain display:inline-flex;flex-wrap:nowrap;gap:6px;justify-content:flex-end container — not .button-row (its default flex-wrap:wrap would stack the buttons two-deep once the column tightens). Row buttons are sized one notch tighter than the canonical button (padding:2px 8px · font-size:11px · gap:4px · 12px icon) so they don't dominate the row visually. Every button carries a flat Lucide SVG icon (.icon, stroke="currentColor") so the glyph inherits the button's role color. Non-sortable header (no .sortable, no .sort-caret).
Do
✓ Give every table an id and pass it through the helper · put row actions in one far-right Actions column with right-justified buttons and a centered header
Don't
✗ Anonymous .table-wrap with no helper, or custom pager buttons · scatter buttons across multiple data columns or left-align the action cell (drifts away from the table edge as the column widens)
Canonical — supersedes: 20+ anonymous .table-wrap tables bypassing the helper (advanced-analytics.js:210, strategy-lab.js:141); custom pagers (ab-testing.js:843, settings.js:531); per-cell inline action buttons (users.js:412, whale-watcher.js:687) that the eye has to hunt for.
per page Page 1 of 1 0 results
Market Edge Status Actions
BTC > $80k EOD4.1%open
ETH > $4k EOD2.3%SIM
Fed cut March-1.0%skipped
NYC rain Friday3.7%open
SOL > $2001.2%open
NFL Sunday over0.8%SIM
Senate confirm-0.5%skipped
CPI > 3.2%2.9%open
SPX close > 53001.7%open
BTC > $100k EOY5.4%SIM
AVAX > $400.3%skipped
LA wildfire ext-1.4%skipped
SCOTUS ruling2.1%open
UK election early1.9%SIM

14 rows · default 10/page. Switch to 25 or All to land on "Page 1 of 1" with all four nav arrows disabled — the canonical page-bound state. The far-right Actions column shows the canonical row-button placement: centered header, right-justified buttons.

Tabs

In-page section switcher.

What
.tabs container of .tab items, each with an emoji .tab-icon + label; .tab.active is the current one (blue underline). The .tabs row always lives inside a horizontal-scroll wrapper (overflow-x:auto;white-space:nowrap;flex-wrap:nowrap) so tabs never wrap to a second line on narrow viewports — they scroll horizontally instead.
When
Switching views within one page (sub-pages of a hub, facets of a dataset). Every tab carries an emoji icon — tabs are wayfinding, so they use the emoji language (like the sidebar/breadcrumb), not button SVGs.
When not
Cross-page navigation → the sidebar. Binary mode → .mode-switch.
Sizing
tab padding 8px 16px · 13px · 6px icon gap · icon 14px · bottom-border 2px accent when active. Row has its own bottom border + 16px margin-bottom (don't override with margin-top:0). Scroll wrapper has no extra padding — the tab bar's own padding/border still aligns with the page gutter.
Do
✓ Always include an emoji .tab-icon; use the default margins · always wrap the .tabs row in a horizontal-scroll container so the bar scrolls instead of wrapping
Don't
✗ Icon-less tabs, SVG icons in tabs, or style="margin-top:0" per instance (15+ files do this today) · let tabs wrap to a second line (they become unreadable and break the active-underline anchor)
Canonical — supersedes: the near-universal inline .tabs margin-top:0 override; .tabs used as a toolbar via inline flex (platform-config.js:146); icon-less tabs across most pages; any tab row that wraps on narrow viewports.
<div style="overflow-x:auto;white-space:nowrap">
  <div class="tabs" style="flex-wrap:nowrap">
    <div class="tab active"><span class="tab-icon">🏠</span>Overview</div>
    <div class="tab"><span class="tab-icon">📋</span>Detail</div>
    <div class="tab"><span class="tab-icon">🕐</span>History</div>
  </div>
</div>
🏠Overview
📋Detail
🕐History
⚙️Settings
📊Metrics
🧪Experiments
🔔Alerts
👥Team

Eight tabs — shrink the window or zoom in until the bar overflows; it scrolls horizontally instead of wrapping.

Toast

Transient feedback — supersedes the 3 copy-pasted showToast copies and bare alert().

What
A .toast pinned bottom-right; left-border color = severity (.ok green / default blue / .err red). Auto-dismisses.
When
Success/info/non-blocking error feedback after an action.
When not
Decisions/confirmations → modal. Persistent inline validation → near the field.
Sizing
bottom/right 24px · padding 10px 16px · radius 8px · 13px/600 · z-index 1100 · 3px severity left-border.
Do
✓ One shared showToast() for all transient feedback
Don't
alert()/confirm() (~118 calls) or per-page toast copies
Canonical — supersedes: triply-duplicated showToast (alpha-hunter.js:430, governance.js:258, strategy.js:530) and ~118 raw alert()/confirm() calls across 16 files.
Info toast Saved. Request failed.

Typography

Base font is 14px system sans; numeric/monospace data uses Courier New.

What
Body text, headings, and the monospace numeric treatment for data values.
When
Numeric KPIs/metrics use the monospace 'value' treatment so digits align in tables and tiles.
Sizing
Body 14px/1.5 · section h2 14px/600 · stat value 18–24px/700 mono · labels 11px uppercase muted.
Do
✓ Use .value/.s-value for headline numbers
Don't
✗ Invent ad-hoc font-size:32px inline numbers
Body text — 14px / 1.5 line-height, var(--text).
Label — 11px uppercase muted
$12,480.55