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).
13 KiB
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-mutewas#5e7081(3.8 : 1) — below WCAG AA. Lighten to#7a8ea3or 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
#000or pure white#fffon 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.5for body,1.4for dense tables.letter-spacing: 0.05emfor 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 notoutline: nonewithout a replacement. - Hover ≠ focus. Hover may add a border-color change; focus must add a ring visible to keyboard users.
- Cursor:
pointeronly 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) getsborder-color: var(--bad)only after first blur. Live red-on-keystroke is hostile.
4.3 Widgets
- Card:
--bg-elevbackground,--borderhairline, 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-mutebody 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
:hoverborder-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-labelor 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.