DESIGN.md@docs · diff
git:20260922.f9f7f12 to git:20260922.711512d
6 added, 1 removed. Audit A to A.
# Squinch — Diagram Design Language
> How rendered diagrams look, and the rules that keep them looking that way.
> Companion to [SPEC.md](SPEC.md) (the DSL) and [ENGINEERING.md](ENGINEERING.md)
> (budgets, verification). Numbers below were tuned against the
> lookbook — but the *rules* are non-negotiable.
## 1. Principles
1. **The default theme is the brand.** Most people will only ever see a screenshot of
the default render. `light`/`dark` are the flagship product surface, not a base to
customize away from.
2. **The app is the frame; the diagram is the art.** App chrome recedes so the canvas
owns the screen (see §10). Diagram themes have their own language, independent of
the app skin.
3. **Uniformity beats fit.** Nodes have fixed heights and snapped width tiers; text
adapts to the box (wrap → ellipsis → hover for full), never the reverse. No
ransom-note diagrams.
4. **Nothing almost-aligned.** Elements share an axis *exactly* or are clearly
apart — a 3px offset reads as a mistake, 40px reads as a decision. Exactness is
enforced where the author asks for it (`align`, `cols`), and positions are
whole pixels everywhere (§8). Note this is deliberately **not** "every position
sits on the 8px grid": node widths step by 40, so half-widths alternate on and
off the grid, and equalising two centres therefore *must* put one of the pair
at a multiple of 4. Exact alignment and gridded positions cannot both hold, and
exactness is the one that matters — snapping to 8 would turn a straight edge
into a 4px dogleg, which is the artefact this rule exists to forbid.
5. **Quiet structure, loud meaning.** Neutrals carry structure; color is spent only on
semantics (status, `highlight`, flows, async). Provider icons supply the color;
our chrome stays out of the way.
6. **Deterministic beauty.** Every rule here produces identical pixels from identical
input.
## 2. Tokens
- **Grid**: base unit `8px`, and it governs the numbers *we* choose: node
dimensions (tiers `120/160/200/240` and `200/240/280/320`, heights `64` leaf,
`96` card, `56` actor), padding, radii, stroke widths, and the `16` minimum
edge stub. Positions are ELK's and are whole pixels rather than multiples of
8 — see §1.4.
Two deliberate exceptions, both container paddings, both holding a label
against a border: zone padding is `28` top / `20` sides, and frame padding is
`44` top. Each is tuned to seat its label, and rounding them onto the grid only
adds slack — 32/16 doubles the zone's top-to-side gap and the boundary reads
top-heavy; 48 pushes a frame's contents down without moving its title. Where
the grid and a proportion disagree, the proportion wins; the grid exists to
serve the drawing, not the reverse.
- **Radii**: `2 / 3 / 4 / 6 / 8` — label pill / chips / notes & leaf plate /
icon tile / nodes, cards and zones. A shape's radius says how big it is,
which is why a chip and a card never share one.
- **Strokes**: `1 / 1.5 / 2` (hairline dividers / edges & node borders / emphasis).
Odd widths get half-pixel alignment (§8).
- **Type scale**: `10.5 / 11 / 11.5 / 13 / 15 / 19` — chip segments & wordmark /
taglines, pills, shelf / header subtitle / node labels / card titles / the
header's diagram name. Weights 400/500, plus 600 for the header name alone.
Labels never bold; hierarchy comes from size and colour, not weight shouting.
One mono face (IBM Plex Mono 400) exists for exactly two jobs — a commit hash
and a zone's `detail:` — where digits have to line up between diagrams.
- **Depth**: a 4% top-to-bottom gradient on every lit surface, over a 1px
contact shadow. It is the whole of the depth system: no elevation ladder, no
blur beyond `stdDeviation="1"`. Two details are load-bearing rather than
incidental. The alpha lives in the shadow's `flood-color` rather than an
opacity, because the adaptive merge only rewrites colour-valued attributes
(§6). And the filter carries `color-interpolation-filters="sRGB"`: SVG
filters default to linearRGB, so a filtered element round-trips through
linear space at 8 bits — near white that costs nothing, near black it
flattens a 4% ramp into a handful of wide steps with a visible cliff. A
subtle gradient and a drop shadow on the same element is exactly the
combination that exposes it.
- **Colour roles**: see `themes/index.ts`, which is the list — every theme
defines every role, and the doc comment on each says what it is for. Diagrams
reference roles, never hex (already a SPEC rule). One role is deliberately
not theme-relative: the brand ramp `#C441FE → #15B6FF`, identical in both
themes as the logo is.
- **Author hues**: the eight `hue*` tokens are the whole vocabulary an author's
`color:` can reach (plus `accent`, the ninth word), each designed as a pair —
darker and more saturated on paper, lifted on the dark canvas — and spaced so
no two collapse at 1.5px. Four of them are the zone tints the kinds default
to; the other four were set beside them. They are a palette, not a
picker: adding a hue is a design decision against both canvases.
## 3. Node anatomy
Every node is the same surface — a rounded rect with the 4% ramp, a hairline
border and the contact shadow — and the parts hung on it say what kind of thing
it is. Nothing carries an affordance it cannot honour: a leaf has no inside, so
it gets none of the marks that imply one.
- **Icon tile**: `40×40`, radius 6, in the neutral `plate` tone, holding the
artwork at `26` — one shell for every kind of mark. The ring of tile is the
point: vendor art is drawn against white in its own guidelines and reads as
pasted on when it touches a gradient. A single-colour mark becomes a
**knockout chip** in the same inset — the chip filled with the mark's own
colour (a trademark keeps its hue; `sys`/`builtin` take the theme's muted
ink) and the mark knocked out of it. The knockout ink follows the chip's
lightness: white on a dark chip, near-black on a light one (JavaScript's
yellow, React's cyan — which is those brands' own usage), by one
deterministic integer threshold. This is what makes the chip legible in both
themes with no per-theme machinery, and it is the same treatment at every
size — tile, shelf chip, and badge are one drawing routine.
- **Leaf node**: height `64`, width snapped to tiers. Tile left-aligned at
padding `12`, label 13/500, and under it one optional line at 11: the leaf's
own `subtitle:` in `faint` — a caption (runtime, owner, region), always
drawn. A description never draws here: it is prose, and one line held about
thirty characters of it before the ellipsis, so it stays the card's tagline
and the hover card's text. A subtitle widens the tier the way a card's
tagline does, which is why the check nudges past 24 characters. An optional
**node badge** (`badge:`) sits on the tile's bottom-right corner: `22×22`,
radius 5, surface fill + border stroke, holding a `14×14` mark in its own
- brand colour, inset so it clears the card edge by 5.
+ brand colour, inset so it clears the card edge by 5. When that colour would
+ vanish into the plate — Kafka's and GitHub's near-blacks on the dark
+ surface, the yellows on white — the mark draws in `ink` instead, by the same
+ deterministic integer lightness threshold as the knockout chip. The plate
+ never changes: it is the inverse of the chip on purpose, so it is the mark
+ that yields.
- **Actor tile** (`person`): height `56`, filled rather than outlined and with
no border at all, holding a `34` round avatar. The human who starts the story
should read as a different sort of thing before the icon is read, and shape
is the fastest way to say so.
- **System card** (collapsed container): height `96`. A `3px` spine down the
left edge in the brand ramp, clipped to the card's own radius — it is the
"divable" mark, containers only. The tile, then title 15/500 and tagline 11
muted, their baselines hung off the tile's centre line. A `26×26` bordered
chip top-right holds the kind glyph, whose column is reserved whether or not
one is drawn. Along the bottom, a `30` **shelf** continuing the gradient's
lower tone under a hairline: child icons at `16`, a `+N` overflow count, and
an optional `domain:` chip right-aligned. The shelf is drawn only when it has
something to hold, and a card without one centres its header rather than
leaving the bottom half empty.
- **Detailed card** (`preview <path>`, SPEC §5): the same card, taller, never
wider. The head keeps its `66`, so the two altitudes of one card line up
head to head in a rank. Under it, one `32` row per previewed child (up to
three), each under a hairline in the shelf's tone: a `22` plate holding the
child's mark at `16`, its name at 12/500 in ink, then its caption at 11
in `faint` on the same baseline — a leaf's `subtitle:`, a container's
`description:`. The shelf stays — `+N more` and the domain chip when there
are any, a bare base when not, so a rank of detailed cards lines up along the
bottom — and carries no chips: the rows have named them. Three rows and the
shelf make `192`, exactly twice the small card. Wires
land on the card, never on a row — the rows are a callout of what is inside,
not a claim about which part a wire reaches (docs/notes/preview-card.md).
- **Stacked sheets**: two outline rects behind every container, offset `4` and
`8` back and down at opacity .8 and .5. "There is more inside", said by the
shape before anyone clicks. They bleed past the card rather than being sized
into it — inflating the node would put ELK's ports on the inflated face and
every edge would stop short of the card it points at — so a corpus invariant
asserts the bleed lands in empty space (`test/invariants.ts`). They are
emitted *outside* the node's own group, because the playground styles the
group's first rect on hover and measures its bounding box for the dive.
- **Context** cards and leaves keep the flat surface and a dashed border: they
are scenery, and scenery is not lit, lifted, or advertised as divable. A
context card keeps a muted spine, so subject and scenery never read alike.
- **`external`** — someone else's — takes a hatched overlay across the whole
card, shelf included: a texture rather than a colour, since colour is already
spoken for, and it has to survive print and a colour-blind reader.
- Three things here are called badges and they are distinct: a **card glyph**
is the identity of a collapsed system, a **flow badge** is a step number on
an edge, a **node badge** is whose platform a leaf belongs to.
- **Labels**: wrap at container width, max 2 lines, then ellipsis; full text on
hover (SPA/VSCode) and in `<title>` (static SVG). Lint nudges labels > ~40
chars, and a second lint names any character the bundled font subset cannot
draw — it renders as a gap rather than a glyph, and nothing else would catch
it (the SVG stays valid and deterministic either way).
## 4. Edge craft (where diagrams are won)
- **Orthogonal, rounded** (radius `8`) by default; `curved`/`straight` per view.
- **Stubs**: an edge leaves and enters perpendicular to the node side, running ≥ `16`
before its first turn. No diagonal escapes from a box edge.
- **Port distribution**: multiple edges on one side spread at even offsets on the
grid — never stacked into a single point. Order chosen to minimize crossings,
deterministically.
- **Crossing hops**: where two edges cross, the minor edge takes a small gap (`6`)
— the strongest "drawn by a person who cared" signal there is.
- **Arrowheads**: filled chevron `8×6`, matched to stroke color; open chevron for
`~>` async. Small, sharp, consistent — never SVG default markers.
- **Async dashes** are `4 7` with round caps — beads on a string rather than
chopped line. Each cap adds half the stroke to a dash, so at 1.5px the beads
read as about 5.5 on, 5.5 off; dotted and `packets` keep butt caps, or a 3px
gap would close. The drift animation's offset must then be a whole number of
dash *periods* (4+7 = 11 — caps do not move the period) or the pattern jumps
each time it loops, so one shared keyframe uses the LCM of the periods it
serves (dashed 11, dotted 5 → 55) and the durations are derived from the px/s
the vocabulary promises. Adding a pattern means revisiting that number.
- **Edge labels**: pill chips (11px, surface bg, radius 2, 1px border) with a canvas
halo — a label never sits raw on a line, and never collides with another chip.
Placement: space is **reserved at layout** — ELK inline labels on cross-rank
edges, sized gutters and lanes on coplanar ones — and the pill draws in its
reservation. A label can never collide or detach; the corpus invariant sweep
enforces it geometrically. History of the placement-search era:
`docs/notes/edge-labels.md`.
- **Aggregate edges** (from lifting): medium weight, neutral, count badge as pill.
- **Animation** (`~>`, and opted-in sync edges): CSS `stroke-dashoffset`,
constant speed in px/s (not per-edge duration — long edges must not "flow
faster"), subtle: motion you notice peripherally, not a marquee.
`prefers-reduced-motion` always respected. The vocabulary stays inside that
bar: `reverse` (a response flowing against the arrow), `slow`/`fast`
(cadence as meaning), `packets` (sparse dashes — discrete messages, not a
stream), `pulse` (the whole edge breathes; a heartbeat or healthcheck), and
`comet` (a `3.5`px dot in the edge colour rides the route — a single request
making its way through, and the only motion available to a plain solid call).
The comet travels at the same constant `150`px/s the dash values hold to, but
it cannot get that from a shared keyframe: duration is length ÷ speed, per
edge. Below roughly `60`px a floor of `0.4`s takes over and a very short edge
runs slightly fast — the one place motion here is not constant-speed, taken
deliberately because the alternative was silently drawing nothing where the
author asked for a comet. One value per edge; anything marquee-like stays
out.
## 5. Containers, zones, notes, chrome
- **Expanded frames**: a recessed surface behind their children, radius `8`,
with a header at their top-left: the container's own mark on a `24` chip
(radius 5, the plate tone under a hairline) and its name in ink — the face
its collapsed card has, kept when the card is opened, so a dive lands on the
thing that was clicked. No tagline and no kind chip: the full page is made
of frames, and one small chip is what keeps a header from multiplying into
container soup (docs/notes/full-detail.md). Depth is one step, not a ladder —
except `expand *`, the one deliberate ladder (SPEC §5), where frames do nest.
Nested frames carry **no fill**, only the 1px border and the label: the zones
argument applies verbatim — surfaceAlt compounds where frames nest, and the
recession must say "opened" once rather than encode depth as darkness
(docs/notes/full-detail.md).
- **Zones** (deployment boundaries): a dashed outline, radius `8`, in the kind's
hue (`account` red, the network kinds blue, `cloud` violet, the rest gray —
or the author's `color:`) — and **no fill, ever**. A tint compounds where zones nest, so a subnet
inside a VPC read darker than either and the boundary's weight encoded depth
rather than kind.
- **Zone chips** straddle their boundary's border and are built from one hue at
three strengths: a square icon tab on a quiet plate, a label bed at 12%, an
optional mono `detail:` segment at 20%, and a 35% border. Segments are flush —
a gap between them shows the canvas through and reads as a mistake. The
`detail:` segment is all-or-nothing: a clipped `10.0.0.0/16` is not a
shortened label, it is a different network, so on a boundary too narrow for
both the segment goes and the name keeps its room. Placement slides along the
border to the spot clear of edges and pills (`docs/notes/note-placement.md`).
- **Flow badges**: a numbered disc in the accent, its ink chosen so the number
reads on it in both themes — the dark theme's bead is a pale lavender, and
white on lavender is unreadable at 10px.
- **Notes**: the same neutral plate as everything else, radius `4`, with a
contact shadow and a dotted leader to their anchor. They open with an 11px
glyph — a circle-i, or a triangle for `style: warning`. The glyph is what
carries "this is commentary, not a diagram object"; an amber fill used to,
and it was the only third hue in a two-hue palette, reading as a sticky note
stuck onto the drawing rather than part of it. Max width `200`, three lines.
Placement: the anchor's own side, sliding past every obstacle (nodes, pills,
chips, badges, the footer band, notes already placed) before standing further
off; a corner note hugs its corner and grows the canvas rather than
overlapping (`docs/notes/note-placement.md`).
### Chrome — what frames the drawing
- **Header**, top-left: the diagram's name at 19/600, an optional subtitle at
11.5, and a meta chip whose segments alternate tints. `version`, `commit` and
`date` are reserved and their values speak for themselves; any other key an
author writes keeps its key beside its value, because `platform` alone says
nothing. Nothing is derived — no git, no clock — because a render is a pure
function of its source (§1.6).
- **Footer**, full width under a hairline: the legend on the left, the `squinch`
wordmark on the right. The legend shows only what the diagram earned — a
drawing with no async edge never explains dashes.
- Both are drawn in canvas coordinates, never inside the body's transform. A
note can push the diagram right and down, and chrome that rode along would
slide with it.
- A diagram with no title, no legend and no titleblock gets no chrome at all.
## 6. Themes
Every theme is the full token set of §2 — never a palette swap on top of light.
- **`light` / `dark`** — the shipping pair, and the whole set. Near-neutral
surfaces, restrained accent, AAA-contrast ink. Dark is designed, not inverted:
icon plates lighten, borders drop contrast, canvas is near-black not gray.
- Meaning never rests on hue: async is dashed with an open chevron, context is
dashed, zones are dashed and kind-tinted, boundary crossings change colour
*and* keep their pattern. Colour is reinforcement; shape and pattern are the
encoding. That rule is what made a dedicated high-contrast theme redundant.
An author's `color:` (SPEC §3) lives inside the same rule: it paints the part
of an element that is already its mark — a node's spine, an edge
stroke, a zone outline — and never replaces a shape or a pattern, so a
coloured diagram read in greyscale still says everything it said in colour,
minus the emphasis.
- Retired with the docs/design restyle (2026-08): `sketch` / `sketch-dark`
(rough.js + hand-lettered Caveat) and `contrast` (WCAG-first). The restyle's
card anatomy — gradient ramps, contact shadows, stacked sheets, the segmented
chip grammar — has no hand-drawn or pure-black translation, and three
unreviewed palettes riding every geometry change cost more than they returned.
A new theme is a design exercise against docs/design, not a token swap.
## 7. Design references
Study: Stripe docs diagrams (restraint, edge craft), AWS official reference
architectures (zone framing, icon discipline), Linear (geometry, spacing rhythm), Vercel/Geist
docs (typography in technical drawings), Railway's canvas (dark-theme depth).
Failure modes to design against, wherever they appear: auto-fit box sizing (the
ransom-note effect), unmanaged edge crossings and spline chaos, container soup at
deep nesting, gradient-era chrome.
## 8. Rendering crispness
- Odd stroke widths on half-pixel offsets; positions and sizes integer after layout.
- `shape-rendering: geometricPrecision`; consistent join/cap (`round`).
- Text: bundled font, `text-rendering: optimizeLegibility`; no synthetic bold.
Exports embed the subsetted faces via `@font-face` data-URIs, so text renders
identically in sandboxed viewers (GitHub `<img>`) and on machines without the
font installed — what layout measured is what every viewer draws.
- Export parity: SVG and PNG (resvg) must be visually identical; PNG at 2x default.
- **The interactive export carries the altitudes, not just one of them.** A
single self-contained HTML file with every view pre-rendered and the same
anchored dive between them (§11) — because a diagram you can only zoom in the
playground makes the view system unshareable. Shared defs are hoisted once, so
the file is the drawings plus one font, not one font per drawing.
## 9. Quality gates (how "very good" stays true)
- **The lookbook**: ~15 curated reference diagrams (small, dense, deep-nested,
zone-heavy, flow-heavy, worst-case labels) rendered in every theme. Each release is
eyeballed against it; snapshots lock it. The lookbook is the beauty bar the same
way the agent gauntlet is the correctness bar — and it ships in the repo as the
example gallery, so the marketing *is* the test suite.
- **Beauty checklist** for renderer PRs: no near-misses, no label collisions, no
port pile-ups, no raw label-on-line, stubs respected, recession correct at 3 deep.
- **Anti-regression**: any lookbook diff must be an intentional, named improvement
(`label: "tighter-ports"`), never drift.
## 10. App chrome (SPA / VSCode webview)
Direction: **precision instrument** — Linear/Geist-grade professional, not playful,
not loud. The bar: a staff engineer screenshots the whole app and it looks like a
product that costs money.
- Near-neutral chrome (grays with one restrained accent), crisp 1px borders, subtle
depth, no decoration that competes with the canvas. Dark and light both first-class,
each matched to the diagram theme in view.
- The canvas is the hero: chrome occupies edges only (slim toolbar, collapsible
editor pane, floating breadcrumb + tag chips); every panel is dismissible to a
pure-canvas mode. Canvas gets a barely-there dot grid.
- Typography: same bundled sans as the diagrams for wordmark/UI labels, mono for the
editor — the app and its output visibly share one type system.
- Details that read "expensive": real keyboard shortcuts surfaced in tooltips, Cmd-K
everywhere, buttery zoom/pan (trackpad-native), exact-pixel icon alignment, an
empty state that renders a beautiful example diagram instead of a blank pane.
- shadcn/ui stays as the component base — themed to this language, not its defaults.
## 11. Altitudes on screen
Views are altitudes over one model, so moving between them is navigation, not a
slide change — and the chrome has to say so.
- **Changing altitude is animated about the card you moved through**, the one
element the two views share, so the reader never has to re-find their place.
One motion, no picker; `prefers-reduced-motion` cuts straight through. The
geometry and everything rejected on the way: `docs/notes/zoom-transitions.md`.
- **The canvas is a camera, and it is the same camera everywhere.** Drag pans;
a wheel or two-finger scroll pans; pinch or Ctrl/⌘+scroll zooms about the
cursor; `+` `−` `0` `1` and Shift+arrows do it from the keyboard. The
playground and the interactive export run one controller, so they cannot
drift into two feels. A view opens as it always did — width-fitted, never
scaled up, from the top when it is tall — and **every view arrives fitted**,
whatever was done to the one before: the dive starts from exactly what the
reader was looking at and lands framed. Only a slide is drawn larger than
life. `docs/notes/pan-zoom.md`.
- **The way back is always on screen.** A breadcrumb of the ancestor trail, every
hop clickable, in the editor and in presentation alike.
- **Presentation mode is the same views, full-bleed**: the declared views become
the deck in declaration order, arrows step, clicking a card still zooms in and
the deck follows. Nothing is authored twice. Chrome auto-hides while idle and
returns on the first movement.
- **A flow is a story, and stepping tells it**: one arrow key walks the current
view's flow hop by hop before moving to the next view, and unwinds the same
way in reverse. The live hop takes the accent and the heavier stroke, hops
already told recede, and anything the request has not reached is dimmed.
Hops are counted **as seen in this view**, never by the flow's declared
numbering — a flow that begins two systems away has its opening steps lifted
out of a scoped view, and counting declared numbers there spends the first
presses on frames where nothing happens. Badges still read their declared
number: that is the flow's real shape, and it is what the reader is being
told about.