Status: shipped in v0.6.0; kept as the living reference. A non-behavioral visual system for the canvas: node shape, colour, edge style, badges, state cues, zoom levels, motion, and light/dark. It says how the graph looks and how a visual cue maps to a model state — it does not define what any model state means or how the engine computes it.
The Canvas Visual Refresh landed in three PRs — chrome / silhouettes / states,
then edge class / direction / cues + motion, then zoom LOD + the
acceptance matrix. The v0.6.0 scope is the look of edges and the elision
of detail, not edge geometry: edge paths stay on React Flow's Bézier route
(getBezierPath) and orthogonal routing is deferred (§VL6 "Routing"). The
sections below describe the shipped behaviour; anything still deferred is marked
so inline. Every semantic reference points out to a spec
(→ SEMANTICS-*.md, → loop-model/1, → loop-expr/1). Carries no loop-*/N
id; revised freely. §VL10–VL11 are the invariants against the GraphDoc /
offline posture; §VL12 the acceptance criteria; §VL13 the decision record;
§VL14 the order this feeds into.
This formalises and extends the N1 "Vessel" system already on the canvas
(src/components/nodes/nodes.tsx, src/components/edges/LoopEdge.tsx,
src/index.css). It is written so the later Canvas Visual Refresh slice can
bring every existing node/edge onto one grammar in a single pass.
Parameter / Register visuals are live.
loop-expr/1andloop-model/1are Frozen (SEMANTICS-X.md,SEMANTICS-M.md) and the model-language implementation has shipped, so the appearance and state representation of Parameter and Register nodes — their silhouettes (§VL2.1), the in-node layout (§VL2.4), theinvalidtreatment (§VL3), the display-grouping note (§VL5.4) — are normative and rendered on the real canvas. They are first-class in the acceptance matrix (§VL11.2 / §VL12), no dev flag. Rendering a Parameter or Register — like any other node — changes no GraphDoc byte,loop-revision/*digest, undo entry, or viewport (§VL10).
The workbench design tokens (2026-10). The token set was designed anew from the reviewed workbench mock-up (share notes 93–95, dark and light) and replaced the earlier one, and the Cozy Shelter family layer, outright; only the user's System / Light / Dark choice was kept. One set of meanings, with a value per theme, in
src/index.css:
Group Tokens surfaces --bg-app--bg-bar--bg-panel--bg-head--bg-raise--bg-hover--bg-canvas--bg-field--bg-overlay--bg-node--label-bglines --line-1--line-2--line-3--line-control--line-control-hover--line-disabled--grid--chart-gridink --ink-1…--ink-3,--ink-disabledaccent (what runs, what is primary) --accent--accent-soft--accent-trail--accent-glowselection and focus --sel--sel-soft--sel-name--focus--focus-halostatus --ok--no--warnnode kinds --k-pool--k-source--k-drain--k-converter--k-gate--k-end(Parameter and Register carry no kind hue)connection meanings --m-flow--m-condition--m-change, their-hiforms,--dim-opacityframes, data, shadows, radii --frame-*,--dist-band-mix,--shadow-*,--radius(8 px)--control-radius(5 px)Three values were CORRECTED from the mock-up, each measured in its comment: the light faint ink
--ink-4(3.98 → 5.01:1 — no rule ever read it, so it was removed on 2026-10-21 with--chart-axis,--accent-ink,--pos,--k-paramand--k-register, all unread),--ink-disabledin both themes (2.4–3.4:1 against a live label's 9.6–15), and light--gridwith--dim-opacity(dimmed-line-to-grid 1.21 in light against 1.19 in dark). A control boundary keeps the product's ≥ 3:1 contract (--line-control), which the mock-up's--line-2would not meet. Each theme declarescolor-scheme. Token names and contrast figures elsewhere in this document belong to the earlier set and are kept as the record of that work;e2e/shell-function-colours.spec.tspins the current values.
- Shape carries role; colour classifies; motion says "now". The outer
silhouette tells you what a node is. Type hue appears only in small
areas — a chip, a compact dot, a chart mark — never as a fill. The
mineral signal (
--signal-primary) is reserved for what is alive this step: flow in transit, a node that acted, a value that just changed. - One cue per meaning. Selection, focus, "acted this step", "blocked", "invalid", and "conflict" are visually distinct and never share a colour or a motion. A node can show several at once without ambiguity.
- Legible without colour. Every state that matters is also carried by shape, line style, an icon, or a position/label change, so the canvas reads under any colour-vision deficiency and in greyscale.
- Detail follows zoom. Zoom out and the canvas sheds supplementary detail in a fixed order (§VL7) so a large graph stays scannable; it never rearranges, and it never hides the required set — role silhouette, edge class + direction, selection/focus, error/conflict/blocked flags, run-in-progress, and the accessible name + hit target (§VL7.1).
- Same information hierarchy in light and dark. The two themes differ in surface and shadow, not in what is emphasised (§VL8).
- Calm by default. Motion is short, purposeful, and fully suppressible (§VL9). Nothing loops or pulses while the simulation is paused.
- Background
--surface-canvas; an optional dot/line grid at a fixed world spacing, drawn in--line-hairline, that fades out below §VL7's L2 zoom. The grid is a visual aid only — it does not snap or constrain positions (position is authoritative graph data,→ SEMANTICS-R.md §R4). - Alignment & spacing helpers (align edges, distribute, match gaps) act on
the current selection and are undo-tracked like any move. They change
positiononly; no automatic layout runs behind the user's back. - Charts / analysis (timeline, distribution) sit as floating cards over
the canvas corner, not as a docked region — dismissible, movable, with the
same surface (
--surface-panel) and elevation as a dialog. A card names the data it shows and the step range.
The default node structure now draws each kind as a small line SYMBOL, the value
inside, the name outside (src/components/nodes/symbol.ts, nodes.tsx
NodeFrame, nodeStructure.css). The vessel silhouettes below remain only for
the previous structure (?nodes=inside, one page load).
- The cell. Every kind occupies a 56 × 56 cell at the node's stored position,
its symbol centred at (28, 28). That centre is the old port row (
PORT_ROW), so a document's positions, straight lines and user bends are kept and no node is moved;LAYOUT_VERSIONis unchanged. A symbol larger than the cell (the gate's diamond, 62; the register, 68 wide) overflows it; the router keeps lines out of the symbol's own box. - The shapes. Approved: pool — a circle, r 28; gate — a diamond, 31 to each
point; converter — a flat hexagon (points at ±30, flat sides ±16, height 44);
parameter — a 52 px square, radius 5, with a baseline under the value;
register — a 68 × 44 readout, radius 4, its value read
= …. Confirmed on the product screen (2026-10-20): source — a triangle pointing down; drain — a triangle pointing up (the mock-up had none); end — an octagon. The outline is--node-stroke, 1.6 px. - The small marks. The tool row's Add buttons and the left list's rows
share the canvas symbols' base outlines (
KindIcon.tsx, from the sameSYMBOLS): 16 px, a 1.3 px stroke at that size, mitred corners; the parameter keeps its baseline; no value, name or inner role mark. Only the small end icon adds an identifying square: inside its octagon (sides on pixel edges), a small filled square — an aid to tell it from the pool at 16 px, used in the tool row and the list only; the canvas end stays a plain octagon. At 16 px on a 1× screen every outline-only octagon measured read as the pool's circle (Lumi approved the square, 2026-10-21). Before that day the Add buttons were+ - =text for source, drain and register, and an end was a circled ×. - The ends. A connection ends on the symbol's outline straight out from the
centre: left and right on the port row, top and bottom on the centre column.
A face's resource and state ports share that point; while a connection is
dragged, the dragged kind's handles are on top there, so a drop reaches the
port of its own kind.
routeMap.portPointand the DOM handles read the same anchors. - The value. Pool, parameter and register show their number inside, 600 16 px (15 px in the readout), in the document's own format and precision. A value wider than its symbol is elided there — never shrunk (the overflow rule of 2026-10-12: space changes neither precision nor size) — AND written in full on the line beside the symbol, so no value is only cut short. The tooltip carries the value with its capacity or unit.
- The aux line. What a value means and the symbol has no room for — a pool's
capacity (
≤ 50), a parameter's or register's unit — is a small line beside the symbol, above the name, never elided. It is pinnedltrlike the value row it continues, and the unit takes its own direction (dir="auto",docs/localization.md§L9.3). - The label. The aux line and the name are one box, 12 px from the symbol on
its face (below unless a connection uses that face): past every state ring
(selection 4.5 px, invalid 8.5 px, the fired ticks 9–11 px), so no state mark
meets the name. The name is 500 13 px in
--ink-2, at most 136 px and two lines; a selected node's is 600 in--sel-name.
viewBox 120×64, vector-effect: non-scaling-stroke. The path encodes the
role; it is filled with --surface-raised (light) and outlined in
--line-strong.
| Kind | Reading of the shape |
|---|---|
| pool | a tank — flat-ish top, tapered sides (holds a quantity) |
| source | a wedge pointing out (produces) |
| drain | a wedge pointing in (consumes) |
| gate | a diamond (routes / decides) |
| converter | a bow-tie (in one side, out the other) |
| end | a rounded capsule (terminal) |
| parameter | a small rounded tag with a notched left edge — a knob you set. Non-colour tell vs register: the notch + a short stub "handle" on the notch side, and no =; the value sits in a slot, not after an equals sign |
| register | a narrow lozenge with a leading = glyph — a readout, not a container. Non-colour tell vs parameter: the = is always drawn (struck through when invalid, §VL3), the outline is a single unbroken lozenge with no notch and no stub |
Parameter and Register are visually lighter than the flow nodes — smaller,
thinner stroke, no resource/state handles (§M1.3 / §M2: they have no ports and
are never edge endpoints) — to read as annotations on the model rather than
stages in the flow. The two are told apart without colour: the parameter's
notch-and-stub silhouette vs the register's plain lozenge, and the register's
persistent = glyph vs the parameter's slotted value. Neither shares a
silhouette with any flow kind.
Top to bottom, shown/hidden per §VL7:
- Title — the label. One line, ellipsized.
--text-primary. - Primary value — the number the node is "about" while a run is live:
pool count, source/drain amount this step, gate split, register result.
Large, tabular figures. Absent when no run is live (
→ SEMANTICS.md). - Capacity / bound —
n / capwhen a pool has a finite capacity; a thin fill bar under the value tracksvalue ÷ cap. Uncapped pools show the value alone. - Key expression — for nodes that carry one (register always; provisional
for gate/source/converter once
loop-expr/1is adopted there), a single monospace line of the expression as written, ellipsized, in--text-tertiary. It is display text, not evaluated here. - Type chip — a 10–12 px pill in the node's hue with the resource-type
name (§VL5). One chip per node. A node with a flow colour (issue #325,
docs/flow-colour-and-compact-nodes.mdFC-4.1) shows that colour on the chip, the faint hue wash and the L0 dot instead of the kind hue; the silhouette still carries the kind.
Resource ports are the side circles (out right, in left); state ports are
the top/bottom state-* handles. Handle affordance (size, hit area, hover
halo) is unchanged from today; this doc does not touch connection behaviour.
Parameter and Register have no handles at all — no resource port, no state
port, no drag-to-connect affordance on any edge — because the model gives them
no ports and forbids them as edge endpoints (→ SEMANTICS-M.md §M1.3, §M2).
They participate only by being referenced from an expression.
Fixed against the frozen model spec. Both use the §VL2.2 top-to-bottom order, but only the rows below ever apply to them.
Parameter (→ SEMANTICS-M.md §M1):
- Title —
data.label. One line, ellipsized. - Value —
data.value, the single semantic field: a finite number, large tabular figures, shown whether or not a run is live (it is a constant, not a per-step reading — §M1.1). A read-time-filled default reads0with the Inspector'sPARAM_VALUE_FIXEDnotice; the node itself shows no error cue — a Parameter is neverinvalid(§M1.1), so it carries no struck-=, no—, no warning outline for its own state. - Unit —
data.unit(already trim/NFC/≤24-byte normalised by the model, §M1.2), a short suffix in--text-tertiarynext to the value (4.5 gold). Absent unit → value alone. - Advisory-range tell — when
min/max/stepare present and coherent, the value slot gets a thin tick scale underlay (min→max) with the current value marked; this is decoration of an advisory hint, never a clamp. When the storedvalueis outside[min, max]the mark sits at the clamped end of the scale and a small▲!-style advisory dot appears on the value (the Inspector showsPARAM_VALUE_OUT_OF_RANGE); the number itself is still shown as stored, unclamped (§M1.2). A hint the model dropped at read time (PARAM_STEP_INVALID,PARAM_RANGE_INVALID,PARAM_UNIT_TOO_LONGafter truncation) produces no canvas cue — only the Inspector notice. - Type chip — §VL5.4 (display grouping only).
No capacity bar, no expression row, no primary-value-from-run row.
Register (→ SEMANTICS-M.md §M2, §M3):
- Title —
data.label. - Computed value —
R(currentStepIndex)(§M3.5): the evaluation of the Register against the current committed snapshot. Large tabular figures, rendered throughdata.format(introunds for display,floatas-is,percentshowsvalue × 100 %) — display only; the digested value is the raw number. Absent when no run is live, exactly like a pool's primary value. - Invalid state — when the model reports the Register
invalid(§M3.4): the value area shows the neutral placeholder—, the leading=glyph is struck through (the §VL3invalidlayer), and the outline takes the error colour (--no, since 2026-10-21; it was the warning colour). No number is shown — never0, never the last valid value (§M6.2). The Inspector carries the enumerated model code (M_REG_PARSE·M_REG_EVAL·M_REG_UNKNOWN_REF·M_REG_WRONG_KIND·M_REG_INVALID_ID·M_REG_CYCLE·M_REG_DEPENDS_ON_INVALID) plus a message; the canvas shows only the placeholder + struck=(no code text on the node). - Expression —
data.exprinloop-expr/1§X8 canonical form, one monospace line in--text-tertiary, ellipsized, as written (never evaluated or rewritten for display; a dangling@idafter a delete stays visible — §M5). This is the §VL2.2 "key expression" row; for a Register it is always present (default"0"). - Unit —
data.unit, same placement as Parameter. - Type chip — §VL5.4.
Timeline — a step index where the Register is invalid is a gap: no
series point, not a 0 and not an interpolated segment (§M6.2). When the
Register recovers, the series resumes at the next index with no bridge across
the gap. The timeline card marks the gap span with the same struck-= glyph in
its legend row so the gap is not misread as missing data.
No content change from rendering. Drawing either node — resting, invalid, mid-run, at any zoom — mutates no GraphDoc field and moves no digest, undo entry, or viewport (§VL10, VL-INV-1…6). A content change happens only when the user actually adds or edits a Parameter / Register (VL-INV-6).
Each is an additive layer over the resting node. Colours are from
--state-*; every state also has a non-colour tell.
| State | Colour cue | Non-colour tell | Motion |
|---|---|---|---|
| resting | — | — | none |
| hover | --line-strong → slightly darker |
1 px outline grows | none |
| selected | --state-selected ring |
2 px ring, offset 2 px — drawn 2.5–4.5 px outside the silhouette, never a recolour of the outline (docs/flow-colour-and-compact-nodes.md FC-4.1) |
none |
| focus (keyboard) | --state-focus ring |
dashed ring, the innermost layer: 4.5–6 px inside the silhouette, inside the selected ring and inside a flow-colour band | none |
| acted this step ("fired") | --state-fired edge glow |
a corner tick mark appears for the step | one 320 ms fade-in, then hold until the next step |
| value changed | --state-fired on the number |
▲ / ▼ glyph beside the value | number slides up/down ~320 ms (→ §VL9) |
| arrival (a pool just received) | --state-arrival fill pulse at the in port |
a small inbound chevron | one 320 ms pulse |
| blocked (acted-but-nothing-happened, e.g. a gated-closed passive) | --state-warning outline |
a hollow ⃠ badge, top-right | none — steady until state clears |
| conflict (Review: this element differs base vs proposed vs yours) | --warning hatched outline |
a ▲! badge; the Review panel lists it | none |
invalid (a Register the model reports as invalid — parse / evaluate / unknown or wrong-kind / unreferenceable-id / cycle / depends-on-invalid, → SEMANTICS-M.md §M3.4, SEMANTICS-X.md §X7) |
--warning solid outline |
the leading = glyph struck through; value shows — (no number — never 0, never the last valid value, §M6.2); the enumerated M_REG_* code + message in the Inspector; a Timeline gap at every invalid step index |
none — steady until the model clears it |
Stacking: rings (focus inside selected) < step cues (fired glow, value slide) < persistent flags (blocked, conflict, invalid badges, always top-right, stacked downward in that order).
Spatial order of the rings (issue #325, docs/flow-colour-and-compact-nodes.md
FC-4.1), from the inside out: the dashed focus ring, the flow-colour band (only
when the node has a flow colour), the neutral structure line on the silhouette,
the selection ring, and the dashed invalid ring outermost. Each is cut from
the silhouette at a fixed pixel distance, so all of them show at once and none
paints over another.
The invalid state applies only to Register nodes (→ SEMANTICS-M.md §M3). A Parameter is never invalid (§M1.1): a bad value is read-time
filled to 0, and a bad advisory hint (min/max/step/unit) is dropped
with an Inspector notice only — neither puts any error cue on the node.
selected / focus / conflict (Review) still apply to both kinds normally.
Run controls do not restyle nodes. running vs paused vs ended is
shown by the run bar and the timeline head, not by the nodes.
Small, fixed-size chips attached to a node corner or an edge midpoint. At most
one badge per corner; overflow collapses to a ••• chip that expands in
the Inspector.
| Badge | Where | Shows | Style |
|---|---|---|---|
| value / capacity | in-node (§VL2.2) | n or n / cap + fill bar |
not a chip — part of the node body |
| expression | in-node, or edge midpoint for edge exprs | the source text, ellipsized | monospace, --text-tertiary, no border |
| flow | resource edge midpoint | the flow amount as written (2, 1-3, 2D6) |
solid chip, --edge-resource text on --surface-canvas |
| probability / condition | state edge midpoint | ✳ trigger, ≥… activator, ±… label — the expr as written |
dashed-border chip |
| warning | node top-right | ⃠ blocked · ▲! conflict · struck = invalid |
see §VL3 |
| resource-type mismatch | edge midpoint | a small ⚠ + the two type names on hover | advisory only — the edge is not dimmed or blocked. When a pairing counts as a mismatch is loop-model/1's call, not this doc's (§VL5.3) |
Badges never overlap the silhouette outline; they sit just outside it.
→ loop-model/1 will define resource types — the field structure, how a
mismatch is judged, and whether/how a type is carried in the canonical
projection (→ loop-revision/2). This doc fixes none of that. Here, given
that a resource type exists on an element, the visual rules are:
- it drives colour, icon, legend, and Inspector warnings;
- it does not change any number, delete a connection, or block a run;
- it is advisory in v0.6.0 (VL-D3).
| Type | Hue token | Icon (line, 12 px) |
|---|---|---|
| Gold / currency | warm amber | coin |
| Energy / stamina | teal-green | bolt |
| XP / progress | violet | upward chevrons |
| Player / population | blue | person |
| Item / stock | neutral brown | box |
| (untyped) | --line-structure |
— |
Hues are low-chroma, chosen for ≥3:1 against --surface-canvas and mutual
distinctness under deuteranopia/protanopia; the icon is the primary carrier
so the set works in greyscale. The exact palette is tuned in the Refresh slice
against the accessibility checks in §VL8.
- Node type chip (§VL2.2) — hue fill + name.
- Resource edge — a 2 px inner stroke in the type hue alongside the
--edge-resourceline; the arrowhead takes the hue. Untyped edges are unchanged. - Legend card — a floating card listing the types present in the graph with counts; toggled from the toolbar.
- Chart series — a pool's series takes its type hue where unambiguous.
What counts as a mismatch is defined in loop-model/1, not here. When the
model reports one for an element, the canvas shows the resource-type
mismatch badge (§VL4) at that edge and the Inspector lists it; nothing else
changes — the line keeps its normal weight, the run proceeds. The Inspector's
mismatch list is presented in a stable order so its rendering is
deterministic (§VL10); the ordering key is a UI choice, not a wire rule.
Parameter and Register nodes may carry a type for display grouping only —
the type chip's hue + name (§VL5.1), nothing more. They have no ports and never
sit on a resource edge, so §VL5.3 mismatch does not apply to them and the model
emits no finding for them (→ SEMANTICS-M.md §M4.1: resourceType lives only
on a pool's data and a resource edge's data). If a build stores a type on
one of these nodes it is rendered as the chip and otherwise ignored.
| Class | Line | Arrowhead | Selected | Label |
|---|---|---|---|---|
| resource | solid, --edge-resource or its flow colour, 1.5 px (+ type inner-stroke, §VL5.2) |
filled triangle | keeps its colour, 2 px, over a translucent --state-selected underlay |
flow chip (§VL4) |
| state | dashed, --edge-state or its flow colour, 1.25 px |
small open triangle | keeps its colour, dashed 2 px, over the same underlay | ✳ / ≥… / ±… chip |
A flow colour (issue #325, docs/flow-colour-and-compact-nodes.md FC-4.2) is
the edge's rest colour on its line, its arrowhead and a 1 px border on its
label. Selection is the underlay, never a recolour; a satisfied activator keeps
its --signal-primary stroke over the colour, a route-invalid edge keeps its
--warning dash and flag, and run tokens keep their own colours.
| dependency hint (Review: "removing this node also removes/retargets this edge", → SEMANTICS-R.md §R7A.3) | dotted, --warning | none | — | — |
- Live flow — while a resource edge carries flow this step, a bead travels
source→target along the path in
--flow-strength, leaving a fading--flow-trail.prefers-reduced-motion(§VL9): the bead is replaced by a static highlighted segment near the target end and the amount chip. - State effect — a trigger fire / activator flip / label apply pulses the
edge once (
--state-guide), matching the node's step cue. - Routing — not in v0.6.0. Edges stay on React Flow's bézier path
(
getBezierPath). Orthogonal routing (right-angle segments that step around nodes, rounded corners, parallel-edge fan-out, optional manual waypoints) is its own spec-first project — seedocs/edge-routing.md(DRAFT). It is a pure render concern and must never editsource/target/ handles.
The canvas has three detail levels, switched at fixed world-zoom thresholds —
0.8 (L2 ↔ L1) and 0.45 (L1 ↔ L0). Elision only hides supplementary
detail. At every level a node keeps its position, its size, and its
silhouette, and an edge keeps its class and endpoints — a zoom change is a pure
fade of hidden elements, never a resize or a re-route (§VL12.5). The switch is a
pure function of zoom: a threshold round-trip restores the prior state exactly
(no hysteresis).
VL7.1 The required set — never hidden at any zoom
- role — the kind-distinguishing silhouette;
- edge class + direction — solid vs dashed, and the arrowhead's direction (its decoration may simplify, its orientation may not);
- selection & keyboard focus — the rings (§VL3);
- error / conflict / blocked — the
invalid/▲!/⃠flags (§VL3); - run-in-progress — the step "fired" glow and the flow bead;
- accessible name & hit target — the node's name is on the element for AT and its clickable area does not shrink below the L2 footprint.
| Level | Zoom (approx) | Nodes also show | Edges also show |
|---|---|---|---|
| L2 — detail | ≥ 0.8 | title, value, capacity bar, key expression, type chip, all badges | flow / condition chip, type inner-stroke |
| L1 — compact | 0.45–0.8 | title + value; type as a dot | flow / condition chip, type inner-stroke |
| L0 — map | < 0.45 | type dot; the outside name (symbol nodes) — no value, no aux line | type inner-stroke only |
Grid fades out entering L1.
A line's value at L1 (Lumi, 2026-10-20). The flow / condition chip shows
at L1 as well as L2: a weight, a +1 or an end condition is what the model
says, and a whole-diagram fit of an ordinary example lands at L1. Like a name,
it shrinks with the view — no minimum type size, no chip dropped because it
overlaps. It hides at L0. The router reserves a chip's rest size, which the
chip reports while it is drawn (docs/edge-routing.md §ER14.3), so a document
opened at L1 now has its chips' room reserved from the start.
The outside name at L0 (symbol nodes, Lumi 2026-10-20). The name stays at
L0. It shrinks with the view like everything else on the canvas: no minimum
type size, no name dropped because it overlaps; reading it closely means
zooming in, as reading a value does. A whole-diagram fit (View ▸ Reset view)
that lands below 0.45 therefore keeps every name on screen, small. The bundled
examples keep their names, values and lines apart by their own layout; a
complex user model is not promised to be free of overlaps at every zoom. Under
?nodes=inside (the previous body) L0 is unchanged: no text.
Parameter / Register at each level. Same rule as every other kind — the §VL7.1 required set never drops:
| Level | Parameter also shows | Register also shows |
|---|---|---|
| L2 | title, value + unit, advisory tick scale, type chip | title, computed value, expr line, unit, type chip; struck = + — when invalid |
| L1 | title + value | title + computed value (or —); struck = kept when invalid |
| L0 | silhouette only (notch + stub), type dot | silhouette only (lozenge + =), type dot; the = stays struck when invalid |
At L0 both keep: the role silhouette (parameter's notch-and-stub vs
register's plain lozenge — the non-colour tell survives), the invalid
struck-= / --warning outline on a Register, the selection / focus rings,
and any run-in-progress cue. Only text — the number, the unit, the expr
line, the Inspector code — is elided. Neither node resizes or re-routes at a
threshold (they have no edges to route); their footprint and hit target are
byte-identical across L2/L1/L0 (§VL12.5).
- Tokens only. Every colour on the canvas is a
--*token defined for:root(light) and redefined under the dark blocks — no literal colours in component styles. The Refresh slice audits this. - What changes between themes: surface (
--surface-*), line weights' apparent contrast, and elevation (light uses a faint drop shadow; dark drops it and leans on surface contrast). What does not change: which element is emphasised, the hue identities (a Gold edge is recognisably the same hue in both), and the state→cue mapping. - Colour roles (2026-10-21, the colour review's direction A, first
step). The run colour (
--accent, teal) is for the controls that start or stop a run — Play / Pause (.pb-btn--primary) and the Monte Carlo dialog's Run (.btn--run) — and for what a run shows (beads, pulses, a satisfied condition, the timeline guide). A control that only opens, chooses or sets something is neutral: a dialog's primary button that does not start a run (.btn--primary: the ink boundary, its place), the run bar's Monte Carlo button (it opens the dialog), the chosen tab, view and toggle (the underline / pressed face in ink), the speed slider and the dialog's checkboxes. Connecting a port is interaction — the selection blue. The brand mark (logo, toolbar mark) has its own--brand, at the value it always showed. The run chart's series have their own--series-1…6, at the kind hues' values and order they used to read; node kinds keep--k-*(node wash). Colours a user or an example stores (a frame's colour, a flow colour) are not theme roles and are unchanged. Text on these surfaces is held to 4.5:1 by value, not by hue: the light run colour and success colour were darkened in their own hue, a coloured frame's title is written in ink (the colour stays on its border and outline), and text on the solid focus fill is--on-focus(white in light, the page's dark in dark). By meaning (step 2, 2026-10-21): an error — a wrong input, a failed action, a computation that gave no number, a node that cannot be read — is--no; a warning — a state to notice or check (a route point in a clearance, a stale result, a dirty revision, a temporary session, a refused connection's note) — keeps--warn; a normal state of a run (a condition not met, a blocked move, a change clamped by a capacity) and a guidance note are a strong neutral (--ink-2/--ink-3— the colour is not a faded one; a cue that already animated keeps its animation, e.g. the not-met condition's ring still plays its 260 ms ping, opacity 0.9 → 0.35, on the step's arrive beat; that ring has its own--cue-unmet— the tertiary ink in light, a lighter neutral in dark, chosen so that at every opacity of the ping it is never below the warning colour it had before); a destructive control (a frame's delete) is neutral,--noon hover / keyboard focus. Each keeps its words, marks, hollow ring and numbers. (Found after that survey of the--warnrules, 2026-10-21: the Model panel's error code under an invalid Register — anyM_REG_*code, a dependency on an invalid Register included — is--no; the row's "no value at step N" keeps the row's ink.) Area and control marks (step 3, 2026-10-21): none of these is a run, so none is teal — a Neutral frame (no colour chosen) is a grey wash (--ink-3@ 0.05, the opacity it had); the selected frame's resize handle is a strong neutral (--ink-2— it sits on the selected frame's blue dashed outline, and in the selection blue it read as part of that outline; its keyboard focus ring is unchanged); the guided tour's spotlight ring is--ink-1(the scrim does the emphasising). A frame's chosen colour is unchanged. - Contrast is held to the numeric floors in §VL11.2 and checked automatically (§VL12.10).
- Non-colour redundancy is mandatory for: resource vs state edge (solid vs
dashed), every §VL3 state (each has a shape/icon/motion tell), resource type
(icon + chip text, not hue alone), and parameter vs register (notch-and-
stub silhouette vs plain lozenge; the register's persistent
=glyph, struck wheninvalid) — distinguishable in greyscale, under simulated deuteranopia / protanopia / tritanopia, and underforced-colors: active(the shape, the dash-free outline, and the=/ struck-=all survive a UA colour override). - Focus is always a visible indicator distinct from selection; tab order
follows reading order. On the canvas it is the
--state-focusring (§VL3). In the shell it follows the family contract (Cozy Shelter tokens v1.1.0 §3), and the class is decided by whether the control normally shows a boundary:- A — a visible boundary (bordered buttons, the play-bar buttons, every
bordered input and select, the view tabs, the mean toggle, the
expression operator keys): the boundary itself takes
--line-focus, a 3 px--focus-halosurrounds it, and there is no outline. The solid boundary carries the 3:1 obligation — measured 3.78–4.40 light, 5.14–7.55 dark. The halo is an 18 % tint, 1.25:1 on its own, and is never the indicator. - A-clipped — the same control inside a box that clips (the series trigger and CSV in the one-row Timeline legend): an outer halo would be cut, so it is dropped and the solid indicator is thickened inward.
- B — no visible boundary (ghost buttons, palette chips, menu and sheet
rows, links, checkboxes, table cells, the reference-insert pill): the opaque
2 px
--line-focusoutline of the global:focus-visiblerule. When in doubt, B. - Under
forced-colorsthe halo is dropped by the UA, so every selector that saysoutline: noneis repeated with the same specificity to restore a realHighlightoutline (inside the box for the clipped pair).--focus-ring/--line-focusis the SOLID colour (--cs-accentin light, Loop's own#83a9e6in dark). The family's--cs-focus-ringis the halo and must not be aliased onto it.
- A — a visible boundary (bordered buttons, the play-bar buttons, every
bordered input and select, the view tabs, the mean toggle, the
expression operator keys): the boundary itself takes
- Shell tokens. The light shell reads the family's
--cs-*layer through Loop's own names: ground and sunken (one soft page surface — no warm grey is left, and depth is a boundary or an existing shadow, never a difference in colour temperature), canvas, panel / raised / overlay (one white), the hairline, the container line, primary and secondary ink, the solid focus colour, the halo and the overlay elevation. Dark is Loop's own palette — the family layer has no dark values. The hover fill is a role of its own,--surface-hover(a hovered or keyboard-focused row, chip or icon button), with its own values in both themes: it does not follow the sunken surface, so the rest / hover step did not shrink when sunken joined the family ground (a menu row steps by an RGB distance of 43 in light, 41 in dark; following sunken it would be 15).--line-structureis not a shell token: it is drawn only by the node silhouettes, the state handle, the chart axes, the plot frames and three non-interactive labels; a container's boundary is--line-containerand a control's is--line-control. What the shell may and may not move. The functional token values (hues, edges, flow, signal, frame accents, states) are identical to what they were, pinned value by value ine2e/shell-function-colours.spec.ts, and the opaque data marks were measured pixel-identical. A translucent functional fill (a frame tint, the 4 % hue wash, the flow trail) composites over the shell background, so its rendered result can differ by up to 6 per channel in light: that is the background changing, not the functional colour. The canvas's information ink (node titles, edge-chip text, token counts) and its non-data structure — the dot grid (--canvas-grid) and the plot grids (--chart-grid) — follow the shell ink and hairline in light and keep their values in dark. The grids are SVG paint, which forced colours do not recolour, so underforced-colorseach names a system colour by its role rather than letting a shell colour show through: the dot grid is a decorative position aid — as thousands ofGrayTextdots it would compete with the nodes and edges — so it isCanvasand is not seen; a plot grid is a line values are read against, so it isGrayText. Two radius axes:--radius(12 px) for containers, dialogs and popovers,--control-radius(8 px) for standard rectangular controls; compact controls (15–22 px tall chips, triggers, tabs, operator keys) keep their own 4–6 px — the class is the control's form. - Control boundary. Any 1 px control border on a panel — the shared
.btn(every variant that does not set its own border: plain,--sm,--icon), the PlayBar's.pb-btnand the Timeline's.timeline__csv— is--line-controlat rest and--line-control-hoverhovered, and keeps ≥ 3:1 against the surface behind it and against its own face, light and dark (WCAG 1.4.11). The face contrast is what makes the rule global rather than per surface: with--line-structureit was 1.86 (light) / 2.15 (dark) on every surface, and 1.78 / 1.65 / 1.86 / 1.51 against a panel / overlay / raised / sunken surface (desktop audit 2026-09-20; the mobile sheet found the same 1.78 on 2026-09-19). Measured minima with the control tokens, composited pixels, on the family shell (2026-10-01): light rest 3.92 (on a hovered row,--surface-hover) · 4.49 (sunken) · 4.82 (panel, overlay and face — one white), hover ≥ 7.04; dark rest 5.46 (face) · 5.98 (overlay) · 6.46 (panel), hover ≥ 7.67. Ghost is outside the contract and keeps its own pair — no border at rest,--line-stronghovered (an appearing edge, not a boundary that has to stay legible) — and primary keeps its--signal-primary. A disabled control is the WCAG exception (opacity: .4,.pb-btn:disabled→--line-disabled), not a 3:1 contract, and its token does not change on enable; under forced colours the UA owns the border (ButtonBorder,Highlighthovered,GrayTextdisabled). Pinned on composited pixels ine2e/desktop-btn-boundary.spec.ts(one control per surface token + PlayBar + Timeline, light / dark / forced) ande2e/mobile.spec.ts(the sheet buttons). The other bordered controls (2026-10-01) — Inspector fields and selects, the Monte-Carlo and author inputs, the language search, the share URL, the seed field, the distribution pool select and mean toggle, the view tabs, the expression operator keys and the reference-insert pill — drew--line-structureuntil the shell branch and are held to the same contract ine2e/shell-control-boundary.spec.ts: rest ≥ 4.14 light, ≥ 5.46 dark, hover--line-control-hover, and a disabled field drops to--line-disabledand the disabled ink so it reads as disabled. The thirteenth rule is the data-import dialogs' text and number inputs, paste area and selects: they were unstyled browser controls (white with black text in the dark theme too) and are shell controls now — the control boundary, the shell face and ink, class A focus — with their box unchanged (a 1 px border and 2 px 3 px padding in place of the browser's 2 px inset border and 1 px 2 px padding). The inputs and the paste area take the 8 px control radius; the selects are 16–19 px tall, compact controls, and keep 6 px.--line-control-hoverstates its own value per theme; it does not read an ink token, so a shell ink change cannot shrink the rest / hover step. forced-colors/ high contrast: when the UA overrides colours, the §VL7.1 required set stays distinguishable (shape, dash, icon, ring); the app does not fight the override.
- Durations: step cues ~320 ms; hover/selection transitions ~120 ms; the
flow bead's travel scales with the run speed slider. Nothing animates while
status === 'paused' | 'idle'except a direct user action (select, hover, drag). prefers-reduced-motion: reduce(already honoured for edges): no travelling bead (static segment instead), no number slide (instant swap with the ▲/▼ glyph kept), no pulse (instant colour change held for one step), no card slide-in. All information is preserved; only the animation is dropped.- Playback, issue #330 PR 1 (v0.22.0) (
docs/simulation-playback.md§PB4.6): a Gate output that moved is highlighted while its token travels, and the step's summed amount rides beside the token as a+Nbadge from+1. Under reduced motion the same information is static for the committed step: the moved path's highlight, the arrival tell, and the+Nbadge at the target end. A connection's own label never moves; it dims only while the token or its badge covers it. - Playback, issue #330 PR 2 (v0.23.0) (
docs/simulation-playback.md§PB4.7): when a token reaches a Pool, a soft tint pulses inside the Pool (0.5 s, under the text); a Converter whose tokens move shows a small ⇄ mark inside it, which fades after the step. Under reduced motion both are held static for the committed step; in forced colours the pulse is a 2 px system-colour line instead of a tint. - Playback speed tiers, issue #330 PR 3 (v0.24.0)
(
docs/simulation-playback.md§PB6.1): at 400 ms a step or slower the token travels with its+N; from 200 to 399 ms it travels and+Nshows on arrival; below 200 ms the moved path flashes and the token appears at the end with+N. Step always draws the full form; reduced motion is unchanged. - Motion never conveys information that isn't also in a static frame.
Re-rendering an existing file in the new visual system is a pure view
change. Opening or scrolling a graph must never make a revision dirty or
mint a migration.
- VL-INV-1 — no stored field is auto-changed by rendering: node
position,label, and any size/measurement field are left exactly as saved. - VL-INV-2 — the new silhouettes do not re-fit or nudge saved
positions; a node drawn with a different outline keeps its coordinate. - VL-INV-3 — opening a document creates no auto-migration commit and no undo entry.
- VL-INV-4 — for any pre-Refresh file, the GraphDoc bytes and the
loop-revision/1fullContentDigestare identical before and after the Refresh ships. - VL-INV-5 — no automatic
fitViewand no viewport re-save on open (the one exception already specified is mobile rotation,→ docs/mobile.md). - VL-INV-6 — a content change happens only when the user actually adds
or edits a Parameter / Register / Resource Type, and only then under the new
model version (
→ loop-model/1/loop-revision/2).
The visual system must not break the local / offline / deterministic posture.
- Icons and fonts are bundled or served from
public/— no runtime CDN request, ever (same rule as the rest of the app;→ docs/pwa.md). - Every visual asset is inside the Production precache closure
(
check:pwa-closure) and inside the portable single-file build. - Icons ship as inline SVG or a bundled sprite, not as separate network fetches at render time.
- Tests freeze the animation phase or disable motion; a screenshot for a fixed graph × sim state × theme × zoom is byte-deterministic (modulo the approved pixel tolerance).
- Snapshot matrix: {light, dark} × {desktop, mobile} × {L2, L1, L0} over a fixed fixture, plus the per-state and per-edge-class frames from §VL12.
- The long-label fixture is LATIN-only — a role split, not a cut in
multi-script support. §MML1 wraps a long title in full (no ellipsis), so the
whole string now reaches the pixel clip; a CJK / Cyrillic / emoji tail would
then render with whatever glyphs the CI runner's OS fonts supply and stop
being byte-deterministic between Windows builds. The pixel fixture
(
e2e/canvas-refresh-visual.spec.ts) therefore uses a long Latin string (bundled IBM Plex, identical everywhere) that still wraps to several lines, grows the vessel to its ceiling, and force-breaks an unbreakable token. The multi-script / CJK / Cyrillic / emoji stress case lives ine2e/node-long-label.spec.tsas a DOM test — full string kept on the element, no sideways spill, silhouette grows, the side handles stay on the 28 px port row (docs/diagram-layout.md§DL1) — and never compares pixels. - Parameter / Register are in the committed matrix. Their frames —
paramresting,paramout-of-range,registervalid mid-run,registerinvalid(eachM_REG_*reason once), the Timeline gap — sit in the same {light, dark} × {desktop, mobile} × {L2, L1, L0} grid as every other kind. The Canvas Visual Refresh slices land them in three passes: chrome / silhouettes / states first, then edge & motion, then the full zoom ×forced-colorsmatrix. - Verified environments:
prefers-reduced-motion: reduce, keyboard-focus visible, andforced-colors/ high-contrast (the required set of §VL7.1 must still be distinguishable when the UA overrides colours). - Contrast is numeric, not eyeballed: node outline & body text ≥ 4.5:1 on
their surface; edge lines & state-indicator strokes ≥ 3:1 on
--surface-canvas; type hues ≥ 3:1 and mutually ≥ some ΔE floor under simulated deuteranopia / protanopia / tritanopia. The exact numbers are pinned in §VL12 and checked.
Machine-checkable, for the Refresh slice's E2E — the chrome/state/edge frames in
e2e/model-nodes-visual.spec.ts + e2e/canvas-refresh-*.spec.ts, and the
committed pixel matrix in e2e/canvas-refresh-visual.spec.ts:
- Snapshot matrix — a committed screenshot for every cell of
{light, dark} × {desktop, mobile} × {L2, L1, L0} over one long-content
fixture (a long Latin label that wraps full-height with an unbreakable-token
tail — see §VL11.2 for why not multi-script — large + negative values, unit,
an
invalidRegister, a selected + keyboard-focused node, a resource and a state edge, a live run cue), plus aforced-colors: activeframe at L2 and L0. The multi-script / CJK / Cyrillic / emoji long label is covered from the DOM ine2e/node-long-label.spec.ts, not here. Non-deterministic chrome (the build stamp) is outside the.react-flowclip; the attribution is masked; fonts are awaited and the single-shot run cue is frozen before the shot. Tolerance and platform pinning as for the existing Distribution snapshots. - No off-canvas UI — at every supported width (desktop + the mobile
View/Run widths,
→ docs/mobile.md), every floating card, legend, and badge has its bounding box fully within the viewport; the document never scrolls sideways. - Token purity — a check (script, like
check:mobile-query) asserts no literal#rgb/rgb()colour insrc/components/nodes/**,src/components/edges/**— onlyvar(--*). - Colour-blind & forced-colors redundancy — for a fixture containing every
node state and both edge classes, each is distinguishable with hue stripped
and under
forced-colors: active(class name / shape / dash presence / icon in the DOM/SVG carries the non-colour tell). - Zoom keeps the required set, and the canvas does not jump — going L2 → L1 → L0: the §VL7.1 required-set elements stay present at every level; only supplementary text/badge elements disappear; and node bounding boxes, silhouette paths, positions, and edge endpoint coordinates are byte-identical across the three levels (no resize, no re-route at a threshold).
- Reduced-motion parity — with
prefers-reduced-motion, a mid-run DOM snapshot contains the same information nodes/edges (values, ▲/▼ glyphs, amount chips, state badges, required-set flags) as the normal frame; only animation properties/classes differ. - Light/Dark parity — the set of emphasised elements (rings, badges, fired glow targets) is identical between themes for the same graph state.
- GraphDoc untouched — load each
examples/**graph, render it, pan and zoom, and assert the serialized GraphDoc and itsloop-revision/1digest are byte-identical to before;canUndois unchanged; no autosave write fired (VL-INV-1…6). - Assets are offline-safe —
check:pwa-closurestill passes with the new assets; the portable build contains them; a test with the network blocked renders every node kind, both edge classes, and every icon from cache. - Contrast thresholds — an automated check computes the §VL11.2 contrast ratios for the token pairs actually used and fails below the floor.
- Parameter / Register are first-class in the matrix. Their appearance
and state representation (§VL2.1, §VL2.4, §VL3, §VL5.4) are rendered on the
real canvas and covered by criteria 1–10 like every other kind — no dev
flag, no provisional fixture. The
invalidstate (Register only — a Parameter is neverinvalid) is drawn as a dashed outline in the error colour (--no, since 2026-10-21) plus a corner!flag, so it is identifiable with hue stripped and underforced-colors; the value area shows—, never0or a stale value.
- VL-D1 — non-behavioral. This doc fixes appearance and the visual↔state map only. Any statement about when a state occurs, what an expression evaluates to, or what a resource type permits lives in a spec, not here.
- VL-D2 — extend N1, don't restart. The Vessel silhouettes, the hue-in- small-areas rule, and the mineral-signal-for-"now" rule are kept. The Refresh slice migrates existing nodes/edges onto the full grammar; it does not redesign the shapes.
- VL-D3 — Resource Type is advisory in v0.6.0, and this doc fixes only its
look. Colour / icon / legend / Inspector warning; computation-neutral,
validation-assist. The field structure, the mismatch rule, and any canonical
/ digest inclusion are
loop-model/1+loop-revision/2decisions, not this doc's. Hard type validation is a later, separate spec. - VL-D4 — Parameter / Register are live and first-class.
loop-expr/1/loop-model/1are Frozen and the model-language implementation has shipped: the silhouettes (§VL2.1), in-node layout (§VL2.4), theinvalidtreatment (§VL3, Register only — a Parameter is neverinvalid), zoom behaviour (§VL7.2), and the display-grouping note (§VL5.4) are normative and rendered on the real canvas, in the committed acceptance matrix like every other kind — no dev flag, no provisional fixture. Rendering these nodes changes no GraphDoc byte,loop-revision/*digest, undo entry, or viewport (§VL10). - VL-D5 — charts float, they don't dock. The timeline/distribution move from a docked region to dismissible floating cards as part of the Refresh.
- VL-D6 — routing is render-only. Any edge routing / label placement added
never mutates graph data (
source/target/ handles /position). - VL-D7 — desktop and mobile share this grammar. The mobile View/Run layout
(
→ docs/mobile.md) applies the same node/edge/state visuals; only the chrome around the canvas differs. - VL-D8 — the Refresh is a view change, not a data migration (§VL10). No
existing GraphDoc /
loop-revision/1digest moves because of it. - VL-D9 — every visual asset is offline-safe (§VL11.1): bundled or
public/, in the precache closure and the portable build, no runtime CDN. - VL-D10 — zoom never hides the required set (§VL7.1) and never resizes / re-routes at a threshold (§VL12.5).
- This doc — Visual Language draft (here). ✔ merged; Parameter / Register appearance fixed in this follow-up.
loop-model/1+loop-expr/1— Parameter / Register semantics, expression grammar & evaluation. ✔ Frozen (SEMANTICS-M.md,SEMANTICS-X.md).loop-revision/2(with the "Workspace stays v1" note,→ SEMANTICS-M.md §M8.2) — new node kinds and expression fields in the canonical projection / semantic digest. Draft — the remaining pre-implementation spec.- Merge
loop-revision/2; verify merge-commit CI. chore/open-0.6.0-dev.- Implementation slices: model language → Canvas Visual Refresh (this doc made real). Scenario Compare is out of the confirmed v0.6.0 scope — it needs its own spec/design PR and a separate go decision after the Refresh.