@grida/hud 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,143 @@ The HUD is the non-content visual chrome drawn on top of the viewport: selection
10
10
 
11
11
  In industry terms: Blender calls this "Overlays", Unity calls it "Gizmos", game engines call it "HUD". We use HUD.
12
12
 
13
+ ## Bedrock layers (`core/` + `primitives/`)
14
+
15
+ > **Stability — `v0.x`, no compatibility guarantees.** The bedrock is an
16
+ > **additive foundational layer**. It ships its own value types and mechanism
17
+ > modules, fully unit-tested, but is **not yet load-bearing in production**:
18
+ > the package's `Surface` and its consumers still run on the legacy `event/`
19
+ > stack. The bedrock is reached only through the `@grida/hud/core` and
20
+ > `@grida/hud/primitives` subpaths — never the package root, which keeps the
21
+ > legacy names (`HitShape`, `RenderShape`, `CursorIcon`, …). The two coexist
22
+ > without collision until the legacy stack is grounded on the bedrock in a
23
+ > follow-up. Types here may change without a semver bump in this window.
24
+
25
+ Two layers below the named-class surface, designed once and intended to
26
+ outlive every higher-layer redesign. They import only `@grida/cmath` and each
27
+ other — no DOM, no framework, no class knowledge.
28
+
29
+ - **`core/`** (`@grida/hud/core`) — engine substrate. Independently-testable
30
+ mechanism modules: the synthesized input vocabulary (`HUDEvent`), a
31
+ multi-click classifier (`ClickTracker`), screen↔world helpers
32
+ (`Transform`), a generic name-keyed `NamedRegistry<K, T>`, and the generic
33
+ `HitRegistry<I>` + pure `shapeContains` (lower priority wins on overlap).
34
+ No master class. See [`core/README.md`](./core/README.md).
35
+
36
+ - **`primitives/`** (`@grida/hud/primitives`) — agnostic value types: the
37
+ canonical `HUDObject<I>` (a discriminated union enforcing the
38
+ `(render ∨ hit)` invariant at the type level), the `HitShape` / `RenderShape`
39
+ unions, the 4-method `Painter` seam, and cursor value types. The pre-existing
40
+ drawing primitives ship alongside; the opinionated drawers (`ruler.ts`,
41
+ `corner-radius.ts`, …) still co-locate here, flagged for relocation to
42
+ `classes/<name>/` in a follow-up. See [`primitives/README.md`](./primitives/README.md).
43
+
44
+ Bedrock anti-goals (the defensive perimeter — a request that crosses one is
45
+ "the wrong tool"): **not a plugin host** (no `register*` extension API), **not
46
+ a renderer** (`Painter` is the seam to a host renderer; bedrock paints HUD
47
+ chrome, never document content), **not a hit-test acceleration structure**
48
+ (`HitRegistry` is O(n) per query; no spatial index ships in `core/`), **not a
49
+ state mirror** (no per-class state), **not a DOM library** (no `HTMLElement`
50
+ in `core/`).
51
+
52
+ ## What this package is — two tiers
53
+
54
+ The package ships **two tiers** of API with different audiences. They are not a hierarchy — they are two products shipped from one package.
55
+
56
+ ### Tier 1 — Named classes (the inhouse vocabulary)
57
+
58
+ A **named class** is a composed, opinionated interaction model that represents one coherent editable concept. Each class encapsulates:
59
+
60
+ - A model — the math it edits, the gesture grammar that mutates it, the intent contract it commits to
61
+ - HUD-owned hover, cursor, hit priority, modifier interpretation
62
+ - A bounded feature surface — _capability_ toggles only (does this instance have padding? does it support corner radius?), not visual variants (colors, thicknesses — those are style tokens)
63
+
64
+ Named classes are the vocabulary the Grida editor uses by default. The host writes a compact binding class around `@grida/hud` that calls one setter per active named class per logical "thing" being edited. HUD does not try to _be_ the binding class — that's the god-class trap.
65
+
66
+ The hard rule that prevents god classes: **a named class is defined by its model, not its chrome.** Padding (4 side numerics), corner-radius (4 corner radii), resize-box (rect mutation), size-meter (display-only) all share "a rect" as their target — they are four classes, not features of one. If two candidates share a model and only differ visually, they are one class with a style/feature axis.
67
+
68
+ ### Tier 2 — Primitives (the open building blocks)
69
+
70
+ Two kinds of code live in `primitives/`, with different audiences:
71
+
72
+ - **Genuinely-Tier-2 atoms** — un-opinionated, externally-consumable building blocks. The draw vocabulary (`HUDRect`, `HUDPaint`, `HUDPolyline`, `HUDScreenRect`, `HUDMarker`, paint primitives), the hit-priority infrastructure, the cursor renderer protocol. External consumers building editors that don't fit any named class compose against these.
73
+
74
+ - **Class-bound math factored out of a class** — `reduceTransformBox`, corner-radius geometry, parametric-handle math. These live in `primitives/` for code organization (one file per math domain, pure-function tests without mounting the chrome), but their closure of valid actions IS the chrome's gesture grammar. An external consumer cannot use `reduceTransformBox` without re-implementing the transform-box chrome around it. They are not Tier 2 in the audience sense — they are class-bound implementation details that happen to live alongside Tier 2 atoms.
75
+
76
+ When auditing whether a new module belongs in `primitives/` vs. `classes/<name>/`, the question is _consumability_: would an external consumer building an unrelated editor use this directly? If yes, Tier 2. If no but the code is pure and reusable by tests, `primitives/` is still its home — but flag it in the module header as class-bound, and don't claim Tier 2 in docs.
77
+
78
+ ### Composition contract
79
+
80
+ The central guarantee that makes the inhouse pattern work — multiple setters from a compact host binding class targeting the same logical thing via a shared `id` — and the part this package treats as _tested_ (not emergent):
81
+
82
+ 1. Multiple setters may target the same logical thing via a shared `id`. The `id` is an opaque string the host chooses — it is the _composition-time matching key_, not a typed contract. Classes that target different concepts use different field names by convention (`node_id` for scene-node-bound classes; bare `id` for affine-bound classes like transform-box, which can edit non-node things like image fills), but two classes co-target the same logical thing iff the host hands them the same string.
83
+ 2. `HUDSemanticGroup` + the host's visibility policy gate which classes are active.
84
+ 3. The SDK owns the priority ladder. It is internal, deterministic, and stable across releases.
85
+ 4. Any combination of active classes × visibility config × id-sharing pattern produces:
86
+ - A deterministic hover state (highest-priority hit wins, ties broken by declaration order)
87
+ - A deterministic cursor (resolved from hover)
88
+ - Independent intents per class (each class commits to its own field; no shared intent payloads, no implicit coordination)
89
+ - No phantom hits, no flickering hover, no stuck cursor
90
+ 5. A **co-target test matrix** under [`__tests__/composition/`](./__tests__/composition/) enforces (4). The matrix exists today with the first cell (`padding × transform-box`, 5 assertions); each subsequent class migration adds one row + one column. The full matrix is planned out in [`__tests__/composition/README.md`](./__tests__/composition/README.md); today's coverage is one pair, growing per-migration.
91
+
92
+ #### Priority overrides — deferred
93
+
94
+ Per-instance priority bias is currently **unspecified**. `HUDSemanticGroup` + the host's visibility policy is the only host-facing knob for "what shows when." If a real consumer needs to override priority under a specific circumstance, file an issue with the concrete use case; the API choice (closed enum, numeric bias, group-driven) follows the consumer. Until then: no API. This is consistent with the promotion-bar's "two consumers shape the contract" rule.
95
+
96
+ ### Named-class conventions
97
+
98
+ - **Schema-level feature flags.** Absence of the host's `setX(...)` input is the off-state. No `SurfaceOptions.features.X` booleans to drift.
99
+ - **HUD-owned hover and modifier reading.** Hosts push state; HUD owns the live affordance.
100
+ - **Host-owned commit.** Intents stream `phase: "preview"` and a final `phase: "commit"`.
101
+ - **Anti-goals header per class.** Each named class's surface module enumerates what it deliberately is NOT, in plain English.
102
+
103
+ ### What justifies a new named class
104
+
105
+ The promotion bar — first match wins, top down. Apply this to every candidate that wants to land as a class (new model, or split-out of an existing module):
106
+
107
+ 1. **Model audit.** Does the candidate share a model (math + gestures + intent payload shape) with an existing class? If yes, **fold** — it is a feature toggle, a style axis, or a binding-target on the existing class, not a new one.
108
+ 2. **Closed gesture grammar.** Can every interaction be enumerated as a finite (target × gesture × modifier) → intent table, with no open-ended customization slot? If no, it is **host code over primitives**, not a named class.
109
+ 3. **Two consumers (or one + adversarial demo).** Is the model exercised by ≥2 distinct internal consumers, or by 1 real consumer plus a demo whose binding target differs enough to falsify accidental coupling? If no, **wait** — the contract is not yet shaped; keep it private inside the consumer.
110
+ 4. **Feature flags are capability flags.** Every flag must name a _capability_ (has padding? supports corner radius?), never a _visual variant_ (color, thickness, dash pattern — those are style tokens). Shape-of-model toggles count as capability (`single radius` vs `4 per-corner radii` is a model-shape difference, so it's a capability — but the class then commits to handling both shapes coherently in one gesture grammar; if it can't, see rule #1).
111
+ 5. **Stable identity.** One noun. `VectorPath`, not `Path-with-vertices-and-segments-and-regions`. If naming requires a phrase, the model is not yet crisp.
112
+
113
+ A candidate failing any rule routes to: fold (1), host code (2), wait (3), style token (4), or naming work (5).
114
+
115
+ ### Promotion contract
116
+
117
+ Every promoted class ships with:
118
+
119
+ - Public input type declared `@unstable` until rule #3 is satisfied for real.
120
+ - Anti-goals header in the class's `surface.ts`.
121
+ - Tests under [`__tests__/classes/<name>/`](./__tests__/) covering: feature-flag null-state, hit asymmetry (visible chrome strictly contained in hit region — Fitts'-reach), every gesture in the grammar, every intent variant, hover + cursor mapping, decision-module wiring, equality + snapshot of public types.
122
+ - One row in the [co-target test matrix](./__tests__/composition/) per (this-class, other-class) pair that may share an `id`.
123
+ - Demo section in `editor/app/(dev)/ui/components/hud/_showcase.tsx` — fixture, host adapter, intent log, every feature toggled, verified off-state.
124
+ - One row in the **Named classes** table below.
125
+
126
+ ### Named classes
127
+
128
+ The table below lists every promoted named class. The package ships them; the host's binding class consumes them.
129
+
130
+ | Class | Model | Source | Demo |
131
+ | --------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | -------------------------------------------------- |
132
+ | `padding` | 4 side numerics (top/right/bottom/left) committed by side-handle drag, optional alt-mirror | [`classes/padding/`](./classes/padding/) | `editor/app/(dev)/ui/components/hud/_showcase.tsx` |
133
+ | `transform-box` | 2×3 affine on a unit box, mutated by 4 corners (rotate) / 4 sides (scale) / body (translate) | [`classes/transform-box/`](./classes/transform-box/) | `editor/app/(dev)/ui/components/hud/_showcase.tsx` |
134
+ | `vector-path` | Path (vertices + segments + tangents + optional regions) under 10 intent variants over one model | [`classes/vector-path/`](./classes/vector-path/) | `editor/app/(dev)/ui/components/hud/_showcase.tsx` |
135
+
136
+ **Co-target coverage gap.** The promotion contract above requires one matrix row per `(this-class, other-class)` pair. Today's matrix covers `padding × transform-box` only; `vector-path`'s rows are pending (`vector-path × padding` and `vector-path × transform-box` are both `N/A` per the matrix plan since vector-path cannot share an `id` with rect-bound classes, but the N/A cells should still be marked in the matrix — see `__tests__/composition/README.md`). Tracked as a follow-up; does not block further migrations.
137
+
138
+ #### Pending migrations
139
+
140
+ Modules below predate the doctrine and are **candidates** for promotion under the rules above. Each migration is one PR.
141
+
142
+ | Existing module | Target | Audit status |
143
+ | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
144
+ | `primitives/corner-radius.ts` (chrome path) + `setCornerRadius` | `classes/corner-radius/` | pending — chrome currently inline in `surface/surface.ts:778–927`; needs extraction before relocation |
145
+ | `primitives/parametric-handle.ts` (chrome path) + `setParametricHandles` | `classes/parametric-handle/` | pending — same situation as corner-radius (inline in surface.ts) |
146
+ | Selection chrome inside `surface/chrome.ts` (resize handles, rotate handles, selection outline, hover overlay, marquee, lasso) | split per audit into `classes/{resize-box, selection-outline, marquee, lasso, …}/` | deferred — largest single migration; lands last after smaller migrations settle the contract |
147
+
148
+ See [`classes/README.md`](./classes/README.md) for the folder convention each migration adopts and the per-module promotion-bar dry-run.
149
+
13
150
  ## Why a canvas-based surface
14
151
 
15
152
  The DOM approach — positioned `<div>` handles, `data-grida-id` for hit-test, native dblclick — works until it doesn't. Concrete failures we hit before this package existed:
@@ -74,6 +211,52 @@ The `event/` layer is the testable core. It dispatches plain objects, returns pl
74
211
 
75
212
  Gesture state machine, hover indicator, modifier snapshot, cursor, click-tracker (dblclick). These have no representation outside the surface; making them surface-private is the single-source-of-truth rule.
76
213
 
214
+ ## Two backends: render and hit-testing
215
+
216
+ The HUD has two backends, and they deliberately disagree.
217
+
218
+ Every frame the HUD produces two outputs: a list of render primitives drawn to the canvas, and a registry of hit regions consulted on pointer events. They share no geometry. They are paired per affordance — the rotation handle's hit region is not derived from a rendered shape, and the resize knob's visual is not derived from its hit AABB. One backend is for the eye; the other is for the cursor.
219
+
220
+ ### Why they disagree
221
+
222
+ The two outputs optimise for different things:
223
+
224
+ - The renderer optimises for **legibility at any zoom** — small, crisp shapes that don't dominate the document.
225
+ - The hit-tester optimises for **Fitts'-law reach** — fat targets, virtual regions outside the visible shape, priority ladders that resolve overlap by intent rather than topology.
226
+
227
+ Collapsing them — drawing a 16 px knob just to match the 16 px hit AABB, or shrinking the hit AABB to 8 px just to match the visual — breaks one of the two. The split exists so neither has to compromise.
228
+
229
+ ### `OverlayElement` — the pairing primitive
230
+
231
+ `OverlayElement` is the public type that holds the discipline together. Each element carries a mandatory `hit` and an _optional_ `render`. Hit is required because every overlay must be reachable; render is optional because some overlays are deliberately invisible.
232
+
233
+ The renderer never reads from `hit`. The hit-tester never reads from `render`. The asymmetry is the point.
234
+
235
+ ### Four families of overlay
236
+
237
+ The mental model the package operates under. Every overlay the HUD draws — or the next one anyone adds — falls into one of four shapes:
238
+
239
+ | Family | Visual | Hit region | Example |
240
+ | --------------- | ----------------- | ---------------------------------------- | ------------------------------------ |
241
+ | **Paired** | Drawn shape | Same shape, padded for reach | Corner resize knob, line endpoint |
242
+ | **Virtual** | None | Region the user can click but never sees | Rotation handles, edge-resize strips |
243
+ | **Decorative** | Drawn shape | None | Snap pips, measurement labels |
244
+ | **Body-region** | Selection outline | Inner region that triggers translate | Selected shape body |
245
+
246
+ Anything new the HUD learns to do should be a deliberate choice between these — not a side effect of how it happened to be drawn.
247
+
248
+ ### Guidance for new affordances
249
+
250
+ 1. **Decide hit geometry first, visual second.** The user reaches for the hit region; the visual is a hint about where it is.
251
+ 2. **If an affordance is virtual, omit `render`.** Don't draw a stand-in just because the type allows it.
252
+ 3. **If the visual is smaller than the minimum comfortable touch target, pad the hit region.** Don't pad the visual to compensate.
253
+
254
+ ### Guidance for tests
255
+
256
+ Tests should assert against `hit` and `render` separately. A test that only checks the rendered shape silently passes when someone removes the hit padding; a test that only checks the hit region silently passes when someone drops the visual.
257
+
258
+ New affordances should add at least one assertion per side, and — where the two intentionally differ — one assertion that they differ in the expected direction (e.g. `hit` strictly contains the `render` bbox).
259
+
77
260
  ## Public API
78
261
 
79
262
  ```ts
@@ -96,6 +279,9 @@ surface.setTransform(t); // camera (screen ↔ doc)
96
279
  surface.setSelection(ids); // read-only mirror from host
97
280
  surface.setStyle(partial);
98
281
  surface.setReadonly(v);
282
+ surface.setPixelGrid({ enabled, zoomThreshold, color?, steps? }); // or null to disable
283
+ surface.setRuler({ enabled, axes?, color?, ranges?, marks?, ... }); // or null to disable
284
+ surface.setRulerTransform(t);
99
285
  surface.dispose();
100
286
 
101
287
  // Input
@@ -156,8 +342,21 @@ type SurfaceGesture =
156
342
  | { kind: "pan"; dx: number; dy: number }
157
343
  | { kind: "marquee"; rect: Rect } // screen-space
158
344
  | { kind: "translate"; ids: NodeId[]; dx: number; dy: number }
159
- | { kind: "resize"; id: NodeId; anchor: ResizeDirection; rect: Rect }
160
- | { kind: "rotate"; id: NodeId; corner: RotationCorner; angle: number };
345
+ | {
346
+ kind: "resize";
347
+ ids: NodeId[];
348
+ direction: ResizeDirection;
349
+ initial_shape: SelectionShape;
350
+ current_shape: SelectionShape;
351
+ }
352
+ | {
353
+ kind: "rotate";
354
+ ids: NodeId[];
355
+ corner: RotationCorner;
356
+ anchor_angle: number;
357
+ current_angle: number;
358
+ }
359
+ | { kind: "endpoint"; id: NodeId; endpoint: "p1" | "p2" };
161
360
  ```
162
361
 
163
362
  ### Intents
@@ -171,12 +370,29 @@ type Intent =
171
370
  | { kind: "translate"; ids: NodeId[]; dx: number; dy: number; phase: Phase }
172
371
  | {
173
372
  kind: "resize";
174
- id: NodeId;
373
+ ids: NodeId[];
175
374
  anchor: ResizeDirection;
375
+ /** AABB of the new shape (for axis-aligned hosts). */
376
+ rect: Rect;
377
+ /** Full new shape — `transformed` carries the matrix so rotated
378
+ * hosts can resize in the local frame. Optional for backward-compat. */
379
+ shape?: SelectionShape;
380
+ phase: Phase;
381
+ }
382
+ | { kind: "rotate"; ids: NodeId[]; angle: number; phase: Phase }
383
+ | {
384
+ kind: "marquee_select";
176
385
  rect: Rect;
386
+ additive: boolean;
387
+ phase: Phase;
388
+ }
389
+ | {
390
+ kind: "set_endpoint";
391
+ id: NodeId;
392
+ endpoint: "p1" | "p2";
393
+ pos: [number, number];
177
394
  phase: Phase;
178
395
  }
179
- | { kind: "rotate"; id: NodeId; angle: number; phase: Phase }
180
396
  | { kind: "enter_content_edit"; id: NodeId }
181
397
  | { kind: "cancel_gesture" };
182
398
 
@@ -206,6 +422,28 @@ function Viewport() {
206
422
  }
207
423
  ```
208
424
 
425
+ ## Cursors
426
+
427
+ The HUD owns cursor state (`SurfaceState.setCursor` → `surface.cursor()`), but **does not own cursor pixels.** The host receives a `CursorIcon` and decides what CSS `cursor:` value to apply.
428
+
429
+ For hosts that want Grida's default Figma-style rotation/resize cursors — curved double-arrows for rotate, straight double-arrows for resize, both following the selection's screen-space rotation — wire the opt-in renderer from the dedicated subpath:
430
+
431
+ ```ts
432
+ import { cursors } from "@grida/hud/cursors";
433
+
434
+ surface.setCursorRenderer(cursors.defaultRenderer());
435
+ ```
436
+
437
+ **Tree-shake invariant.** Nothing in `surface/`, `event/`, or `primitives/` may import from `cursors/`. Hosts that don't import the subpath pay zero bundle cost. Enforced by `__tests__/cursors.test.ts`.
438
+
439
+ The subpath also exposes the SVG templates and the `data:` URL encoder for hosts that want to render cursor previews in sidebar UI without going through the Surface:
440
+
441
+ ```ts
442
+ import { cursors } from "@grida/hud/cursors";
443
+ const svg = cursors.templates.rotate(45); // angle in degrees
444
+ const url = cursors.svgDataUrl(svg);
445
+ ```
446
+
209
447
  ## Selection intent
210
448
 
211
449
  The full classification table — every named scenario at pointer-down, what
@@ -232,7 +470,13 @@ Skim the spec before changing the classifier or its dispatch.
232
470
  - Hit-test happens in two tiers on `pointer_down`:
233
471
  1. **UI layer (screen-space AABB)** — surface's own `HitRegions` registry, populated by chrome builder each frame. Resize handle? Rotation handle? Body region (translate)? Direct path; no host involvement.
234
472
  2. **Scene layer (doc-space point)** — if no UI hit, surface converts the point screen→doc and calls `pick(point_doc)`. Host implements this with whatever it has (`elementFromPoint`+`data-id` for SVG-DOM hosts, scene-cache R-tree for cg).
235
- - `shapeOf(id)` returns a **doc-space** `SelectionShape` (`{ kind: "rect", rect }` for most nodes, `{ kind: "line", p1, p2 }` for vector lines). Surface converts to screen for handle placement and screen-space chrome.
473
+ - The UI-tier hit registry is built independently of the render path see [Two backends: render and hit-testing](#two-backends-render-and-hit-testing).
474
+ - `shapeOf(id)` returns a **doc-space** `SelectionShape`:
475
+ - `{ kind: "rect", rect }` — axis-aligned (most nodes)
476
+ - `{ kind: "line", p1, p2 }` — vector lines
477
+ - `{ kind: "transformed", local, matrix }` — anything with a non-identity 2×3 affine (rotation, skew, non-uniform scale, mirror). `local` is the artwork's local-frame AABB; `matrix` maps local → doc. Identity matrix here is byte-equivalent to `{ kind: "rect", rect: local }`.
478
+
479
+ See [Transformed selections](#transformed-selections) below for what the HUD does with the `transformed` variant.
236
480
 
237
481
  Handles are always drawn at a fixed screen-space size regardless of zoom. The primitive layer supports this via `HUDScreenRect` (see below).
238
482
 
@@ -249,12 +493,202 @@ Handles are always drawn at a fixed screen-space size regardless of zoom. The pr
249
493
 
250
494
  Layer order within a frame (back-to-front):
251
495
 
496
+ 0. Pixel grid (when enabled and `transform[0][0] > zoomThreshold`)
252
497
  1. Hover outline
253
498
  2. Selection outline
254
499
  3. Marquee rect
255
500
  4. Resize/rotate handles
256
501
  5. Size meter pill
257
- 6. Host-fed extras (always on top)
502
+ 6. Host-fed extras
503
+ 7. Ruler strips (when enabled — top + left L-shape, screen-space)
504
+
505
+ ### Substrate vs frame — why pixel grid and ruler sit at opposite ends
506
+
507
+ The two named viewport chromes — pixel grid and ruler — share the
508
+ inlining mechanism (a pure `draw*(ctx, params)` routine over the HUD's
509
+ existing context), but they belong to **different paint-order
510
+ families**:
511
+
512
+ - **Substrate (back-most).** Pixel grid is content-space: it lives
513
+ with the document, the user reads it _under_ everything else, and
514
+ its only job is to give content something to align to. Selection
515
+ chrome, marquee, and host extras are what the user is _interacting
516
+ with_; the grid is decoration that informs the interaction. So it
517
+ paints first.
518
+ - **Frame (top-most).** Ruler is viewport-space: it frames the editing
519
+ area, the way a window title bar frames a document. The ticks
520
+ reference world coordinates a selection might cross, but the strip
521
+ itself is "outside" the editing surface. A selection outline,
522
+ marquee, or handle that visually escapes into the strip reads as
523
+ broken in the same way a document scrolling under a title bar
524
+ reads as broken. So it paints last — after the pixel grid, after
525
+ surface chrome, and after host extras.
526
+
527
+ Every major editor (Figma, Sketch, XD, Illustrator, Affinity,
528
+ OmniGraffle) paints the ruler on top of content chrome. The HUD
529
+ follows that convention. The corner square (`strip × strip` at
530
+ origin) is deliberately left blank — hosts that want to fill it
531
+ draw it via the host-fed extras pass, which sits beneath the
532
+ ruler and is therefore correctly clipped by the strip on top.
533
+
534
+ Each strip also paints a **1-px inner-edge separator** early in
535
+ its own pass — right after the background fill, before any range
536
+ accent, mark, or step tick. The line where the strip meets the
537
+ editing area is the universal affordance every editor ships:
538
+ without it the strip visually bleeds into content. Painting it
539
+ beneath the data means a full-strip mark (`strokeHeight: strip`)
540
+ reads as one continuous stroke crossing the strip boundary into
541
+ the canvas guide below — instead of being capped by the separator
542
+ on top. The two roles answer different questions and earn
543
+ different paint slots: the separator is cosmetic chrome ("where
544
+ does the strip end?"), the ticks and marks are data ("what's the
545
+ position?"); chrome below, data above. The separator obeys the
546
+ `axes` filter — `axes: ["x"]` paints only the top-strip separator,
547
+ `axes: ["y"]` only the left-strip separator. The two separators
548
+ meet at right angles inside the corner square; nothing else is
549
+ painted there.
550
+
551
+ **Separator color is independent of tick color.** `RulerConfig`
552
+ exposes a separate `borderColor?` token that defaults to `color`
553
+ (the tick color) for backward compatibility, but the two are
554
+ deliberately decoupled. Every production editor (Figma, Sketch,
555
+ XD, Illustrator, Affinity, and our own main editor) paints the
556
+ separator distinctly LIGHTER than the ticks — the ticks must
557
+ read as numerals, the separator only marks the edge. A single
558
+ shared color cannot satisfy both responsibilities. Hosts that
559
+ want the main-editor look should pass a light neutral as
560
+ `borderColor` (e.g. an OKLCH or hex value matching their design
561
+ system's `border` token) while keeping `color` at the standard
562
+ mid-gray for the ticks.
563
+
564
+ **Marks (guide positions).** `RulerConfig.marks` accepts per-axis
565
+ arrays of `RulerMark`. A minimal `{ pos }` mark renders as a regular
566
+ step tick — short stroke, label color = the ruler's `color`. The
567
+ extra fields cover the standard guide-position affordance every
568
+ editor ships: a full-strip line with an accent stroke + label color:
569
+
570
+ ```ts
571
+ interface RulerMark {
572
+ pos: number;
573
+ /** Tick stroke + (default) label color. */
574
+ color?: string;
575
+ /** Label text. */
576
+ text?: string;
577
+ /** Override the stroke color independently of the label color. */
578
+ strokeColor?: string;
579
+ /** Stroke width in CSS pixels. Default 1. */
580
+ strokeWidth?: number;
581
+ /**
582
+ * Stroke height in CSS pixels. Default `tickHeight`. Pass `strip`
583
+ * (the strip width) for a full-strip mark — the standard
584
+ * guide-position affordance.
585
+ */
586
+ strokeHeight?: number;
587
+ /** Label color. Defaults to `color` if omitted. */
588
+ textColor?: string;
589
+ /** Label alignment. Default "center". */
590
+ textAlign?: CanvasTextAlign;
591
+ /** Label position offset from `pos`. Default 0. */
592
+ textAlignOffset?: number;
593
+ }
594
+ ```
595
+
596
+ To paint a guide position the way the rest of the editor renders
597
+ guides — full-strip accent line with a same-colored label — pass
598
+ `strokeHeight: strip` (matching `RulerConfig.strip`, default 20)
599
+ along with an accent `color` / `strokeColor`. Defaults are chosen
600
+ so omitting every field except `pos` keeps rendering identically
601
+ to a step tick — additive, no regressions for existing callers.
602
+
603
+ **Drag threshold.** Hosts implementing drag-from-strip to create
604
+ guides should use `DEFAULT_RULER_DRAG_THRESHOLD` (4 px) as the
605
+ minimum pointer-movement distance from pointer-down before
606
+ committing a new guide — without it, a stray click on the strip
607
+ spawns an unwanted guide. The constant is a published recommendation,
608
+ not a runtime gate; hud does not own the gesture.
609
+
610
+ If a future chrome turns out to be neither a substrate nor a frame,
611
+ the right move is to add a new paint slot deliberately — not to
612
+ hard-code it next to one of the existing ones by analogy.
613
+
614
+ ## Transformed selections
615
+
616
+ When a host returns `{ kind: "transformed", local, matrix }` from `shapeOf`, the HUD renders the chrome — outline, knobs, edge strips, rotation halos, size badge, dashed resize preview — in the artwork's own frame. Knobs rotate with the parent. The size badge reads `local.width × local.height`, not the AABB of the rotated rect. The cursor's `baseAngle` follows the matrix so resize/rotate arrows stay aligned with the selection's tilt.
617
+
618
+ **Render** uses lazy transforms — every rotated primitive carries an optional angle field; the canvas wraps the draw call in a `translate/rotate/restore`:
619
+
620
+ | Primitive | Field | Effect |
621
+ | --------------- | ------------------------------------ | ------------------------------------------------------ |
622
+ | `HUDScreenRect` | `angle?: number` (radians, CCW) | Rotates the rect around its screen-space center. |
623
+ | `HUDLine` | `labelAngle?: number` (radians, CCW) | Rotates the label pill around its screen-space center. |
624
+
625
+ **Hit-test** uses the same lazy transform via a new `HitShape` variant:
626
+
627
+ ```ts
628
+ type HitShape =
629
+ | { kind: "screen_rect_at_doc"; anchor_doc; width; height }
630
+ | { kind: "screen_aabb"; rect }
631
+ | { kind: "screen_obb"; rect; inverse_transform }; // ← new
632
+ ```
633
+
634
+ `screen_obb` carries the un-rotated zone rect (in shadow space, centered at the chrome's screen center) plus an `inverse_transform` that maps a screen-space pointer INTO shadow space. The hit-test loop applies the inverse to the pointer, then runs the usual AABB containment. **No AABB-of-rotated-corners inflation** — clicks outside the visible rotated chrome don't trigger phantom resize regions, regardless of aspect ratio or rotation. The 9-slice priority ladder operates in the same coordinate frame as the axis-aligned `rect` path, so promotion/demotion rules behave identically.
635
+
636
+ The `resize` gesture operates in the local frame: `applyResize` takes a `SelectionShape` and returns a `SelectionShape`, with deltas inverse-transformed into local space for `transformed` shapes. The emitted `Intent` carries both `rect` (AABB) and `shape` (full local + matrix) so legacy axis-aligned hosts keep working while transform-aware hosts can write the new dims back into the artwork without touching the matrix.
637
+
638
+ **v1 caveats.** Pure rotation is exact at every level (render, hit, gesture). Skew and non-uniform scale render correctly but use a uniform-scale fallback for handle sizing — anisotropic per-axis sizing is a follow-up.
639
+
640
+ ## Vector regions
641
+
642
+ A **region** is a closed loop of segments — a "face" of the vector network — that the user can click on to select. The interior fills with a diagonal-stripe pattern on hover; on commit, the same paint at higher opacity reads as the selected affordance. Drag from the interior translates the loop's segments and endpoint vertices.
643
+
644
+ Region picking is part of the core interaction model for path content-edit on closed sub-paths (without it, the user can't get from "hovering the artwork" to "editing this loop" without aiming at a thin segment outline). But the data — closed-loop enumeration — varies by backend: some hosts derive faces via planar-graph traversal (e.g. `vn.VectorNetworkEditor.getLoops()`); others can't.
645
+
646
+ **The feature flag is the schema.** Hosts that can enumerate loops populate `VectorOverlay.regions`; hosts that can't omit the field. Absence is the off-state. No separate boolean to keep in sync with the data, no runtime `if (regionsEnabled)` branches — the shape of what the host hands the HUD IS the flag.
647
+
648
+ ```ts
649
+ type VectorOverlay = {
650
+ vertices: ReadonlyArray<readonly [number, number]>;
651
+ segments?: ReadonlyArray<{ a; b; a_control; b_control }>;
652
+ neighbours?: ReadonlyArray<number>;
653
+ /** Schema-level feature flag — omit if not supported. */
654
+ regions?: ReadonlyArray<{ segments: ReadonlyArray<number> }>;
655
+ origin?: readonly [number, number];
656
+ };
657
+ ```
658
+
659
+ Each region names the segment indices forming one closed loop, in traversal order. The HUD reconstructs the cubic path at chrome build time from `vertices + segments[region.segments[i]]` — regions carry no own geometry, so "region equals closed loop of segments" stays a structural truth.
660
+
661
+ ### Selection mirror
662
+
663
+ Hosts push the host-authoritative region selection through `setVectorSelection({ ..., regions: [N] })`. The field is optional for backward compat — `undefined` is treated as `[]`. The chrome reads it each frame to apply the `selected` paint.
664
+
665
+ ### Hit-test
666
+
667
+ Region hit-test is **polygon-in-screen-space**: the chrome builder rasterises the cubic loop to N samples per segment and registers a screen-space AABB paired with a `customHitTest` closure that runs `cmath.polygon.pointInPolygon`. Same AABB-plus-refinement model the segment-strip uses — no new geometry primitive in `HitRegions`, no separate doc-space path-hit infrastructure.
668
+
669
+ ### Priority
670
+
671
+ `REGION_PRIORITY = 9` — strictly above `SEGMENT_STRIP_PRIORITY (8)`, so any vertex / tangent / ghost / segment-strip control within the loop wins on overlap; strictly below the implicit "no overlay → empty-space miss" so an interior click selects the region instead of falling through to the marquee.
672
+
673
+ ### Paint states
674
+
675
+ | State | Render |
676
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
677
+ | idle | No fill — render omitted entirely. The hit region is registered, but the body is visually transparent. |
678
+ | hover | `doc_polyline` with `fillPaint = style.vectorRegionHoverPaint` (default: `HUDPaintStripes` at 45° / 8px / 1.5px, accent color, 50% opacity). |
679
+ | selected | Same shape, `fillPaint = style.vectorRegionSelectedPaint` (default: same stripes at 70% opacity). |
680
+
681
+ Hover wins over selected — matches the precedence on every other vector overlay (vertex / tangent / segment / ghost knobs).
682
+
683
+ ### Intent
684
+
685
+ ```ts
686
+ | { kind: "select_region"; node_id: NodeId; region: number; mode: SelectMode }
687
+ ```
688
+
689
+ Eager at pointer-down (parity with `select_segment` on the "not yet in axis-set" branch). Shift toggles, no-shift replaces — the host's commit policy decides whether to also propagate the loop's segments into the sub-selection mirror.
690
+
691
+ Drag from the region body promotes to the existing `translate_vector_selection` intent (no new translate 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 back into the sub-selection mirror.
258
692
 
259
693
  ## Primitives — what `HUDCanvas` can draw
260
694
 
@@ -270,6 +704,8 @@ Layer order within a frame (back-to-front):
270
704
 
271
705
  `HUDDraw` is a plain command struct grouping these. Builders (`snapGuideToHUDDraw`, `measurementToHUDDraw`, …) take host state and return a `HUDDraw` — the surface uses the same mechanism internally for its chrome.
272
706
 
707
+ Every primitive may also carry an optional semantic `group` string. The HUD package does not define the vocabulary; hosts name their own groups, assign them to surface chrome via `SurfaceOptions.groups`, and return hidden groups from `SurfaceOptions.visibility`. Groups are visibility policy, not paint order, so a host can suppress whole UI families during a gesture while leaving unrelated extras alone.
708
+
273
709
  ## Module layout
274
710
 
275
711
  ```
@@ -277,29 +713,46 @@ packages/grida-canvas-hud/
277
713
  ├── README.md
278
714
  ├── index.ts # Surface, HUDCanvas, public types
279
715
  ├── react.tsx # useHUDSurface hook
280
- ├── primitives/ # UIdumb render shapes
716
+ ├── primitives/ # Tier 2 un-opinionated draw + math atoms
281
717
  │ ├── canvas.ts # HUDCanvas
282
718
  │ ├── types.ts # HUDDraw, HUDLine, HUDRect, HUDPolyline, HUDRule, HUDScreenRect
719
+ │ ├── pixel-grid.ts # inlined pure draw routine (sibling: @grida/pixel-grid)
720
+ │ ├── ruler.ts # inlined pure draw routine (sibling: @grida/ruler)
283
721
  │ ├── snap-guide.ts # HUDDraw builder
284
722
  │ ├── measurement-guide.ts # HUDDraw builder
285
723
  │ ├── marquee.ts # legacy HUDDraw builder (surface emits its own marquee)
286
724
  │ └── lasso.ts # HUDDraw builder
287
- ├── event/ # the math core
725
+ ├── event/ # the math core (shared dispatch, cross-class)
288
726
  │ ├── event.ts # SurfaceEvent, Modifiers, PointerButton
289
727
  │ ├── gesture.ts # SurfaceGesture + transitions
290
728
  │ ├── hit-regions.ts # screen-space AABB region registry
291
729
  │ ├── handles.ts # 8 resize + 4 rotate geometry & hit-test
292
730
  │ ├── click-tracker.ts # dblclick / multi-click detection
293
731
  │ ├── cursor.ts # CursorIcon, ResizeDirection, RotationCorner
294
- │ ├── intent.ts # Intent types + builders
732
+ │ ├── intent.ts # Intent types + builders (unions across all classes)
295
733
  │ ├── transform.ts # screen ↔ doc helpers
296
734
  │ └── state.ts # SurfaceState — pure dispatch entry
297
- └── surface/ # the wired class
298
- ├── surface.ts # Surface class
299
- ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
300
- └── style.ts # HUDStyle defaults + merge
735
+ ├── classes/ # Tier 1 — promoted named classes (see classes/README.md)
736
+ ├── README.md # folder convention + per-class file layout + promotion-bar dry-run
737
+ ├── padding/ # migrated from surface/padding-overlay.ts
738
+ │ ├── transform-box/ # migrated from surface/transform-box.ts
739
+ │ └── vector-path/ # migrated from surface/vector-chrome.ts
740
+ ├── surface/ # orchestration only
741
+ │ ├── surface.ts # Surface class — still hosts inline chrome for corner-radius + parametric-handle (pending extraction)
742
+ │ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf (pre-migration host of selection chrome — split deferred)
743
+ │ └── style.ts # HUDStyle defaults + merge
744
+ ├── cursors/ # opt-in subpath: @grida/hud/cursors
745
+ │ ├── index.ts # cursors.defaultRenderer(), templates, encoder
746
+ │ ├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
747
+ │ ├── templates.ts # parameterized SVG cursor templates
748
+ │ └── encode.ts # SVG → data: URL
749
+ └── __tests__/
750
+ ├── composition/ # NEW — co-target matrix (see __tests__/composition/README.md)
751
+ └── … # per-class tests relocate under __tests__/classes/<name>/ as migrations land
301
752
  ```
302
753
 
754
+ The shared dispatcher (`event/state.ts` and friends) stays unified across all classes — hover, hit-test, cursor resolution are _across_ classes. Per-class folders contribute their intent variant + priority constants, which `event/intent.ts` unions over.
755
+
303
756
  ## Naming conventions
304
757
 
305
758
  - File names are **kebab-case `.ts`** throughout.
@@ -307,31 +760,207 @@ packages/grida-canvas-hud/
307
760
  - Public method names on `Surface` and `HUDCanvas` are **camelCase** (`setSize`, `setTransform`, `setSelection`) — matches the existing primitive renderer API.
308
761
  - The top-level class is **`Surface`**, not `HUDSurface`. The package name already says "hud".
309
762
 
310
- ## Testing
311
-
312
- `packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas mock — the `event/` layer is pure.
763
+ ## UX Testing
764
+
765
+ `packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas
766
+ mock, no real pointer events — the `event/` layer is pure and the tests are
767
+ plain function calls.
768
+
769
+ ### Why UX testing is a first-class concern here
770
+
771
+ The HUD is small in code volume but dense in UX _semantics_. A single
772
+ `if (click_count >= 2) return Scenario.EnterEdit` is one line, but it encodes
773
+ a rule a user expects to work everywhere ("double-click enters edit"). The
774
+ code can't explain the _why_: why dblclick on a vertex doesn't exit, why
775
+ single-click on a body region defers, why a shift-click on an already-
776
+ selected node doesn't immediately add — these are UX choices, not
777
+ implementation details.
778
+
779
+ That creates a maintenance hazard. The HUD is **configurable** — hosts pass
780
+ styles, intent handlers, scene callbacks; small refactors land all the
781
+ time. With no visible behaviour to inspect (we draw to canvas; we synthesize
782
+ events), a UX rule can be silently dropped without anyone noticing until a
783
+ user complains. There is no browser test guarding us; there is no human in
784
+ the loop on every PR.
785
+
786
+ The fix is **doctrine: every default UX behaviour is locked by a unit test
787
+ that describes the behaviour in plain English.** The test name is the
788
+ spec. The body proves the code obeys it. Together they form a
789
+ machine-checked contract that says: "this is what the HUD does by default,
790
+ and if you change it you must change the test on purpose."
791
+
792
+ These tests are pure, headless, and fast. They are _not_ end-to-end browser
793
+ tests. They run on the `event/` and `surface/` modules directly because the
794
+ HUD was deliberately built so that every UX decision is computable from
795
+ inputs alone — `classifyScenario(input) → Scenario`, `decidePointerDown(input) → Decision`,
796
+ `SurfaceState.dispatch(event, deps) → response + intents`. Anything the user
797
+ will observe in a browser is the deterministic image of one of those
798
+ functions; if the function returns the wrong thing in a unit test, the
799
+ browser would show the wrong thing.
800
+
801
+ ### What counts as a UX spec test
802
+
803
+ A UX spec test is a plain unit test with three properties:
804
+
805
+ 1. **The `it("…")` description names the UX rule in natural language.** Not
806
+ "returns the right value" — "dblclick on empty space while in
807
+ content-edit emits exit_content_edit." Read in isolation, the test name
808
+ should describe what the user sees.
809
+ 2. **The body checks behaviour, not implementation.** Assert on the
810
+ emitted intents, the returned `Decision`, the resulting `HUDDraw`
811
+ primitive set — not on private state shape.
812
+ 3. **A comment above explains _why_ — the design intent.** This is the
813
+ piece the code can't carry. "Exit takes precedence over enter when
814
+ in-content-edit; the user clicking outside the edit clearly wants out,
815
+ not to re-enter on a different node." Future readers (humans, agents,
816
+ reviewers) need to know what we were defending against, not just the
817
+ green checkmark.
818
+
819
+ A test that satisfies all three is the smallest unit of UX truth we have
820
+ about the HUD's defaults.
821
+
822
+ ### When to add one
823
+
824
+ Add a UX spec test for every default behaviour that a host could
825
+ silently break by editing the code. In practice: every named branch in
826
+ `classifyScenario`, every kind in `PointerDownDecision`, every emitted
827
+ intent shape, every chrome primitive count tied to a state. If the
828
+ behaviour is configurable (e.g. style tokens), the test pins the _default_
829
+ — the rule under `DEFAULT_STYLE` or under no overrides. Configurability
830
+ doesn't excuse the absence of a default spec; it raises the bar for
831
+ having one.
832
+
833
+ When you change UX on purpose, you update the test at the same time. A
834
+ PR that touches a UX rule without touching the matching test is a smell;
835
+ a PR that flips a test's assertion without changing the test name is a
836
+ near-certain regression.
837
+
838
+ ### How to structure a UX spec
839
+
840
+ Match the style already in use across `__tests__/`:
313
841
 
314
- | File | Tests |
315
- | ----------------------- | ------------------------------------------------------------------------------------------------- |
316
- | `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
317
- | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
318
- | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
319
- | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
320
- | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
321
- | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
322
- | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview\*N commit) |
323
- | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
842
+ ```ts
843
+ // UX spec: dblclick away while editing exits content-edit.
844
+ //
845
+ // While a vector sub-selection is mirrored on the surface, a dblclick
846
+ // that does NOT land on a vector control (vertex / tangent / segment-
847
+ // strip) classifies as ExitEdit. Without this the user has no way out
848
+ // of edit mode except keyboard or the host's own UI, which surveys say
849
+ // is the #1 confusion in vector editors.
850
+ it("classifies dblclick on empty space WHILE in content-edit as ExitEdit", () => {
851
+ const i = input({ click_count: 2, in_content_edit: true });
852
+ expect(classifyScenario(i)).toBe(Scenario.ExitEdit);
853
+ expect(decidePointerDown(i)).toEqual({ kind: "exit_edit" });
854
+ });
855
+ ```
324
856
 
325
- Render output (visual canvas correctness) is verified in the browser, not unit-tested.
857
+ Top comment explains design intent. Test name names the UX rule. Assertion
858
+ locks the behaviour. Three layers, all required.
859
+
860
+ ### Default UX behaviours locked by tests
861
+
862
+ Non-exhaustive index — open the test files for the full surface.
863
+
864
+ | Behaviour | Pinned in |
865
+ | ------------------------------------------------------------------------------------------- | ---------------------------------------------- |
866
+ | Single-click on unselected node → immediate select | `decision.test.ts` |
867
+ | Single-click on already-selected node → defer (drag is a live candidate) | `decision.test.ts`, `state.test.ts` |
868
+ | Shift-click on selected node → defer (toggle-remove vs drag is ambiguous) | `decision.test.ts` |
869
+ | Click in body region with selection → always defer (drag claim) | `decision.test.ts` |
870
+ | Dblclick on content → emits `enter_content_edit` | `decision.test.ts`, `state.test.ts` |
871
+ | Dblclick on empty space / other node WHILE in content-edit → emits `exit_content_edit` | `decision.test.ts`, `state.test.ts` |
872
+ | Dblclick on vertex / tangent / segment-strip WHILE in content-edit → handler runs (no exit) | `decision.test.ts` |
873
+ | Marquee starts from empty-space pointer-down | `state.test.ts` |
874
+ | Drag past threshold cancels a deferred select (drag-vs-click discriminator) | `state.test.ts` |
875
+ | Tangent knob renders as a 45°-rotated square ("diamond"), smaller than vertex | `classes/vector-path/surface-extended.test.ts` |
876
+ | Vertex knob renders as a circle, selected fills with chrome color | `classes/vector-path/surface-extended.test.ts` |
877
+ | Selected tangent line is thicker than idle | `classes/vector-path/surface-extended.test.ts` |
878
+ | Segment outline: idle gray → hover @ 50% accent → selected solid accent | `classes/vector-path/segment-render.test.ts` |
879
+ | Segment strip emits N inner samples per cubic, t ∈ (0, 1) | `classes/vector-path/surface-extended.test.ts` |
880
+ | Priority ladder: tangent (4) < vertex (5) < segment (8) | `classes/vector-path/surface-extended.test.ts` |
881
+ | Rotation-aware cursor CSS via `cursors.defaultRenderer` | `cursors.test.ts` |
882
+ | Click-tracker: single vs double within window + position threshold | `click-tracker.test.ts` |
883
+
884
+ ### Test file index
885
+
886
+ | File | Domain pinned by tests |
887
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
888
+ | `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
889
+ | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
890
+ | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
891
+ | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
892
+ | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
893
+ | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates, exit-edit |
894
+ | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview·N → commit) |
895
+ | `decision.test.ts` | one test per named scenario in the selection-intent classifier, including ExitEdit gating |
896
+ | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
897
+ | `chrome-transformed.test.ts` | `SelectionShape.transformed` end-to-end |
898
+ | `chrome-priority.test.ts` | overlay priority ladder over scenarios |
899
+ | `cursors.test.ts` | `cursors.defaultRenderer` produces rotation-aware CSS values; tree-shake invariant |
900
+ | `classes/vector-path/surface.test.ts` | vector chrome baseline: vertex emission, neighbouring filter, hit-region pad |
901
+ | `classes/vector-path/surface-extended.test.ts` | vector chrome UX: tangent diamond shape + size, selection highlight, priority ladder |
902
+ | `classes/vector-path/segment-render.test.ts` | segment outline state machine: idle / hover / selected styling |
903
+ | `classes/vector-path/region.test.ts` | vector regions: closed-loop fill (idle / hover / selected), polygon hit-test, REGION_PRIORITY ladder |
904
+ | `classes/vector-path/gesture.test.ts` | vector-specific gesture state machine: tangent drag, segment-strip click→split, drag→bend |
905
+ | `classes/padding/surface.test.ts` | padding overlay: per-side rect geometry, mirror handles, hover stripe / drag outline, priority ladder |
906
+ | `classes/transform-box/surface.test.ts` | transform-box chrome: quad corners, side strips, body translate, cursor rotation, container-rotation de-rotation |
907
+ | `ruler.test.ts` | inlined ruler draw routine: layout helpers (step / subtick / range-merge), drawRuler call sequence, HUDCanvas wiring (setRuler/setRulerTransform, substrate-vs-frame paint-order: ruler top-most over chrome and extras, pixel-grid back-most) |
908
+
909
+ Render output (visual canvas correctness — paint order, anti-aliasing, the
910
+ actual pixel result) is verified in the browser, not unit-tested. Every UX
911
+ _behaviour_ is.
912
+
913
+ ## Extending the HUD
914
+
915
+ The HUD intentionally exposes no generic "register a painter" or "register a
916
+ layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real
917
+ needs, in this order of preference:
918
+
919
+ 1. **Named built-in chrome.** Things every Grida editor wants — pixel grid,
920
+ ruler, selection, snap guides, measurement — live inside this package
921
+ as first-class features with their own toggles (e.g. `setPixelGrid`,
922
+ `setRuler`, `setStyle`, the chrome built from `SurfaceState`). New
923
+ canonical chrome lands here; open a PR against `@grida/hud`.
924
+
925
+ Inlined chrome carries an explicit synchronisation contract: each
926
+ inlined primitive (`primitives/pixel-grid.ts`, `primitives/ruler.ts`,
927
+ …) documents its upstream sibling in a header comment. Bug fixes must
928
+ land on both sides. The bar for inlining a new chrome is that it
929
+ resolves into a single pure draw routine over an existing context —
930
+ anything that wants to own its own canvas, its own DPR, or its own
931
+ stateful renderer class belongs in a sibling package the host mounts,
932
+ not here. See "Not a renderer" under Anti-goals.
933
+
934
+ 2. **Host-fed `HUDDraw` extras.** Pass extra primitives into `surface.draw(extra)`
935
+ per frame. Best for transient, gesture-coupled overlays the host already
936
+ computes (measurement lines, custom snap visualizers). Drawn on top of the
937
+ substrate-band and content-band chrome (pixel grid, selection, marquee,
938
+ handles, size meter) but **beneath the frame-band chrome** (ruler). If a
939
+ host extra is meant to occupy the ruler strip (a corner-square fill, a
940
+ ruler-strip widget), draw it as an extra and let the ruler clip the
941
+ bleed — don't try to paint above the ruler. The HUD reserves the right
942
+ to add more frame-band chrome later; hosts should not build patterns
943
+ that depend on extras being the absolute top layer.
944
+
945
+ 3. **DOM-level escape hatch.** The host owns the container element; the surface
946
+ only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay
947
+ (HTML toolbar, popover, debug widget) can splice their own DOM into the
948
+ container directly. Deliberate escape hatch — reach for it only when (1) and
949
+ (2) don't fit, and prefer pushing canonical needs into (1) over keeping them
950
+ here.
326
951
 
327
952
  ## Anti-goals
328
953
 
329
- - **Not a renderer.** `primitives/HUDCanvas` is intentionally minimal Canvas2D. Skia / WebGL backends are not in scope.
954
+ - **Not a renderer.** `primitives/HUDCanvas` is intentionally minimal Canvas2D. Skia / WebGL backends are not in scope. Inlined chrome (pixel grid, ruler) must reduce to a single pure draw routine over the existing ctx — no nested canvases, no stateful renderer classes, no internal DPR ownership. Anything heavier belongs in a sibling package the host mounts. Incremental affordances inside an existing inlined routine (e.g. the ruler's inner-edge separator) are fine when they share that routine's transform and state; they are not the same as introducing a new renderer.
330
955
  - **Not a scene graph.** Surface never reads node data — only via `pick` / `shapeOf`.
331
956
  - **Not a host of plugins.** No widget registry. Custom HUD elements go through host-fed `HUDDraw` extras.
957
+ - **Not a kitchen of decorative-line helpers.** The `*GuideToHUDDraw` family (`snapGuideToHUDDraw`, `measurementToHUDDraw`) exists to translate _rich cmath domain structs_ — `SnapGuide`, `Measurement` — into multi-element draw lists where the layout rules are the work. They are not, and must not become, thin aliases for "produce one primitive of a named flavor." If an affordance is `{ one HUDLine | HUDRect | HUDPoint } + { color }` over geometry that already lives elsewhere, the host composes it directly. Worked example: the aspect-ratio guide (a single dashed `HUDLine` whose endpoints come from an 8-case `CardinalDirection` table) lives entirely outside this package — the geometry is `cmath.ui.diagonalForDirection`, the render is one host-side object literal. Refusing the alias keeps the public surface narrow and the promotion bar honest.
332
958
  - **Not undo-aware.** Intents carry `phase`; host owns undo.
333
959
  - **Not a selection store.** Host owns selection; surface mirrors.
334
960
  - **Not SVG-aware.** No `data-id`, no DOM IR, no `<style>` resolution.
961
+ - **Paint kinds: closed taxonomy, not open registry.** `HUDPaint` is a discriminated union HUD ships (`solid`, `stripes` today). Adding a kind requires a HUD PR with ≥2 internal consumers shaped — same promotion contract as any new primitive. There is no runtime registration of paint kinds; a future `bitmap` (host-rasterized escape hatch) is the only deliberate widening on the table, and it lands only when a real second consumer asks. Built-in kinds are HUD-owned: HUD chooses rasterization quality and zoom behavior; hosts pass theming (`color`) and dimension knobs only. The chrome design language lives inside this package, not at runtime in the host.
962
+ - **Not a paint compositor.** One paint per fill, one paint per stroke. No layered fills, no blend modes between paints on the same primitive — if a host wants stacking, they emit two primitives.
963
+ - **No paint on labels.** `HUDLine`'s label pill + text intentionally stay on the legacy `color` path. Labels are theming surface, not paintable design-language surface; promoting them to accept `HUDPaint` would invite per-label patterned chrome that doesn't match anything the editor actually wants.
335
964
 
336
965
  ## Adoption
337
966