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
  1. 1
    HUDLine
    plain · dashed · thick · with label
  2. 2
    HUDRect
    stroke · fill+stroke · dashed · fill-only
  3. 3
    HUDPolyline
    open zigzag · closed stroke · closed fill
  4. 4
    HUDPoint
    row of fixed-size crosshairs
  5. 5
    HUDScreenRect
    rect · circle · rotated · tl-anchored
  6. 6
    HUDPaint
    solid fill · stripes fill · solid stroke · stripes stroke
HUDLineDocument-space line segment. Knobs: strokeWidth, dashed, color, optional label (rendered as a screen-space pill) + labelAngle for rotated selections.
HUDRectDocument-space axis-aligned rectangle. Independent stroke / fill toggles, fillOpacity, dashed, strokeWidth. Selection outlines, marquee, and layout zones all reduce to this primitive.
HUDPolylineDocument-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.
HUDPointDocument-space anchor drawn as a fixed-size screen-px crosshair. Per-point color batches by color bucket. Used for vertex / control-point markers.
HUDScreenRectDocument-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.
HUDRuleFull-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.
HUDPaintClosed 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 defaults45° / 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-goalsClosed 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 DOM
Selection 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…
ContentReplaceClick unselected node, no shift → immediate select-replace (commits on-down).wg: selection-intent
ContentAddShift-click unselected node → immediate add to selection.wg: selection-intent
ContentNarrowOrDragClick selected node (no shift) → defer. Drag = translate, click = narrow to self.wg: selection
ContentToggleOrDragShift-click selected node → defer. Drag = translate, click = toggle off.wg: selection-intent
BodyDragOnlyDrag chrome with no node under cursor → pend without deferred select; drag promotes to translate.wg: selection-intent
BodyNarrowOrDragDrag chrome with hover ∈ selection (no shift) → defer. Drag = translate, click = narrow-to-self.wg: selection
BodyToggleOrDragShift-drag chrome with hover ∈ selection → defer toggle-off vs drag.
BodySwapOrDragClick chrome with hover ∉ selection → defer swap-to-hovered vs drag. Drag preserves the group; click swaps the selection.wg: selection
BodyAddOrDragShift-click chrome with hover ∉ selection → defer toggle-add vs drag. NOT an immediate add.
HandleResizePointer-down on resize knob or edge → start resize immediately (commit on-down).
HandleRotatePointer-down on rotation region → start rotate gesture immediately.
HandleEndpointPointer-down on a line endpoint knob → start endpoint drag.
EmptyMarqueeClick empty space (no selection) → pend marquee. Drag opens marquee; click commits empty.wg: selection-intent
EmptyDeselectThenMarqueeClick empty space with selection → emit deselect_all + pend marquee.wg: selection-intent
EmptyAdditiveMarqueeShift-click empty space → pend additive marquee (preserves selection).
EnterEditDouble-click on content → emit enter_content_edit.
ExitEditDouble-click away from edit target while in content-edit → emit exit_content_edit. Takes precedence over EnterEdit.
EmptyClearSubSelectionThenMarqueeClick 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 normalizationHost 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 membersOn 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 outlinesMulti-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 equivalenceWith identity matrix, transformed chrome emits the same overlays / hits / priorities as the rect path.
screen_obb hit-testRotated 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 angleHUDScreenRect.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 previewDuring 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 inflationBody 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 renderOne 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 haloEndpoint drag IS the rotation affordance — moving an endpoint pivots the line around the other endpoint. No separate rotation knob.
set_endpoint contractAbsolute 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 conservationcorner×2 + edge = perimeter axis (when > 0). Zones mutually exclusive — no double-count between corner and edge.
Rotation halo wraps the cornerRotation 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…
LabelLocal width × height, formatted to one decimal. Always the artwork's own dims — not the AABB of the rotated rect.
AnchorVisually-lowest OBB edge. Computed by projecting all four local corners through the matrix and picking the edge with the largest midpoint Y.
labelAngleSet to the edge's screen-space angle so the renderer's perpendicular offset (LABEL_OFFSET = 16 screen-px) rotates outward.
Multi-selectionUnion 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 wrappersurface.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 positionEach 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 insetProducer-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.
IntentsThree 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.
GeometryPer-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-testRegion: 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-priorityPADDING_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 — idleNo fill (render omitted). Hit still registered — the body becomes interactive on hover.
Paint — hoverdoc_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 handleThe 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.
GeometryBox-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-awareSide 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 rotationWhen `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…
RenderDashed HUDLine across the selection. Endpoints from cmath.ui.diagonalForDirection — the 8-case table lives in cmath, not hud.
DirectionCorner directions (ne/nw/se/sw) span opposite-corner → dragged-corner. Edge directions (n/s/e/w) resolve to a canonical diagonal.
Resize originThe 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.
VisibilityProduction: 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=5
Initialising…
Primitivesurface.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 domainsdomain.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.
IntentUniversal 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 knobOne paired overlay per vertex. Render as circle, hit as square padded to MIN_HIT_SIZE. Selected fills with chrome color.
Tangent diamond45°-rotated square, smaller than vertex. Skipped when control == vertex (degenerate). Selected uses highlight fill.
Segment outlineState 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 knobSmaller than vertex; appears at midpoint (t=0.5) or projected cursor. Suppressed while is_interacting=true.
Priority laddertangent (4) < vertex (5) < segment (8). Lower wins on overlap.
Marquee / lasso → vertex sub-selectionEmpty-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 dragtranslate_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 dragset_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 Metabend_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-dragPointer-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 — geometryEach 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 & priorityScreen-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 — paintIdle: 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 intentselect_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 — dragDrag 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…
PrimitiveHUDRule { axis, offset, color }. Full viewport extent in screen-space; offset is doc-space.
TriggersComputed 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…
PrimitiveTwo stroked HUDRects + up to 4 labelled HUDLines (guide lines, one per non-zero side) + up to 4 dashed auxiliary lines.
MovementExtra 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.
TriggerProduction: 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.
LabelPer-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 / setRulerTransformMirror 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 framePixel 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 ladderMajor-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-ownedHud 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 conventionTop 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 cleanlyHUDStage 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-sideHovered + 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 gateBelow the threshold the grid does not paint. Default threshold 4× in production hosts.
Transform syncsetPixelGridTransform(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 passthroughdefault, 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 thrashMid-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 invariantNothing in surface/, event/, or primitives/ may import from cursors/. Hosts that don't import the subpath pay zero bundle cost.
DeterministicSame 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 windowTwo 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 windowIf 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 upCounter goes 1 → 2 → 3 → … each click inside the window. Triple-click is observable; the host decides what to do with it.
Per-button isolationThe 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 taggingThe 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.
filterHUDDrawByGroupOn 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 ruleNo 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 ruleWhen 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-throughBuilt-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 costPer 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 loadDrag 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 editorEarlier 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 readout1-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.
ItemWhat it needsNote
Padding overlay (hatched)new primitiveNeeds HUDRect.hatched { angle, spacing } — solid fill only today.
Network curvenew primitiveBezier connector between nodes. Hud only has straight HUDLine.
Network edge arrowheadnew primitiveComposable from HUDLine; depends on the curve primitive landing first.
Gradient stop editornew primitiveHUDLine with color-stop array, plus per-stop draggable knobs.
Variable-width stroke stopsvector-chrome extensionPer-vertex width handles along a path. Needs new VectorOverlay variant + new gesture.
Image crop handlesnew gesture modeRe-uses the resize 9-slice math, but the intent writes the crop rect, not the layer size.
Locked indicator iconprimitive | DOMEither extend hud with HUDIcon (SVG path) or ship as a DOM badge.
Node title bar / frame nameDOM escape hatchText-heavy with double-click rename — canvas text is brittle.
Component consumer badgeDOM escape hatchSmall badge with icon; dblclick enters component via enter_content_edit.
Text caret + selection rangeDOM escape hatchRequires contenteditable / IME. Hud is not a text renderer.
Distribute-evenly buttonDOM escape hatchInteractive button positioned over the canvas.
Floating toolbarDOM escape hatchContextual action bar above the selection.
Dropzone indicator (drag-in)host extraContainer drop highlight while dragging a node from outside. Render with HUDRect fillOpacity; needs host-side drag-state plumbing.
Group member outlines (mid-drag)host extraAlready implemented for selection; needed during gesture preview too.
Sort handle (z-order swap dot)host extraRender 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.