Technical spec · v0.x · MIT
@grida/hud
Surface backend for the Grida editor viewport. This page walks through every contract the package implements, with a minimal live demo per section. Where a rule traces back to a working-group decision, the row cites the wg doc. Skim the sections, click around, hover the inspector.
§0 Live editor
The real editor — every contract, end-to-end
This canvas mounts @grida/svg-editor — the production host of @grida/hud. Every gesture, intent, and mutation below — select, marquee, lasso, translate, resize, rotate, drag, endpoint, enter content-edit, undo, redo — runs through the same wiring shipped to users. The sections below isolate each contract in a minimal host so you can read the spec; this one shows the whole stack live.
⌘/ctrl + wheel = zoom · space-drag = pan · ⌫ = delete
mode:
selecttool: cursorselection: —Primitives
The atoms HUD ships
Every chrome surface in this package — selection outlines, knobs, rulers, snap guides, measurement overlays — composes from six draw primitives. The stage walks them top to bottom: lines, rects, polylines, points, screen-space rects, and finally the paint vocabulary every primitive accepts on its fill and stroke slots.
Stage legend
- 1HUDLineplain · dashed · thick · with label
- 2HUDRectstroke · fill+stroke · dashed · fill-only
- 3HUDPolylineopen zigzag · closed stroke · closed fill
- 4HUDPointrow of fixed-size crosshairs
- 5HUDScreenRectrect · circle · rotated · tl-anchored
- 6HUDPaintsolid fill · stripes fill · solid stroke · stripes stroke
| HUDLine | Document-space line segment. Knobs: strokeWidth, dashed, color, optional label (rendered as a screen-space pill) + labelAngle for rotated selections. | |
| HUDRect | Document-space axis-aligned rectangle. Independent stroke / fill toggles, fillOpacity, dashed, strokeWidth. Selection outlines, marquee, and layout zones all reduce to this primitive. | |
| HUDPolyline | Document-space polyline. Open by default; auto-closes when fill: true. Even-odd fill rule. Per-primitive strokeOpacity separate from fillOpacity — used by the path-edit hovered-segment state. | |
| HUDPoint | Document-space anchor drawn as a fixed-size screen-px crosshair. Per-point color batches by color bucket. Used for vertex / control-point markers. | |
| HUDScreenRect | Document-space anchor + screen-space dimensions — the primitive that keeps knobs at constant visible size regardless of zoom. Variants: anchor (center / corner), angle for rotated selections, shape: "rect" | "circle" while the hit AABB stays a square for Fitts' reach. | |
| HUDRule | Full-viewport axis-aligned line at a document-space offset on "x" or "y". Omitted from the stage — at full width it would dominate every other primitive. Used by snap guides and the ruler. | |
| HUDPaint | Closed taxonomy solid | stripes. The same value flows into either fillPaint or strokePaint — Canvas 2D's fillStyle and strokeStyle both accept a CanvasPattern. Hosts cannot register kinds at runtime; new kinds enter HUD by PR with ≥2 consumers shaped. | |
| Stripes defaults | 45° / 8px / 1.5px in device pixels — matches the main editor's vector-edit hover-region pattern. Tile rasterizes at device-pixel density and HUD applies a counter-CTM pattern transform, so stripes stay constant width at any zoom. | |
| Anti-goals | Closed taxonomy, not an open registry. One paint per fill, one per stroke (no compositor). No paint on labels. See README's Anti-goals. |
Architecture
Three layers, one direction
Hud is a state machine plus a canvas renderer plus a thin wired surface. Dependencies flow
primitives ← event ← surface — the host sits above all three.Host (svg-editor, grida-canvas-react, …)
- owns: Document, scene, selection, camera, history
- provides: pick, shapeOf, vectorOf, onIntent
- pushes: pointer / wheel / key events
- commits: intents (history.preview / commit)
│
▼
surface/ — wired class
- Surface: lifecycle, draw loop, providers
- chrome: builds HUDDraw from SurfaceState + shapeOf
- vector-chrome: vertex / tangent / segment overlays
│
┌──────────┴─────────┐
▼ ▼
event/ primitives/
- pure math - HUDCanvas
- gesture state - HUDDraw primitives
- hit-regions - snap/measure/lasso builders
- click-tracker no state, no host
- decision - vitest-friendly
no canvas, no DOMSelection intent
Pointer-down → Scenario, deterministically
Every pointer-down classifies into one of a finite set of named scenarios. The classifier composes overlay hit (Tier 1) with scene pick (Tier 2) and the modifier snapshot. Same input → same routing, across versions. The fixture is deliberately overlap-dense — stacked cards, a nested rect, and a rotated bar — because the classifier's work only shows up when pick is ambiguous and chrome covers non-selected geometry. Click around and watch the scenario name in the inspector.
⌘/ctrl + wheel = zoom · middle-drag = pan · shift = additive
Initialising…
| ContentReplace | Click unselected node, no shift → immediate select-replace (commits on-down). | wg: selection-intent |
| ContentAdd | Shift-click unselected node → immediate add to selection. | wg: selection-intent |
| ContentNarrowOrDrag | Click selected node (no shift) → defer. Drag = translate, click = narrow to self. | wg: selection |
| ContentToggleOrDrag | Shift-click selected node → defer. Drag = translate, click = toggle off. | wg: selection-intent |
| BodyDragOnly | Drag chrome with no node under cursor → pend without deferred select; drag promotes to translate. | wg: selection-intent |
| BodyNarrowOrDrag | Drag chrome with hover ∈ selection (no shift) → defer. Drag = translate, click = narrow-to-self. | wg: selection |
| BodyToggleOrDrag | Shift-drag chrome with hover ∈ selection → defer toggle-off vs drag. | |
| BodySwapOrDrag | Click chrome with hover ∉ selection → defer swap-to-hovered vs drag. Drag preserves the group; click swaps the selection. | wg: selection |
| BodyAddOrDrag | Shift-click chrome with hover ∉ selection → defer toggle-add vs drag. NOT an immediate add. | |
| HandleResize | Pointer-down on resize knob or edge → start resize immediately (commit on-down). | |
| HandleRotate | Pointer-down on rotation region → start rotate gesture immediately. | |
| HandleEndpoint | Pointer-down on a line endpoint knob → start endpoint drag. | |
| EmptyMarquee | Click empty space (no selection) → pend marquee. Drag opens marquee; click commits empty. | wg: selection-intent |
| EmptyDeselectThenMarquee | Click empty space with selection → emit deselect_all + pend marquee. | wg: selection-intent |
| EmptyAdditiveMarquee | Shift-click empty space → pend additive marquee (preserves selection). | |
| EnterEdit | Double-click on content → emit enter_content_edit. | |
| ExitEdit | Double-click away from edit target while in content-edit → emit exit_content_edit. Takes precedence over EnterEdit. | |
| EmptyClearSubSelectionThenMarquee | Click empty space while in content-edit → clear vector sub-selection only; node-level selection preserved. |
Group selection
One envelope, many members
Hosts pass a pre-computed
SelectionGroup[] for multi-select: one shape (the union rect) plus the member ids. The surface draws a single envelope and routes body-region gestures against the group's members. Mirrors the svg-editor wiring at dom.ts:1631–1666.Off = flat NodeId[] — three separate chromes
Initialising…
| Selection normalization | Host invariant: parent and any descendant are never simultaneously selected. The router assumes the input already satisfies this. | wg: selection-intent |
| ids_at_down captures group members | On pointer-down within a body overlay, the surface records ids_at_down = chrome group members. Drag-threshold discriminates click vs. drag. | wg: selection-intent |
| BodySwapOrDrag (multi) | Click child of selected group → narrow to that child on click. Drag → translate the group as a unit. | wg: selection |
| Member outlines | Multi-select shows a thin stroke rect per member, in addition to the union envelope. Host-fed extra: compute_member_outlines_extra. |
Transformed selections
Knobs follow the artwork, not its AABB
When
shapeOf returns { kind: 'transformed', local, matrix }, the surface runs the 9-slice in the artwork's own frame, rotates every knob with the parent, and hit-tests via screen_obb. Clicks outside the rotated rect never trigger phantom resize zones.Initialising…
| Identity equivalence | With identity matrix, transformed chrome emits the same overlays / hits / priorities as the rect path. | |
| screen_obb hit-test | Rotated zone rects carry an inverse_transform that maps the pointer into shadow space before AABB containment. No bbox-of-rotated-corners inflation. | |
| Knob render carries angle | HUDScreenRect.angle rotates the knob around its screen-space center. Cursor baseAngle follows the matrix so resize/rotate arrows stay aligned with the tilt. | |
| Dashed resize preview | During resize on a transformed selection, the preview is a closed polyline through the four local corners projected by the matrix — not the AABB. | |
| Skew + non-uniform scale (v1 caveat) | Renders and hits correctly, but handle sizing uses a uniform-scale fallback. Anisotropic per-axis sizing is a follow-up. | |
| Mirror (negative determinant) | No crash; full 13-zone set is emitted. The outline polyline winds in the mirrored corner order. |
Line selection
Endpoint knobs, not a 9-slice
When
shapeOf returns { kind: 'line', p1, p2 }, the surface skips the 9-slice ladder and paints a line-specific chrome — an outline along the segment plus two endpoint knobs that emit set_endpoint intents. The body translate zone uses the segment's AABB inflated to MIN_HIT_SIZE on each axis so axis-aligned lines (a 1-px-tall AABB) stay grabbable. Click any line to select it, drag an endpoint to move it, or drag the body to translate.Initialising…
| SelectionShape.line | { kind: 'line', p1, p2 } — two doc-space endpoints. No rect, no transform; the surface treats the segment itself as the artwork. | |
| Endpoint knobs (p1, p2) | One paired overlay per endpoint, drawn as an HUDScreenRect at the doc-space point. Action kind: endpoint_handle → scenario HandleEndpoint → intent set_endpoint (preview / commit). | |
| Body AABB inflation | Body translate zone uses the segment's AABB inflated to MIN_HIT_SIZE on each axis. Without inflation, a horizontal or vertical line has a degenerate (zero-thickness) AABB and the body would be ungrabbable. Diagonal lines reach the threshold naturally and skip the inflation. | |
| Outline render | One HUDLine primitive along p1→p2 — the same primitive every other chrome uses for its outlines. No 9-slice corners or edges; the segment IS the outline. | |
| No rotation halo | Endpoint drag IS the rotation affordance — moving an endpoint pivots the line around the other endpoint. No separate rotation knob. | |
| set_endpoint contract | Absolute pos, not a delta. Hosts that bind the line to a node update the corresponding endpoint coordinate on commit; preview ghosts it without committing to history. |
Selection chrome layout
9-slice — priority ladder, axis-independent negotiation
Selection chrome is a 9-slice over the selection rect: body + 4 corners + 4 edge strips + 4 rotation regions (drawn behind the corners). When width or height drops below the comfort threshold, the body promotes axis-independently — short rectangles still translate from their long axis. Hover the rectangle and watch the cursor — it tells you which zone you're in.
Initialising…
| Comfortable (≥ threshold) | Default ladder: corner > body > edge > rotate. Knobs at preferred size; edges fill the surplus along each axis. | |
| Small (≤ threshold on both) | Body region promotes above corner/edge. Knobs and edges still hit-test but body wins. Outside-the-bbox hits the corner knobs (paired hit pad). | |
| Elongated (one axis tight) | Edges orthogonal to the squeezed axis promote. N/S not promoted on a wide-but-short rect; W/E not promoted on a tall-but-narrow rect. | |
| Tiny (≤ MIN_CHROME_VISIBLE) | Chrome hidden. Body still hit-able anywhere inside the bbox; outside the bbox → no chrome. | |
| 9-slice conservation | corner×2 + edge = perimeter axis (when > 0). Zones mutually exclusive — no double-count between corner and edge. | |
| Rotation halo wraps the corner | Rotation rect fully contains the resize-corner rect — no gap, no overlap with edges. Lowest priority so it loses to body/edge/corner. |
Size meter
W × H, on the lowest OBB edge
Host-fed pill that reads the selection's local width × height. For axis-aligned rects it sits below the bottom edge; for rotated / transformed selections it picks the visually-lowest edge of the OBB and the label rotates with the artwork. The rect below is rotating — watch which edge the pill snaps to as it spins.
Initialising…
| Label | Local width × height, formatted to one decimal. Always the artwork's own dims — not the AABB of the rotated rect. | |
| Anchor | Visually-lowest OBB edge. Computed by projecting all four local corners through the matrix and picking the edge with the largest midpoint Y. | |
| labelAngle | Set to the edge's screen-space angle so the renderer's perpendicular offset (LABEL_OFFSET = 16 screen-px) rotates outward. | |
| Multi-selection | Union bbox W × H — placed at the bottom-center of the union, in container space. No labelAngle (the union is always axis-aligned). |
Corner radius
Per-corner radius handles
surface.setCornerRadius — corner-radius chrome on two rects in one canvas (axis-aligned + rotated). Each knob sits at its corner's ARC CENTER, offset by r in BOTH x and y from the corner toward the interior; position isthe radius value, and the rotated rect's knobs ride the rotated diagonals via the input's transform. Internally this API is a thin adapter over the universal parametric handle primitive (see the next section); the 43-test corner-radius behavior pin proves the equivalence.Demo A · rect geometry (axis-aligned + rotated)
Two rects, one canvas — arc-center handles, snap-back, oblong coincidence
L(0,0,0,0) · R(0,0,0,0)Initialising…
| Corner-radius wrapper | surface.setCornerRadius is a thin adapter — its public types and the three corner_radius* intent kinds are unchanged from pre-migration. Internally, every input is composed by a local cornerRadiusHandles helper next to the primitive and routed through the universal parametric handle. The 43-test corner-radius behavior pin (__tests__/corner-radius.test.ts) is the equivalence proof. | |
| Arc-center position | Each knob sits at the rounded corner's arc center — offset by radius r in BOTH x and y from its corner, toward the rect interior. Translates to a segment curve from corner → (corner + (max, max) · sign) with value = r, domain.max = min(w,h)/2. | |
| Coincidence groups (declarative) | Groups are an opt-in attribute on the input: { ids: [...], policy: "direction-resolved" }. When ALL members are within ε of each other in doc-space, the producer registers ONE hit region for the group; direction resolution picks among the listed candidates only. Corner-radius declares its 4-corner group so a single knob drives all four when they're equal. | |
| Snap-back inset | Producer-side floor: at rest, the knob is floored to insetin the track's own units. During a gesture the floor is lifted. The "16 px per axis" UX convention computes inset = 16 · √2 / zoom per frame (the diagonal track at 45° turns a per-axis pixel into √2 along the track) before populating the field. | |
| Intents | Three named kinds: corner_radius (all-or-named, default drag), corner_radius_explicit (alt-modifier, always named anchor), corner_radius_uniform (line geometry, always all). Preserved for backward compat — see CHECKPOINT-setCornerRadius.md. |
Padding overlay
Flex-parent padding chrome — a Layer B model
Four hover-sensitive inset side rects with diagonal-stripe affordance and a mid-edge drag handle each. Drag a handle and the inner blue content rect resizes live — the host re-derives its layout from the per-side padding values streamed back via
padding_handle intents. Toggle the switch above the canvas to prove the schema-level feature flag — the chrome disappears entirely without any host-side branching. Alt-drag mirrors the value to the opposite side; HUD reads the modifier directly (no host-pushed shadow).padding: t=24 r=16 b=32 l=16
Initialising…
| Feature flag (schema-level) | Absence of `surface.setPaddingOverlay(...)` input = no chrome rendered. No separate `enablePadding` boolean — the data is the flag. Hosts that don't track padding don't push input. | |
| Geometry | Per-side inset rect in doc-space. `top` = full width × top padding; `right` = right padding × full height; bottom/left symmetric. Absent / zero sides skipped — no overlay element, no hit region. | |
| Hit-test | Region: doc-space rect projected to a screen-space AABB on every frame, so the hit body scales with zoom. Handle: 16px screen-px square at the mid-edge, padded to MIN_HIT_SIZE. | |
| Hit-priority | PADDING_HANDLE_PRIORITY (12) wins over corner-radius (15), resize (≥30), translate body (40). PADDING_REGION_PRIORITY (35) wins over body, loses to resize — clicking inside the padding fires hover; clicking a corner still resizes. | |
| Paint — idle | No fill (render omitted). Hit still registered — the body becomes interactive on hover. | |
| Paint — hover | doc_polyline fill with `style.paddingHoverPaint` — default HUDPaintStripes 45° / 8px / 1.5px, accent color, 50% opacity. Alt-held: BOTH the hovered side AND its opposite paint. HUD reads `alt` directly. | |
| Paint — selected (during drag) | Stroked OUTLINE of the side rect (no stripe fill). `color = style.paddingSelectedStroke`, `strokeWidth = selectionOutlineWidth`. HUD-owned — derived from `state.gesture` when a `padding_handle` drag is in flight. The outline communicates the live padding value cleanly; stripes would read as hover preview. Mirrors to opposite side when `alt` is held. | |
| Paint — hover on handle | The hover stripe also lights up when the cursor is on the side's drag handle (not just the region body) — the handle is part of the side's affordance, so losing the stripe when the cursor crosses onto the knob would be jarring. | |
| Value math (2× handle displacement) | Handle sits at the CENTER of the padding strip (`y = padding.top / 2` for top). For the handle to track the cursor 1:1, padding changes at 2× the cursor displacement from the rect edge: `value = 2 × (cursor_y - rect.y)` for top, symmetric for others. Click-no-drag preserves the initial value. | |
| Intent — drag | `padding_handle { node_id, side, value, mirror, phase }`. Preview-stream on every move + one commit on release. `mirror` is read LIVE per frame — toggling alt mid-drag flips the flag on subsequent previews. Layer B dedicated kind; internally reducible to parametric-handle drag math. |
Transform box
Affine transform box — a Layer B model
Quad outline + 4 corner rotate handles + 4 side scale handles + body translate. The image is bound to the chrome's affine transform; the parent rect clips the "real" rendering and a 50% ghost shows the same image extending past the frame so you can see where the transform is going. Drag a corner to rotate, a side to scale on that axis, the body to translate. Toggle the container-rotation chip to exercise the de-rotation path. The HUD intent (`transform_box`) is target-agnostic — the same chrome would work against any 2×3 affine target.
Container rotation
op: (idle)
a=1.000 b=0.000 c=0.000 d=1.000
tx=0.000 ty=0.000
rot=0.0° scale=[1.00,1.00]
Initialising…
| Feature flag (schema-level) | Absence of `surface.setTransformBox(...)` input = no chrome rendered. No `enableTransformBox` boolean — the data IS the flag. Same pattern as `setPaddingOverlay` / `setCornerRadius`. | |
| Target-agnostic (Layer B doctrine) | The model is NOT image-specific. The intent's `id` lets the host route the resolved `transform` to whatever it bound to — `cg.ImagePaint.transform`, a node's local transform, a future free-transform tool. The input is marked `@unstable` until the main-editor image-paint editor migrates onto it — the contract is locked in only after ≥2 internal consumers shape it. | |
| Geometry | Box-relative affine — translation components ([0][2], [1][2]) normalized [0..1] against `size`. Pipeline: box_local → transform → +rotation around origin → +origin → doc-space. | |
| Hit-priority (corner > side > body) | TRANSFORM_BOX_CORNER_PRIORITY=13 wins over corner-radius (15). TRANSFORM_BOX_SIDE_PRIORITY=14. TRANSFORM_BOX_BODY_PRIORITY=38 beats marquee/translate-body, loses to every resize. | |
| Hit asymmetry (D3 — Layer B doctrine) | Visible stroke: 1px (selectionOutlineWidth). Hit strip: 12px thick (Fitts'-reach). Corner hit AABB: 16×16 (≥ MIN_HIT_SIZE). Hit strictly contains the rendered bbox — the pinning test for the asymmetric-outputs discipline. | |
| Cursors — rotation-aware | Side hit → resize cursor tilted by the box's effective screen-space rotation (`container.rotation + decompose(transform).rotation`). Corner hit → rotate-arc cursor with the same baseAngle. Mirrors `resize_handle.baseAngle` / `rotate_handle.baseAngle` on selection chrome — the cursor stays aligned to the visual axis at any rotation. | |
| Intent | `transform_box { id, op: { type, side?/corner? }, transform, phase }`. HUD has already reduced — host commits `transform` directly. `op` carries TYPE and TARGET only (no pointer delta — D1: subscribe to outcomes, not events). | |
| Container rotation | When `input.rotation !== 0`, the gesture's doc-space cursor delta is de-rotated by `-rotation` before being fed to the Layer A reducer. The intent's `transform` stays in box-relative space — the host re-applies its container rotation on the next push. | |
| Math primitive (Layer A) | `reduceTransformBox(base, action, {size})` — pure, exported from `@grida/hud`. The same reducer is the engine behind this chrome and behind any host's own non-UI transform manipulation. |
Aspect ratio
Aspect-ratio guide
A dashed diagonal across the selection. Shown during a shift-resize gesture to communicate "this drag preserves the aspect ratio". Decorative — no hit-test. Pick a direction: the rect animates as if shift-resized from that corner/edge, the opposite point stays anchored as the resize origin, and the dashed amber line traces the dragged corner's trajectory.The 8-case
CardinalDirection → diagonal table ships as cmath.ui.diagonalForDirection; the render is a one-line host-side composition over HUDLine. No hud-side helper by design — see the package README's anti-goal "Not a kitchen of decorative-line helpers."direction
Initialising…
| Render | Dashed HUDLine across the selection. Endpoints from cmath.ui.diagonalForDirection — the 8-case table lives in cmath, not hud. | |
| Direction | Corner directions (ne/nw/se/sw) span opposite-corner → dragged-corner. Edge directions (n/s/e/w) resolve to a canonical diagonal. | |
| Resize origin | The point opposite the dragged direction stays fixed during the gesture — corners anchor opposite corners; edges anchor opposite edge midpoints. The animation makes this visible by pinning that point as the rect scales. | |
| Visibility | Production: only during resize gesture with shift held or a target aspect ratio set. Hidden otherwise. Host-gated, hud doesn't own this. |
Parametric handles
The agnostic primitive — scalar on a 1D manifold
HUD's universal "scalar-on-a-1D-manifold" primitive,
surface.setParametricHandles. One or more handles, each a scalar value constrained to a 1D curve. The corner-radius affordance above is a thin wrapper over this; the demo below uses the primitive directly on a shape HUD knows nothing about — a parametric star, rendered on a custom <canvas> underlay (no SVG fixture), with three handles: tip-radius (one segment handle per outer tip), inner / outer ratio (segment from center to a tip), and point-count (a stepped arc around the center, snapping to integers). The host paints the star, HUD paints the knobs, intents flow.Demo B · parametric star (custom canvas + 3 handles)
Parametric star — corner radius (segment), ratio (segment), count (arc)
r=0.0 · ratio=0.44 · count=5Initialising…
| Primitive | surface.setParametricHandles(input | input[] | null) — one or more handles, each a scalar value constrained to a 1D curve. Two curve kinds today: segment (corner-radius, ratio) and arc (count). Use-case composers (like the corner-radius one) live next to their callers and build the input from shape-specific schemas; the producer never knows what shape the host is editing. | |
| Curve kinds (segment + arc) | segment(a, b) and arc(center, radius, from, to). Projection delegates to cmath.ui.projectPointOnCurve; evaluate via cmath.ui.evaluateCurve. Future curve kinds (polyline, bezier) extend the union without breaking consumers — each adds one case to two pure functions. | |
| Stepped domains | domain.stepsnaps the EMITTED value during projection. The star demo's point-count handle uses { min: 3, max: 12, step: 1 } on an arc curve — the knob can hover continuously around the arc, but the intent payload always carries an integer. | |
| Intent | Universal API: parametric_handle with { node_id, handle_id, value, modifiers: { alt, shift }, phase }. Modifier policy lives in the host — the producer reports flags, the host's reducer decides "all vs one," "broadcast vs explicit," etc. |
Vector chrome
Vertices, tangents, segments, regions — all in the package
Pass
setVectorSelection({ node_id, vertices }) and the surface draws vertex circles, tangent diamonds, segment outlines (with idle/hover/selected state), region body chrome for closed loops, and a ghost insertion knob at the cursor. The path is fully editable — drag a vertex, drag a tangent diamond, alt-drag a segment to bend, click the ghost to insert, click inside a closed loop to select the region. The host applies every mutation intent through @grida/vn and re-emits geometry to HUD; the seam between the two SDKs is host territory by design. The regions field on the overlay is the schema-level feature flag — toggle it off and region chrome disappears entirely, without any HUD-side branching.Insertion
Selection
Bend
vertices: —
regions: —
Initialising…
| Vertex knob | One paired overlay per vertex. Render as circle, hit as square padded to MIN_HIT_SIZE. Selected fills with chrome color. | |
| Tangent diamond | 45°-rotated square, smaller than vertex. Skipped when control == vertex (degenerate). Selected uses highlight fill. | |
| Segment outline | State machine: idle → segmentIdleColor; hover → segmentActiveColor at hoverOpacity; selected → solid active. Hover wins over selected. | |
| Segment strip (virtual hit) | N+1 polyline points along the cubic (no render). Custom hit-test via point-to-curve projection — within 8 screen-px claims the click. | |
| Ghost insertion knob | Smaller than vertex; appears at midpoint (t=0.5) or projected cursor. Suppressed while is_interacting=true. | |
| Priority ladder | tangent (4) < vertex (5) < segment (8). Lower wins on overlap. | |
| Marquee / lasso → vertex sub-selection | Empty-space drag inside content-edit fires marquee_select or lasso_select (per vectorSelectionMode) with phase preview→commit. The host runs the predicate (rect contains vertex / point-in-polygon) and pushes the result back via setVectorSelection. | |
| Vertex drag | translate_vertices preview/commit — drag any vertex (selection or not) and the selected sub-set translates with it. The host freezes the network at preview-1 and applies the gesture-from-start delta against the frozen state each tick, so deltas never accumulate. | |
| Tangent drag | set_tangent preview/commit — the host maps HUD's (vertex, side) tangent reference to vn's (segmentIndex, ta|tb), subtracts the vertex position to convert HUD's absolute target to vn's relative tangent vector, then applies through vn's mirror policy (auto/none/angle/all). | |
| Segment drag (no Meta) | translate_vector_selection preview/commit — the whole sub-selection moves with the cursor. The host unions HUD's additional_vertex_indices with the selected vertices + endpoints of selected segments before applying translateVertex. | |
| Segment drag with Meta | bend_segment preview/commit — the pivot is the drag-start projection on the curve (not 0.5). The host passes the segment's frozen endpoints + tangents to vn.bendSegment so each preview tick re-solves the cubic from the same starting state. | |
| Ghost split-and-drag | Pointer-down on ghost → split_segment fires immediately (atomic, no phase), then translate_vertices preview/commit on the newly-inserted vertex. Press-no-drag commits insert with zero delta. | |
| Region — feature flag (schema-level) | Absence of `VectorOverlay.regions` = no region chrome rendered. No separate `enableRegions` boolean — the data is the flag. Backends that can't enumerate loops simply omit the field. Toggle the toolbar switch to prove it: region chrome disappears entirely without any HUD-side branching. | |
| Region — geometry | Each region carries `segments: number[]` — segment indices forming a closed loop. The HUD reconstructs the cubic path at chrome build time from `vertices` + the referenced segments. Regions carry no own geometry. | |
| Region — hit-test & priority | Screen-space AABB + `customHitTest` running `cmath.polygon.pointInPolygon` against the rasterised loop polygon. Priority REGION_PRIORITY (9) strictly above SEGMENT_STRIP_PRIORITY (8) — any vertex / tangent / ghost / segment-strip control within the loop wins. Wins over empty-space miss → clicking the interior selects the region instead of starting a marquee. | |
| Region — paint | Idle: no render (hit registered, body visually transparent). Hover: doc_polyline fill with `style.vectorRegionHoverPaint` (default HUDPaintStripes 45° / 8px / 1.5px, accent, 50%). Selected: `style.vectorRegionSelectedPaint` (default same stripes at 70%). Hover wins over selected. | |
| Region — select intent | select_region { node_id, region, mode } — eager at pointer-down. Shift → toggle, no-shift → replace. The host mirrors the loop's segments into the segment sub-selection (the main editor's `selectLoop` policy) so segment chrome highlights along with the region's stripe paint. | |
| Region — drag | Drag from a region body promotes to `translate_vector_selection` (no new translate intent kind). The HUD seeds `additional_vertex_indices` with the loop's endpoint vertices so the gesture works even before the host echoes the region select. | |
| Mutation seam (producer position) | @grida/vn provides the vector-network mutation primitives; @grida/svg-editor's PathModel provides canonical d ↔ vector-networkconversion. Hosts that want to mutate a path's geometry compose the two at the host layer. The seam is intentional; widening PathModel's public surface to carry mutation, or shipping a host-shaped reducer that names a specific intent vocabulary, is out of scope. This demo is the proof. |
Snap
Edge / center alignment guides
Full-viewport rules drawn at every doc-space offset where a dragged shape's edge or center lines up with a neighbour's. The svg-editor emits these live during translate, resize, and insert gestures; the static preview below shows the visual the user sees when an alignment claims the drag.
Initialising…
| Primitive | HUDRule { axis, offset, color }. Full viewport extent in screen-space; offset is doc-space. | |
| Triggers | Computed by the host's translate / resize / insert orchestrators against the active drag's neighbour set, then projected world → screen via the camera. | |
| Built-in helper | @grida/hud exports snapGuideToHUDDraw(snapResult) which lifts the host's snap result into the HUDDraw shape the renderer expects. |
Measurement
Distance between two rectangles
The 4-side distance overlay: a guide line per non-zero side from the base rect outward, labelled with the distance, plus a dashed auxiliary line where the rectangles don't share that edge. In production, the host gates this on alt-hover (selection ≠ hover). In this demo it's always-on — drag either rect and watch every guide, label, and auxiliary line recompute live.
Initialising…
| Primitive | Two stroked HUDRects + up to 4 labelled HUDLines (guide lines, one per non-zero side) + up to 4 dashed auxiliary lines. | |
| Movement | Extra builder reads ctx.fixture + ctx.offsets every frame, folds the in-flight translate dx/dy into both rects, and re-runs cmath.measure. No bespoke listener — the recompute is just the same builder firing on the next draw. | |
| Trigger | Production: Alt held + idle gesture, selection ≠ hover, both non-empty; cleared on Alt release or hover-out. Demo: always-on (no gating) — keeps the affordance visible while the reader experiments. | |
| Label | Per-side distance, formatted to one decimal. Skipped for zero-distance sides (when the rects share an edge along that axis). | |
| Built-in helper | @grida/hud exports measurementToHUDDraw(measurement) which lifts the cmath measurement value into the HUDDraw shape the renderer expects. |
Ruler & guides
Built-in ruler chrome + host-owned guide state
Ruler is named built-in chrome — same shape as pixel-grid, opposite slot in the paint order: pixel-grid is the substrate (back-most), ruler is the frame (top-most, above every other chrome and host extra). The host calls
surface.setRuler({ enabled, marks, backgroundColor, ... }); hud paints the L-shape strip, tracks the camera through setRulerTransform, and accent-paints any host-supplied marks. Guide state is host-owned — this demo keeps a { x: number[], y: number[] } in React state and feeds the lines through HUDDraw.rules; the ruler strip naturally clips them. Axis convention matches the main editor: drag from the top strip creates a horizontal (y) guide; drag from the left strip creates a vertical (x) guide. Drag an existing guide to move. Drag a guide back into the strip to delete.Drag the top strip down → horizontal guide · drag the left strip right → vertical guide · hover a guide to show its value · drag to select (turns blue) and move · drag back to a strip to delete · ⌘/ctrl + wheel to zoom
Initialising…
| setRuler / setRulerTransform | Mirror of setPixelGrid's API shape: enable, optional camera transform, host owns marks/ranges. Paint slot is opposite — ruler is top-most, pixel-grid is back-most. | |
| Substrate vs frame | Pixel grid is a substrate — content-space lines that read 'under' the document. Ruler is a frame — viewport-space chrome that bounds the editing area like a title bar. Two different constraints earn two different paint slots; hud locks the order. | |
| L-shape (top + left) | Both axes paint in one pass; the corner square is intentionally blank. axes: ['x'] | ['y'] | ['x','y'] picks the strips to render. | |
| 1-2-5 step ladder | Major-tick step is the smallest in the series whose on-screen spacing ≥ 50 px; subticks auto-derive from the leading digit when enabled. | |
| Guide state is host-owned | Hud has no setGuides / no GuideOverlay / no drag intent. The demo keeps {x, y} in React state and overlays its own hit regions; create / move / delete are pure setState updates. The lines themselves go through HUDDraw.rules — hud renders them under the ruler. | |
| Axis convention | Top strip → "y" guide (horizontal); left strip → "x" guide (vertical). Matches the production wiring in editor/grida-canvas-react/viewport/surface.tsx where bindX calls surfaceStartGuideGesture("y", -1) and bindY calls ("x", -1). | |
| Overlay children compose cleanly | HUDStage binds its pointer listeners on the canvas, not the container. Overlay children (interaction layer, badges, legends) are siblings of the canvas in the DOM — pointer events on them physically never reach hud's listeners, so children can handle their own events with plain React handlers. No stopPropagation gymnastics, no risk of half-delivered gestures (down received, up swallowed) leaving hud's marquee stuck open. | |
| Per-guide UI state is host-side | Hovered + selected are host React state. Hud has no setHoveredGuide / no selection-mirror for guides. The host computes its per-frame RulerMark[] from (guides, hovered, selected) — idle marks are stroke-only, hovered marks add the label, selected marks switch to the accent color. The producer's job ended at exposing strokeColor / color / text on RulerMark; the host varies them. | |
| Drag threshold (4 px) | DEFAULT_RULER_DRAG_THRESHOLD is exported from @grida/hud — the recommended pointer-movement distance before a drag-from-strip commits a new guide (or a drag-from-guide moves it). Below the threshold, a click is just a click: 'new' mode creates nothing, 'move' mode promotes the guide to selected without moving it. Hud publishes the value because it's a property of the ruler chrome's UX, not any host's gesture engine — so the main editor and the demo can never drift. |
Pixel grid
Back-most chrome — visible past the zoom threshold
Pixel grid is a named built-in chrome behind selection chrome. The host configures via
setPixelGrid({ enabled, zoomThreshold }). The grid only paints when transform[0][0] > zoomThreshold — below that it costs zero. The canvas below opens pre-zoomed past the threshold so the grid is visible immediately; ⌘/ctrl + wheel to zoom out and watch it vanish at 4×.zoom 6.00× ≥ 4×Grid is painting — every 1-unit world step renders as a faint line.
Initialising…
| zoomThreshold gate | Below the threshold the grid does not paint. Default threshold 4× in production hosts. | |
| Transform sync | setPixelGridTransform(transform) is called per camera tick; HUDCanvas internally merges with the chrome transform so the grid stays in world coordinates. |
Cursors
Rotation-aware, tree-shake-safe
The surface owns cursor state via
surface.cursor() but not cursor pixels. Hosts that want Grida's Figma-style rotation cursors wire the opt-in subpath: surface.setCursorRenderer(cursors.defaultRenderer()). The gallery below shows every glyph at idle baseAngle; toggle the live demo to swap to the native CSS fallback.Every glyph the surface can emit
Rotate (4)
rotate-nw
rotate-ne
rotate-se
rotate-sw
Resize (4 bidirectional)
ew
ns
nesw
nwse
Native-CSS passthrough
defaultpointermovecrosshairgrabgrabbingtext
Hover any chip to see the native cursor — the hud emits this CSS keyword verbatim.
live cursor: —
Initialising…
| Native CSS passthrough | default, pointer, move, crosshair, grab, grabbing, text — emitted as the matching CSS cursor: keyword. | |
| Rotate (4 per-corner) | Four distinct curved-arrow SVGs, one per RotationCorner. baseAngle adds the live rotation angle so the cursor tracks mid-drag. | |
| Resize (8 bidirectional) | Opposite directions share the SVG (the arrow is bidirectional). Per-direction fallback to native cursor keyword. | |
| Rotation bucket → no thrash | Mid-rotate cursor updates are bucketed by angle. Sub-bucket drift does not re-emit, so the cursor doesn't redraw on every move. | |
| Tree-shake invariant | Nothing in surface/, event/, or primitives/ may import from cursors/. Hosts that don't import the subpath pay zero bundle cost. | |
| Deterministic | Same input → same output. Repeated calls return identical strings so React Object.is bail-outs work. |
Click tracker
Canvas-tuned 250ms / 4-px double-click window
The surface ships its own click counter, tuned for canvas workflows (faster human rhythm than the OS default 500ms). Clicks within 250ms and 4 px of each other count up; otherwise the counter resets. Double-click is what drives
enter_content_edit in production; this demo just shows the count.Click twice within 250 ms and 4 px → ×2 · keep clicking to count up · move > 4 px or wait > 250 ms to reset
Double-click anywhere
Initialising…
| 250 ms window | Two clicks 250 ms apart count as a double (boundary inclusive). 300 ms apart does not. 500 ms (OS default) does not — the canvas beats the OS clock. | |
| 4 px distance window | If the cursor moves more than 4 px between clicks, the counter resets to 1. Spatial decay defeats accidental double-click from drift. | |
| Multi-click counts up | Counter goes 1 → 2 → 3 → … each click inside the window. Triple-click is observable; the host decides what to do with it. | |
| Per-button isolation | The counter tracks each pointer button independently. A primary then secondary does not register as a double. |
Visibility groups
Suppress chrome families per-gesture, without re-wiring draws
Hosts assign string group names to chrome slots via
SurfaceOptions.groups — and tag their own host-fed extras with { group: "..." } — then return a hidden-set from SurfaceOptions.visibility per-gesture. The hud package never owns the vocabulary; hosts name their own. The demo runs three states off one policy: at idle all three chrome families (selection, selectionControls, sizeMeter) paint; during translate all three vanish so the moving silhouette is clean; during resize the static chrome drops out and only sizeMeter remains, label tracking the in-flight rect live. Select the rect, then drag the body (translate) vs. a corner / edge handle (resize) and watch the legend.gesture: idleselectionselectionControlssizeMeterhovermarqueelasso
Initialising…
| Group tagging | The hud package does not define group names. Hosts stamp strings onto built-in chrome via SurfaceOptions.groups and onto their own extras via the primitive's `group` field. | |
| filterHUDDrawByGroup | On each draw, the surface walks every primitive in built-in chrome + extras and drops the ones whose group is in `hidden`. Unrelated extras pass through untouched. Returns undefined when every primitive is hidden. | |
| idle rule | No filter — selection + selectionControls + sizeMeter all paint. The size meter is mounted as a host extra (one HUDLine tagged group: 'sizeMeter'); the policy lets it through. | |
| translate-hide rule | When gesture.kind === 'translate', hide selection + selectionControls + sizeMeter. The moving silhouette renders alone. | |
| resize-isolate rule (the asymmetry) | When gesture.kind === 'resize', hide selection + selectionControls but KEEP sizeMeter. The static chrome drops out so the live W × H pill stands alone with the in-flight rect — the host threads the dragging rect into the extra builder so the label tracks the handle live, not the pre-drag size. | |
| Pass-through | Built-in groups the policy never names (hover, marquee, lasso) stay under their own visibility rules — they paint when they have reason to, regardless of gesture. |
Performance
N × N grid — chrome cost is selection-sized, not scene-sized
Mounts the same
@grida/svg-editor host as §0, against a procedurally-built grid. Crank Nbelow and the node count quadruples, but the hud's per-frame work stays flat — its draw cost tracks selection size, not scene size. Hit ⌘A at N=50 to put 2500 nodes in selection and watch the multi-select chrome render as one envelope. Marquee through the grid, resize the union, drag — the chrome is the same one shipped to production.N =nodes = 900fps60·worst0.0mstry: hover · marquee-drag · ⌘A · resize union
| Hud draw cost | Per frame the surface paints chrome for the selection — selection bbox, 8 resize knobs, rotation regions, optional size meter — plus any host-fed extras and gestures-in-flight (marquee outline, snap rules, hover stripe). Cost is O(|selection|), independent of |scene|. | |
| Multi-select envelope | ⌘A → the hud paints one selection envelope around the union bbox of every selected id. 1 node or 2500, that's still one rect + one set of knobs. The host computes the union once per selection change. | |
| Marquee under load | Drag an empty area to marquee-select. The hud emits one selection intent per frame; the host classifies node membership against its spatial index. The hud's marquee outline (one HUDRect) is constant-cost regardless of how many it ends up selecting. | |
| Why the real editor | Earlier drafts ran on the showcase fixture host — its FixtureSvg paints N² <rect>s per render and its hitPick is O(n) linear-scan, saturating before any hud cost showed up. Hosting on @grida/svg-editor (spatial indexes, optimized DOM) moves the variable back onto the hud chrome. | |
| FPS readout | 1-second rolling rAF window — browser-overall. Useful for A/B-ing N values within this section. For hud-isolated numbers, see the package's own perf benches. |
Not yet built
Open work — what hud needs next
Items the main editor wires today but the demo cannot prototype with existing hud primitives. Each one is the spec for a future hud addition or a deliberate DOM escape hatch.
| Item | What it needs | Note |
|---|---|---|
| Padding overlay (hatched) | new primitive | Needs HUDRect.hatched { angle, spacing } — solid fill only today. |
| Network curve | new primitive | Bezier connector between nodes. Hud only has straight HUDLine. |
| Network edge arrowhead | new primitive | Composable from HUDLine; depends on the curve primitive landing first. |
| Gradient stop editor | new primitive | HUDLine with color-stop array, plus per-stop draggable knobs. |
| Variable-width stroke stops | vector-chrome extension | Per-vertex width handles along a path. Needs new VectorOverlay variant + new gesture. |
| Image crop handles | new gesture mode | Re-uses the resize 9-slice math, but the intent writes the crop rect, not the layer size. |
| Locked indicator icon | primitive | DOM | Either extend hud with HUDIcon (SVG path) or ship as a DOM badge. |
| Node title bar / frame name | DOM escape hatch | Text-heavy with double-click rename — canvas text is brittle. |
| Component consumer badge | DOM escape hatch | Small badge with icon; dblclick enters component via enter_content_edit. |
| Text caret + selection range | DOM escape hatch | Requires contenteditable / IME. Hud is not a text renderer. |
| Distribute-evenly button | DOM escape hatch | Interactive button positioned over the canvas. |
| Floating toolbar | DOM escape hatch | Contextual action bar above the selection. |
| Dropzone indicator (drag-in) | host extra | Container drop highlight while dragging a node from outside. Render with HUDRect fillOpacity; needs host-side drag-state plumbing. |
| Group member outlines (mid-drag) | host extra | Already implemented for selection; needed during gesture preview too. |
| Sort handle (z-order swap dot) | host extra | Render is a one-line HUDScreenRect (circle) at the right edge — already a trivial host composition. The unbuilt piece is the host-side drag-reorder gesture that swaps z-order with the neighbour; no hud primitive blocks it. |