arborist-viz/docs/style-guide.md
russell@unturf.com 0cd96ab1d7
Initial commit: arborist VIZ — Merkle command center
Pyramid web app for inspecting arborist Merkle trees over sharded SQLite:
document roots, inclusion proofs, an interactive 3D Merkle lattice
(six.js / WebGL with graceful fallback), audit-chain linkage, and a live
SSE event stream.

Generated artifacts (env/, vendored static/six/ bundles, demo databases)
are gitignored and rebuilt via the Makefile (venv, six, init-db, serve).
2026-05-28 09:22:59 -04:00

13 KiB
Raw Permalink Blame History

arborist VIZ — Style Guide

Legibility and ergonomics first. Hashes, proofs, and audit chains are read under fatigue. Every choice here optimizes for "still readable at 2am after six hours of incident response."

Tokens live in arborist_viz/static/css/viz.css under :root. Treat that file as the single source of truth; this document explains why each value is what it is and when to break the rules.


1. Color

1.1 Palette

Token Value Role Contrast on --bg
--bg #0b0e12 Canvas. Near-black, not pure.
--bg-elev #11161c Header, sidebars, widget shell.
--bg-elev-2 #161d25 Widget head, buttons, chips idle.
--border #1f2934 Hairline dividers. decorative only
--fg #d6e0ec Primary text. 14.0 : 1 (AAA)
--fg-dim #8aa1b8 Secondary text, labels. 6.7 : 1 (AA)
--fg-mute #7a8ea3 Tertiary, captions, separators. 5.0 : 1 (AA)
--accent #4dd1c1 Mint. Roots, links, live state. 8.9 : 1 (AAA)
--warn #f5b455 Amber. Stale, waiting, caution. 9.7 : 1 (AAA)
--bad #e96b6b Red. Failed, quarantined. 5.7 : 1 (AA)
--pulse #7aa2ff Blue. Proof pulse, hover halo. 7.0 : 1 (AA)

Note: --fg-mute was #5e7081 (3.8 : 1) — below WCAG AA. Lighten to #7a8ea3 or do not use it for text. The old value is fine for icons, decorative borders, or text 16px+ bold.

1.2 Color rules

  • Never use pure black #000 or pure white #fff on this surface. Pure-black-on-dark backgrounds vanish; pure-white text on near-black burns the eyes at low ambient light. Stay inside the palette.
  • Hue carries semantics, never decoration. Mint = healthy/live/root. Amber = stale/waiting. Red = failed. Blue = pulse on a proof event. Do not introduce a fourth status hue without updating this table.
  • Never encode information in color alone. Every red badge gets a glyph or word ("fail"). Every amber chip pairs with text ("stale 4h"). Color is reinforcement, not channel.
  • Avoid color combinations known to fail. No saturated red on saturated blue (chromostereopsis). No yellow-on-white. No cyan-on-white. No green next to red without a glyph (~8% of men are red/green color-blind).
  • Pills, buttons, chips: idle is --fg-dim. Color (accent/warn/bad) signals state change. A bar full of green chips means nothing because green stops being a signal.

1.3 Contrast minimums

Use Minimum ratio Source
Body text < 18px 4.5 : 1 WCAG 2.2 AA
Body text ≥ 18px bold 3.0 : 1 WCAG 2.2 AA
Hashes (monospace) 5.0 : 1 project rule
Focus ring on field 3.0 : 1 WCAG 2.2 AA
Icon / glyph 3.0 : 1 WCAG 2.2 AA

Hashes get a stricter rule than WCAG because every character matters. A mistransposed hex char is a different commitment, not a typo.


2. Typography

2.1 Stacks

--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--mono: ui-monospace, "JetBrains Mono", "Fira Code", Consolas, monospace;

System fonts only. No web-font network round-trip, no FOUT, no GDPR disclosure for a remote font CDN.

2.2 Sizes

Role Size Family Weight Notes
Body 14 px sans 400 was 13 — bump for fatigue.
Widget body text 14 px sans 400
Hash, code, audit row 13 px mono 400 was 11.5 — hashes need bulk.
Section label, table header 12 px sans 500 uppercase 0.08em letter-spacing.
Chip, pill, button caption 12 px mono 500 was 10.5 — too small to skim.
Footer meta, timestamps 12 px mono 400
H1 / page title 18 px sans 600

Floor: nothing below 12px. Hashes never below 13px. Audit chain rows get monospace so columnar scanning works.

2.3 Line and letter

  • line-height: 1.5 for body, 1.4 for dense tables.
  • letter-spacing: 0.05em for uppercase tags. Uppercase without tracking reads as a wall.
  • Never word-break: break-all. Atomic identifiers do not wrap mid-token. See §4.4 for the correct hash-display modes.

3. Spacing

A 4-pixel base unit. Multiples only. No 0.27rem magic numbers.

Step Value Use
1 4 px Icon-to-text gap, chip padding-y.
2 8 px Default inline gap.
3 12 px Widget body padding.
4 16 px Section padding, sidebar padding.
6 24 px Page-level gutters.
8 32 px Headline blocks.

Stack values vertically with the larger of the two adjacent margins; do not collapse margins manually.


4. Components

4.1 Buttons and clickable chips

  • Minimum hit target: 32 × 32 px (WCAG 2.5.8 AA). The Merkle command center is dense; do not go below this.
  • Visible focus ring on every focusable element. Use outline: 2px solid var(--pulse); outline-offset: 2px. Do not outline: none without a replacement.
  • Hover ≠ focus. Hover may add a border-color change; focus must add a ring visible to keyboard users.
  • Cursor: pointer only on actually clickable elements. Never on text.

4.2 Inputs (hash-drop bar, search, filter)

  • 36 px height minimum.
  • Placeholder color: --fg-mute. Never lighter — placeholders that disappear in low light are a known failure mode.
  • Focus: border becomes --accent, ring becomes --pulse. Two signals.
  • Invalid (:invalid) gets border-color: var(--bad) only after first blur. Live red-on-keystroke is hostile.

4.3 Widgets

  • Card: --bg-elev background, --border hairline, 6 px radius.
  • Header strip: --bg-elev-2, monospace type tag in --accent, sans title in --fg.
  • Footer: --bg, monospace meta in --fg-mute. Collapsible <details> for query/source attribution (every widget exposes the roots it derived from — §19.3 of ticket).
  • Empty state: italic --fg-mute body text. Never a blank box.

4.4 Atomic identifiers (hashes, CIDs, signatures, tokens)

An atomic identifier is any opaque string where every character is load-bearing and the whole value is read as one symbol. This includes:

  • SHA-256, SHA-512, BLAKE3, SHA-1 hex digests
  • IPFS CIDs, multihashes
  • Ed25519 / RSA signatures, public keys, SSH fingerprints
  • UUIDs, ULIDs, KSUIDs
  • JWTs, OAuth tokens (when shown to a human)
  • Git commit SHAs, content-addressed blobs

The core rule

Atomic identifiers never wrap mid-token. A SHA-256 split across lines at column 22 in one place and column 17 in another defeats the only useful thing humans do with hashes: visual diff. If two hashes wrap at different column positions, the eye cannot align them — every comparison degrades to a character-by-character scan.

Pick exactly one of the three display modes below. Do not mix.

Mode A — one-line scroll (preferred for inline use)

.id-inline {
  font-family: var(--mono);
  white-space: pre;          /* no wrap, no collapse */
  overflow-x: auto;          /* horizontal scroll if narrow */
  overflow-wrap: normal;
  word-break: normal;
}

The hash is one continuous string. If the container is narrower than the hash, the user scrolls horizontally. Always pair with a visible click-to-copy button — scrolling to read 64 chars is worse than copying.

Use for: table cells, list rows, inline references in prose.

Mode B — grouped chunks (preferred for prominent display)

38f82216  97ee54dd  b195a13f  b9a375d5
6aab5deb  3eee9213  523b4224  b1042270

Render the hash as 8 chunks of 8 hex chars separated by two spaces. Wrap only at chunk boundaries. Two hashes displayed this way line up column-for-column, which is the entire point.

.id-block {
  font-family: var(--mono);
  font-size: 13px;
  line-height: 1.5;
  white-space: pre-wrap;     /* wrap at whitespace only */
  word-break: keep-all;      /* never split a chunk */
  overflow-wrap: normal;
  padding: 0.5rem;
  background: var(--bg);
  border: 1px solid var(--border);
}

Render server-side as chunks.join(" ") with two spaces (or one en-space ). Do not use single space — single-space-separated hex still looks like one token at a glance.

Use for: the .root-hash-block card, proof page hero, anywhere the hash itself is the page subject.

Mode C — truncate with explicit reveal

38f82216…b1042270   [copy] [show full]

First 8 + ellipsis + last 8 hex chars. Always pair with:

  • title="<full hash>" for hover (desktop)
  • a visible click-to-copy button
  • a "show full" affordance (<details> or panel expansion)

Use for: dense lists where Mode A would still overflow, chrome bar pills, breadcrumbs, sidebar nav entries.

Floor rules

  • Never truncate below 8 + 8 = 16 hex chars displayed. Below that, visual collision risk gets real (a malicious near-collision can match the first 6 chars).
  • Never truncate without a copy affordance. Truncated hashes are not selectable into a useful value.
  • Never show only first-N truncation (38f82216…). Always show first AND last segments — first-only loses suffix entropy for diff.
  • Click-to-copy uses the navigator.clipboard API with a visible toast on success. No silent copy.

What this rule applies to outside this app

The reasoning generalizes — any system showing opaque identifiers to a human should follow these modes:

  • Block explorers (TX hashes, addresses)
  • Git interfaces (commit SHAs, blob hashes)
  • Container registries (image digests)
  • Cert viewers (SHA-256 fingerprints)
  • Package managers (integrity hashes)

If you find yourself writing word-break: break-all on an identifier, you have chosen comfort for the layout engine over legibility for the human. Reverse the choice.

4.5 Status dots

8 × 8 px circle. Always accompanied by text label within 8px to its right. Glow (box-shadow: 0 0 6px <color>) only on dot-on to make "live" pop without animating.

4.6 Tables (audit chains, leaf strips)

  • Monospace.
  • Zebra striping rgba(255,255,255,0.02) on odd rows — almost invisible but enough to anchor the eye on long lists.
  • Sticky header on scroll.
  • Per-row hover background var(--bg-elev-2). Click target is the whole row, not just a link in one column.

5. Motion

Default: none.

  • Status changes are step-functions. A chip flips color instantly.
  • Acceptable motion: a 200ms ease on :hover border-color changes; one-shot 400ms pulse on a fresh proof event.
  • No spinners on SSE streams — use a dot-on glow plus the last-event timestamp instead. Spinning indicates "we are waiting"; this app is always waiting on the audit chain.
  • Respect prefers-reduced-motion. Wrap every transition or animation in @media (prefers-reduced-motion: no-preference) { … }.

6. Accessibility checklist

Run before merging any view template:

  • Every interactive element reachable by Tab.
  • Visible focus ring on every focusable element.
  • No information conveyed by color alone.
  • All text passes its required contrast ratio (§1.3).
  • Hit targets ≥ 32 × 32 px.
  • <form>, <button>, <a> used semantically; no <div onclick>.
  • All icons either have aria-label or are decorative (aria-hidden="true").
  • Page works with images blocked.
  • Page works at 200% browser zoom without horizontal scroll on the main grid.
  • Page degrades to a single column at 720px width.

7. Anti-patterns (do not do)

  • Light-mode-only. Operators run dark by default. A light theme may come later; design dark first.
  • Decorative gradients on text. Hashes need pixel-perfect glyphs.
  • Tooltip-only labels. A pill that only reveals meaning on hover is invisible on touch and to screen readers. Always pair with on-screen text or aria-label.
  • Modals. This is a command center; context is read-only most of the time. Prefer side panels and inline detail sections over modal interruption.
  • Confirmation prompts on read actions. "Are you sure you want to view this audit event?" — no. Confirm only on writes.
  • Custom scrollbars. Native scrollbars indicate scrollable regions to assistive tech; styled scrollbars often hide on mobile.
  • Icon-only buttons in the chrome bar. Pair every icon with a word. Recognition beats recall.

8. Source of truth

When this document and viz.css disagree, viz.css wins — the artifact is ground truth. Open a PR to reconcile.