@grida/hud 0.2.0 → 0.2.2

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.
Files changed (38) hide show
  1. package/README.md +562 -37
  2. package/dist/core/index.d.mts +181 -0
  3. package/dist/core/index.d.ts +181 -0
  4. package/dist/core/index.js +301 -0
  5. package/dist/core/index.mjs +291 -0
  6. package/dist/cursors/index.d.mts +1 -1
  7. package/dist/cursors/index.d.ts +1 -1
  8. package/dist/cursors/index.js +1 -1
  9. package/dist/cursors/index.mjs +1 -1
  10. package/dist/index-BQtDtpHM.d.mts +3215 -0
  11. package/dist/index-BlfZbeEJ.d.ts +3215 -0
  12. package/dist/index.d.mts +4 -3
  13. package/dist/index.d.ts +4 -3
  14. package/dist/index.js +55 -2
  15. package/dist/index.mjs +3 -3
  16. package/dist/overlay-CVV4s3IL.d.ts +241 -0
  17. package/dist/overlay-dsG32baA.d.mts +241 -0
  18. package/dist/primitives/bedrock.d.mts +47 -0
  19. package/dist/primitives/bedrock.d.ts +47 -0
  20. package/dist/primitives/bedrock.js +71 -0
  21. package/dist/primitives/bedrock.mjs +65 -0
  22. package/dist/react.d.mts +3 -2
  23. package/dist/react.d.ts +2 -1
  24. package/dist/react.js +4 -1
  25. package/dist/react.mjs +4 -1
  26. package/dist/surface-BHDH6P6p.js +7383 -0
  27. package/dist/surface-B_8w6VWG.mjs +6929 -0
  28. package/dist/types-3wwFisZs.d.mts +296 -0
  29. package/dist/types-3wwFisZs.d.ts +296 -0
  30. package/package.json +16 -3
  31. package/dist/index-Cp0X4SV7.d.ts +0 -947
  32. package/dist/index-DhGdcuQz.d.mts +0 -947
  33. package/dist/surface-BvMmXoEl.mjs +0 -2471
  34. package/dist/surface-ofSNTJ8H.js +0 -2607
  35. /package/dist/{cursor-BFGUuD2M.d.mts → cursor-CxS8EMvm.d.mts} +0 -0
  36. /package/dist/{cursor-CIYvFshz.d.ts → cursor-CxS8EMvm.d.ts} +0 -0
  37. /package/dist/{cursor-BieMVb71.mjs → cursor-DW-uAPVE.mjs} +0 -0
  38. /package/dist/{cursor-DsP9qtN2.js → cursor-FGiJBdU-.js} +0 -0
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:
@@ -129,7 +266,10 @@ const surface = new Surface(canvasElement, {
129
266
  // required wiring
130
267
  pick: (point_doc) => editor.hitTest(point_doc), // (point) => NodeId | null
131
268
  shapeOf: (id) => editor.shapeOf(id), // (id) => SelectionShape | null
132
- onIntent: (intent) => editor.commitIntent(intent), // surface → host
269
+ onIntent: (intent) => editor.commitIntent(intent), // surface → host (commit)
270
+
271
+ // optional wiring
272
+ onTap: (tap) => tool.anchorAt(tap), // surface → host (observe; see "Tap outcome")
133
273
 
134
274
  // optional config
135
275
  style: { chromeColor: "#2563eb", handleSize: 8 },
@@ -143,6 +283,8 @@ surface.setSelection(ids); // read-only mirror from host
143
283
  surface.setStyle(partial);
144
284
  surface.setReadonly(v);
145
285
  surface.setPixelGrid({ enabled, zoomThreshold, color?, steps? }); // or null to disable
286
+ surface.setRuler({ enabled, axes?, color?, ranges?, marks?, ... }); // or null to disable
287
+ surface.setRulerTransform(t);
146
288
  surface.dispose();
147
289
 
148
290
  // Input
@@ -324,6 +466,62 @@ This package's implementation:
324
466
 
325
467
  Skim the spec before changing the classifier or its dispatch.
326
468
 
469
+ ## Tap outcome
470
+
471
+ A **tap** is the surface's observe-only report that a discrete press+release
472
+ landed at a document-space point over a particular node (or empty canvas),
473
+ WITHOUT becoming a drag. It is delivered through the optional `onTap` wiring
474
+ callback — a sibling of `pick` / `shapeOf` / `onIntent`, deliberately NOT an
475
+ `Intent`:
476
+
477
+ - An `Intent` is an **actionable change the host commits** (the surface never
478
+ mutates the document; the host wraps `preview`/`commit`). A tap is a **fact
479
+ to observe** — there is nothing to commit. Folding it into `onIntent` would
480
+ contradict that union's documented meaning.
481
+ - A tap must be able to fire **without changing selection** — most importantly
482
+ the secondary button, which produces a tap and nothing else. Keeping it off
483
+ the selection-bearing intents preserves "Not a selection store" (Anti-goals).
484
+
485
+ ```ts
486
+ import { type TapOutcome } from "@grida/hud";
487
+
488
+ const surface = new Surface(canvas, {
489
+ pick,
490
+ shapeOf,
491
+ onIntent,
492
+ onTap: (tap: TapOutcome) => {
493
+ // tap.point — document-space DOWN point the tap resolved against
494
+ // tap.button — "primary" | "secondary" (never "middle")
495
+ // tap.hit — topmost pick at point, or null for empty canvas
496
+ // tap.mods — modifier snapshot at press time
497
+ },
498
+ });
499
+ ```
500
+
501
+ **Firing rules (the contract a consumer reads from the exports):**
502
+
503
+ - Fires on the press+release of `primary` and `secondary` buttons when the
504
+ pointer never crosses the drag threshold (`DRAG_THRESHOLD_PX = 3`).
505
+ - `point` is the **pointer-down** point, never the up point. For a tap on an
506
+ already-selected node — where the selection commits on pointer-**up** via the
507
+ deferred drag-candidate path — the down and up points differ by up to the
508
+ threshold; the surface reports the down point because that is what the tap
509
+ resolved against and the only one it still holds at release.
510
+ - A drag past the threshold emits **no** tap (it became a gesture). A press
511
+ that opens a handle gesture (resize / rotate / corner-radius / …) emits no
512
+ tap either — it is a handle interaction, not a content tap.
513
+ - `middle` never taps — it is reserved for pan.
514
+ - A secondary tap never mutates selection; the tap is its only outcome.
515
+
516
+ `hit` is carried (host-`pick`-resolved at down time), not host-re-derived — the
517
+ host observes the SAME node the surface resolved the tap against, consistent
518
+ with `select` carrying resolved ids.
519
+
520
+ > **`@unstable`.** `TapOutcome` is marked `@unstable` per the promotion bar
521
+ > ("two consumers shape the contract"): it ships against one consumer today.
522
+ > Fields may change without a semver bump until a second consumer exercises it.
523
+ > There is no reader in this package; it is a contract-first producer surface.
524
+
327
525
  ## Coordinate model
328
526
 
329
527
  - All `SurfaceEvent` points are **screen-space** (CSS px relative to the canvas).
@@ -360,7 +558,117 @@ Layer order within a frame (back-to-front):
360
558
  3. Marquee rect
361
559
  4. Resize/rotate handles
362
560
  5. Size meter pill
363
- 6. Host-fed extras (always on top)
561
+ 6. Host-fed extras
562
+ 7. Ruler strips (when enabled — top + left L-shape, screen-space)
563
+
564
+ ### Substrate vs frame — why pixel grid and ruler sit at opposite ends
565
+
566
+ The two named viewport chromes — pixel grid and ruler — share the
567
+ inlining mechanism (a pure `draw*(ctx, params)` routine over the HUD's
568
+ existing context), but they belong to **different paint-order
569
+ families**:
570
+
571
+ - **Substrate (back-most).** Pixel grid is content-space: it lives
572
+ with the document, the user reads it _under_ everything else, and
573
+ its only job is to give content something to align to. Selection
574
+ chrome, marquee, and host extras are what the user is _interacting
575
+ with_; the grid is decoration that informs the interaction. So it
576
+ paints first.
577
+ - **Frame (top-most).** Ruler is viewport-space: it frames the editing
578
+ area, the way a window title bar frames a document. The ticks
579
+ reference world coordinates a selection might cross, but the strip
580
+ itself is "outside" the editing surface. A selection outline,
581
+ marquee, or handle that visually escapes into the strip reads as
582
+ broken in the same way a document scrolling under a title bar
583
+ reads as broken. So it paints last — after the pixel grid, after
584
+ surface chrome, and after host extras.
585
+
586
+ Every major editor (Figma, Sketch, XD, Illustrator, Affinity,
587
+ OmniGraffle) paints the ruler on top of content chrome. The HUD
588
+ follows that convention. The corner square (`strip × strip` at
589
+ origin) is deliberately left blank — hosts that want to fill it
590
+ draw it via the host-fed extras pass, which sits beneath the
591
+ ruler and is therefore correctly clipped by the strip on top.
592
+
593
+ Each strip also paints a **1-px inner-edge separator** early in
594
+ its own pass — right after the background fill, before any range
595
+ accent, mark, or step tick. The line where the strip meets the
596
+ editing area is the universal affordance every editor ships:
597
+ without it the strip visually bleeds into content. Painting it
598
+ beneath the data means a full-strip mark (`strokeHeight: strip`)
599
+ reads as one continuous stroke crossing the strip boundary into
600
+ the canvas guide below — instead of being capped by the separator
601
+ on top. The two roles answer different questions and earn
602
+ different paint slots: the separator is cosmetic chrome ("where
603
+ does the strip end?"), the ticks and marks are data ("what's the
604
+ position?"); chrome below, data above. The separator obeys the
605
+ `axes` filter — `axes: ["x"]` paints only the top-strip separator,
606
+ `axes: ["y"]` only the left-strip separator. The two separators
607
+ meet at right angles inside the corner square; nothing else is
608
+ painted there.
609
+
610
+ **Separator color is independent of tick color.** `RulerConfig`
611
+ exposes a separate `borderColor?` token that defaults to `color`
612
+ (the tick color) for backward compatibility, but the two are
613
+ deliberately decoupled. Every production editor (Figma, Sketch,
614
+ XD, Illustrator, Affinity, and our own main editor) paints the
615
+ separator distinctly LIGHTER than the ticks — the ticks must
616
+ read as numerals, the separator only marks the edge. A single
617
+ shared color cannot satisfy both responsibilities. Hosts that
618
+ want the main-editor look should pass a light neutral as
619
+ `borderColor` (e.g. an OKLCH or hex value matching their design
620
+ system's `border` token) while keeping `color` at the standard
621
+ mid-gray for the ticks.
622
+
623
+ **Marks (guide positions).** `RulerConfig.marks` accepts per-axis
624
+ arrays of `RulerMark`. A minimal `{ pos }` mark renders as a regular
625
+ step tick — short stroke, label color = the ruler's `color`. The
626
+ extra fields cover the standard guide-position affordance every
627
+ editor ships: a full-strip line with an accent stroke + label color:
628
+
629
+ ```ts
630
+ interface RulerMark {
631
+ pos: number;
632
+ /** Tick stroke + (default) label color. */
633
+ color?: string;
634
+ /** Label text. */
635
+ text?: string;
636
+ /** Override the stroke color independently of the label color. */
637
+ strokeColor?: string;
638
+ /** Stroke width in CSS pixels. Default 1. */
639
+ strokeWidth?: number;
640
+ /**
641
+ * Stroke height in CSS pixels. Default `tickHeight`. Pass `strip`
642
+ * (the strip width) for a full-strip mark — the standard
643
+ * guide-position affordance.
644
+ */
645
+ strokeHeight?: number;
646
+ /** Label color. Defaults to `color` if omitted. */
647
+ textColor?: string;
648
+ /** Label alignment. Default "center". */
649
+ textAlign?: CanvasTextAlign;
650
+ /** Label position offset from `pos`. Default 0. */
651
+ textAlignOffset?: number;
652
+ }
653
+ ```
654
+
655
+ To paint a guide position the way the rest of the editor renders
656
+ guides — full-strip accent line with a same-colored label — pass
657
+ `strokeHeight: strip` (matching `RulerConfig.strip`, default 20)
658
+ along with an accent `color` / `strokeColor`. Defaults are chosen
659
+ so omitting every field except `pos` keeps rendering identically
660
+ to a step tick — additive, no regressions for existing callers.
661
+
662
+ **Drag threshold.** Hosts implementing drag-from-strip to create
663
+ guides should use `DEFAULT_RULER_DRAG_THRESHOLD` (4 px) as the
664
+ minimum pointer-movement distance from pointer-down before
665
+ committing a new guide — without it, a stray click on the strip
666
+ spawns an unwanted guide. The constant is a published recommendation,
667
+ not a runtime gate; hud does not own the gesture.
668
+
669
+ If a future chrome turns out to be neither a substrate nor a frame,
670
+ the right move is to add a new paint slot deliberately — not to
671
+ hard-code it next to one of the existing ones by analogy.
364
672
 
365
673
  ## Transformed selections
366
674
 
@@ -388,6 +696,59 @@ The `resize` gesture operates in the local frame: `applyResize` takes a `Selecti
388
696
 
389
697
  **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.
390
698
 
699
+ ## Vector regions
700
+
701
+ 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.
702
+
703
+ 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.
704
+
705
+ **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.
706
+
707
+ ```ts
708
+ type VectorOverlay = {
709
+ vertices: ReadonlyArray<readonly [number, number]>;
710
+ segments?: ReadonlyArray<{ a; b; a_control; b_control }>;
711
+ neighbours?: ReadonlyArray<number>;
712
+ /** Schema-level feature flag — omit if not supported. */
713
+ regions?: ReadonlyArray<{ segments: ReadonlyArray<number> }>;
714
+ origin?: readonly [number, number];
715
+ };
716
+ ```
717
+
718
+ 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.
719
+
720
+ ### Selection mirror
721
+
722
+ 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.
723
+
724
+ ### Hit-test
725
+
726
+ 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.
727
+
728
+ ### Priority
729
+
730
+ `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.
731
+
732
+ ### Paint states
733
+
734
+ | State | Render |
735
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
736
+ | idle | No fill — render omitted entirely. The hit region is registered, but the body is visually transparent. |
737
+ | hover | `doc_polyline` with `fillPaint = style.vectorRegionHoverPaint` (default: `HUDPaintStripes` at 45° / 8px / 1.5px, accent color, 50% opacity). |
738
+ | selected | Same shape, `fillPaint = style.vectorRegionSelectedPaint` (default: same stripes at 70% opacity). |
739
+
740
+ Hover wins over selected — matches the precedence on every other vector overlay (vertex / tangent / segment / ghost knobs).
741
+
742
+ ### Intent
743
+
744
+ ```ts
745
+ | { kind: "select_region"; node_id: NodeId; region: number; mode: SelectMode }
746
+ ```
747
+
748
+ 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.
749
+
750
+ 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.
751
+
391
752
  ## Primitives — what `HUDCanvas` can draw
392
753
 
393
754
  | Primitive | Coordinate space | Used by |
@@ -411,34 +772,46 @@ packages/grida-canvas-hud/
411
772
  ├── README.md
412
773
  ├── index.ts # Surface, HUDCanvas, public types
413
774
  ├── react.tsx # useHUDSurface hook
414
- ├── primitives/ # UIdumb render shapes
775
+ ├── primitives/ # Tier 2 un-opinionated draw + math atoms
415
776
  │ ├── canvas.ts # HUDCanvas
416
777
  │ ├── types.ts # HUDDraw, HUDLine, HUDRect, HUDPolyline, HUDRule, HUDScreenRect
778
+ │ ├── pixel-grid.ts # inlined pure draw routine (sibling: @grida/pixel-grid)
779
+ │ ├── ruler.ts # inlined pure draw routine (sibling: @grida/ruler)
417
780
  │ ├── snap-guide.ts # HUDDraw builder
418
781
  │ ├── measurement-guide.ts # HUDDraw builder
419
782
  │ ├── marquee.ts # legacy HUDDraw builder (surface emits its own marquee)
420
783
  │ └── lasso.ts # HUDDraw builder
421
- ├── event/ # the math core
784
+ ├── event/ # the math core (shared dispatch, cross-class)
422
785
  │ ├── event.ts # SurfaceEvent, Modifiers, PointerButton
423
786
  │ ├── gesture.ts # SurfaceGesture + transitions
424
787
  │ ├── hit-regions.ts # screen-space AABB region registry
425
788
  │ ├── handles.ts # 8 resize + 4 rotate geometry & hit-test
426
789
  │ ├── click-tracker.ts # dblclick / multi-click detection
427
790
  │ ├── cursor.ts # CursorIcon, ResizeDirection, RotationCorner
428
- │ ├── intent.ts # Intent types + builders
791
+ │ ├── intent.ts # Intent types + builders (unions across all classes)
429
792
  │ ├── transform.ts # screen ↔ doc helpers
430
793
  │ └── state.ts # SurfaceState — pure dispatch entry
431
- ├── surface/ # the wired class
432
- │ ├── surface.ts # Surface class
433
- │ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
794
+ ├── classes/ # Tier 1 — promoted named classes (see classes/README.md)
795
+ │ ├── README.md # folder convention + per-class file layout + promotion-bar dry-run
796
+ │ ├── padding/ # migrated from surface/padding-overlay.ts
797
+ │ ├── transform-box/ # migrated from surface/transform-box.ts
798
+ │ └── vector-path/ # migrated from surface/vector-chrome.ts
799
+ ├── surface/ # orchestration only
800
+ │ ├── surface.ts # Surface class — still hosts inline chrome for corner-radius + parametric-handle (pending extraction)
801
+ │ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf (pre-migration host of selection chrome — split deferred)
434
802
  │ └── style.ts # HUDStyle defaults + merge
435
- └── cursors/ # opt-in subpath: @grida/hud/cursors
436
- ├── index.ts # cursors.defaultRenderer(), templates, encoder
437
- ├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
438
- ├── templates.ts # parameterized SVG cursor templates
439
- └── encode.ts # SVG → data: URL
803
+ ├── cursors/ # opt-in subpath: @grida/hud/cursors
804
+ ├── index.ts # cursors.defaultRenderer(), templates, encoder
805
+ ├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
806
+ ├── templates.ts # parameterized SVG cursor templates
807
+ └── encode.ts # SVG → data: URL
808
+ └── __tests__/
809
+ ├── composition/ # NEW — co-target matrix (see __tests__/composition/README.md)
810
+ └── … # per-class tests relocate under __tests__/classes/<name>/ as migrations land
440
811
  ```
441
812
 
813
+ 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.
814
+
442
815
  ## Naming conventions
443
816
 
444
817
  - File names are **kebab-case `.ts`** throughout.
@@ -446,25 +819,158 @@ packages/grida-canvas-hud/
446
819
  - Public method names on `Surface` and `HUDCanvas` are **camelCase** (`setSize`, `setTransform`, `setSelection`) — matches the existing primitive renderer API.
447
820
  - The top-level class is **`Surface`**, not `HUDSurface`. The package name already says "hud".
448
821
 
449
- ## Testing
822
+ ## UX Testing
823
+
824
+ `packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas
825
+ mock, no real pointer events — the `event/` layer is pure and the tests are
826
+ plain function calls.
827
+
828
+ ### Why UX testing is a first-class concern here
829
+
830
+ The HUD is small in code volume but dense in UX _semantics_. A single
831
+ `if (click_count >= 2) return Scenario.EnterEdit` is one line, but it encodes
832
+ a rule a user expects to work everywhere ("double-click enters edit"). The
833
+ code can't explain the _why_: why dblclick on a vertex doesn't exit, why
834
+ single-click on a body region defers, why a shift-click on an already-
835
+ selected node doesn't immediately add — these are UX choices, not
836
+ implementation details.
837
+
838
+ That creates a maintenance hazard. The HUD is **configurable** — hosts pass
839
+ styles, intent handlers, scene callbacks; small refactors land all the
840
+ time. With no visible behaviour to inspect (we draw to canvas; we synthesize
841
+ events), a UX rule can be silently dropped without anyone noticing until a
842
+ user complains. There is no browser test guarding us; there is no human in
843
+ the loop on every PR.
844
+
845
+ The fix is **doctrine: every default UX behaviour is locked by a unit test
846
+ that describes the behaviour in plain English.** The test name is the
847
+ spec. The body proves the code obeys it. Together they form a
848
+ machine-checked contract that says: "this is what the HUD does by default,
849
+ and if you change it you must change the test on purpose."
850
+
851
+ These tests are pure, headless, and fast. They are _not_ end-to-end browser
852
+ tests. They run on the `event/` and `surface/` modules directly because the
853
+ HUD was deliberately built so that every UX decision is computable from
854
+ inputs alone — `classifyScenario(input) → Scenario`, `decidePointerDown(input) → Decision`,
855
+ `SurfaceState.dispatch(event, deps) → response + intents`. Anything the user
856
+ will observe in a browser is the deterministic image of one of those
857
+ functions; if the function returns the wrong thing in a unit test, the
858
+ browser would show the wrong thing.
859
+
860
+ ### What counts as a UX spec test
861
+
862
+ A UX spec test is a plain unit test with three properties:
863
+
864
+ 1. **The `it("…")` description names the UX rule in natural language.** Not
865
+ "returns the right value" — "dblclick on empty space while in
866
+ content-edit emits exit_content_edit." Read in isolation, the test name
867
+ should describe what the user sees.
868
+ 2. **The body checks behaviour, not implementation.** Assert on the
869
+ emitted intents, the returned `Decision`, the resulting `HUDDraw`
870
+ primitive set — not on private state shape.
871
+ 3. **A comment above explains _why_ — the design intent.** This is the
872
+ piece the code can't carry. "Exit takes precedence over enter when
873
+ in-content-edit; the user clicking outside the edit clearly wants out,
874
+ not to re-enter on a different node." Future readers (humans, agents,
875
+ reviewers) need to know what we were defending against, not just the
876
+ green checkmark.
877
+
878
+ A test that satisfies all three is the smallest unit of UX truth we have
879
+ about the HUD's defaults.
880
+
881
+ ### When to add one
882
+
883
+ Add a UX spec test for every default behaviour that a host could
884
+ silently break by editing the code. In practice: every named branch in
885
+ `classifyScenario`, every kind in `PointerDownDecision`, every emitted
886
+ intent shape, every chrome primitive count tied to a state. If the
887
+ behaviour is configurable (e.g. style tokens), the test pins the _default_
888
+ — the rule under `DEFAULT_STYLE` or under no overrides. Configurability
889
+ doesn't excuse the absence of a default spec; it raises the bar for
890
+ having one.
891
+
892
+ When you change UX on purpose, you update the test at the same time. A
893
+ PR that touches a UX rule without touching the matching test is a smell;
894
+ a PR that flips a test's assertion without changing the test name is a
895
+ near-certain regression.
896
+
897
+ ### How to structure a UX spec
898
+
899
+ Match the style already in use across `__tests__/`:
450
900
 
451
- `packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas mock — the `event/` layer is pure.
452
-
453
- | File | Tests |
454
- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
455
- | `transform.test.ts` | screen doc across translate, scale, DPR |
456
- | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
457
- | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
458
- | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
459
- | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
460
- | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
461
- | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview\*N → commit) |
462
- | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
463
- | `chrome-transformed.test.ts` | `SelectionShape.transformed` end-to-end: outline, knob anchors, OBB hit-test exactness, identity ≡ rect equivalence |
464
- | `cursors.test.ts` | `cursors.defaultRenderer` produces rotation-aware CSS values; tree-shake invariant (subpath isolated from main bundle) |
465
- | `decision.test.ts` | one test per named scenario in the selection-intent classifier |
901
+ ```ts
902
+ // UX spec: dblclick away while editing exits content-edit.
903
+ //
904
+ // While a vector sub-selection is mirrored on the surface, a dblclick
905
+ // that does NOT land on a vector control (vertex / tangent / segment-
906
+ // strip) classifies as ExitEdit. Without this the user has no way out
907
+ // of edit mode except keyboard or the host's own UI, which surveys say
908
+ // is the #1 confusion in vector editors.
909
+ it("classifies dblclick on empty space WHILE in content-edit as ExitEdit", () => {
910
+ const i = input({ click_count: 2, in_content_edit: true });
911
+ expect(classifyScenario(i)).toBe(Scenario.ExitEdit);
912
+ expect(decidePointerDown(i)).toEqual({ kind: "exit_edit" });
913
+ });
914
+ ```
466
915
 
467
- Render output (visual canvas correctness) is verified in the browser, not unit-tested.
916
+ Top comment explains design intent. Test name names the UX rule. Assertion
917
+ locks the behaviour. Three layers, all required.
918
+
919
+ ### Default UX behaviours locked by tests
920
+
921
+ Non-exhaustive index — open the test files for the full surface.
922
+
923
+ | Behaviour | Pinned in |
924
+ | ------------------------------------------------------------------------------------------- | ---------------------------------------------- |
925
+ | Single-click on unselected node → immediate select | `decision.test.ts` |
926
+ | Single-click on already-selected node → defer (drag is a live candidate) | `decision.test.ts`, `state.test.ts` |
927
+ | Shift-click on selected node → defer (toggle-remove vs drag is ambiguous) | `decision.test.ts` |
928
+ | Click in body region with selection → always defer (drag claim) | `decision.test.ts` |
929
+ | Dblclick on content → emits `enter_content_edit` | `decision.test.ts`, `state.test.ts` |
930
+ | Dblclick on empty space / other node WHILE in content-edit → emits `exit_content_edit` | `decision.test.ts`, `state.test.ts` |
931
+ | Dblclick on vertex / tangent / segment-strip WHILE in content-edit → handler runs (no exit) | `decision.test.ts` |
932
+ | Marquee starts from empty-space pointer-down | `state.test.ts` |
933
+ | Drag past threshold cancels a deferred select (drag-vs-click discriminator) | `state.test.ts` |
934
+ | Tap (press+release, no drag) reports the DOWN point — incl. the deferred commit-on-up path | `state.test.ts` |
935
+ | Tap fires for primary + secondary, never middle (pan); drag past threshold emits no tap | `state.test.ts` |
936
+ | Secondary tap changes no selection; empty-canvas tap carries `hit: null` | `state.test.ts` |
937
+ | Tangent knob renders as a 45°-rotated square ("diamond"), smaller than vertex | `classes/vector-path/surface-extended.test.ts` |
938
+ | Vertex knob renders as a circle, selected fills with chrome color | `classes/vector-path/surface-extended.test.ts` |
939
+ | Selected tangent line is thicker than idle | `classes/vector-path/surface-extended.test.ts` |
940
+ | Segment outline: idle gray → hover @ 50% accent → selected solid accent | `classes/vector-path/segment-render.test.ts` |
941
+ | Segment strip emits N inner samples per cubic, t ∈ (0, 1) | `classes/vector-path/surface-extended.test.ts` |
942
+ | Priority ladder: tangent (4) < vertex (5) < segment (8) | `classes/vector-path/surface-extended.test.ts` |
943
+ | Rotation-aware cursor CSS via `cursors.defaultRenderer` | `cursors.test.ts` |
944
+ | Click-tracker: single vs double within window + position threshold | `click-tracker.test.ts` |
945
+
946
+ ### Test file index
947
+
948
+ | File | Domain pinned by tests |
949
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
950
+ | `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
951
+ | `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
952
+ | `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
953
+ | `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
954
+ | `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
955
+ | `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates, exit-edit; observe-only tap outcome (down-point fidelity, primary/secondary, middle excluded, drag emits none, empty-canvas null hit) |
956
+ | `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview·N → commit) |
957
+ | `decision.test.ts` | one test per named scenario in the selection-intent classifier, including ExitEdit gating |
958
+ | `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
959
+ | `chrome-transformed.test.ts` | `SelectionShape.transformed` end-to-end |
960
+ | `chrome-priority.test.ts` | overlay priority ladder over scenarios |
961
+ | `cursors.test.ts` | `cursors.defaultRenderer` produces rotation-aware CSS values; tree-shake invariant |
962
+ | `classes/vector-path/surface.test.ts` | vector chrome baseline: vertex emission, neighbouring filter, hit-region pad |
963
+ | `classes/vector-path/surface-extended.test.ts` | vector chrome UX: tangent diamond shape + size, selection highlight, priority ladder |
964
+ | `classes/vector-path/segment-render.test.ts` | segment outline state machine: idle / hover / selected styling |
965
+ | `classes/vector-path/region.test.ts` | vector regions: closed-loop fill (idle / hover / selected), polygon hit-test, REGION_PRIORITY ladder |
966
+ | `classes/vector-path/gesture.test.ts` | vector-specific gesture state machine: tangent drag, segment-strip click→split, drag→bend |
967
+ | `classes/padding/surface.test.ts` | padding overlay: per-side rect geometry, mirror handles, hover stripe / drag outline, priority ladder |
968
+ | `classes/transform-box/surface.test.ts` | transform-box chrome: quad corners, side strips, body translate, cursor rotation, container-rotation de-rotation |
969
+ | `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) |
970
+
971
+ Render output (visual canvas correctness — paint order, anti-aliasing, the
972
+ actual pixel result) is verified in the browser, not unit-tested. Every UX
973
+ _behaviour_ is.
468
974
 
469
975
  ## Extending the HUD
470
976
 
@@ -473,15 +979,30 @@ layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real
473
979
  needs, in this order of preference:
474
980
 
475
981
  1. **Named built-in chrome.** Things every Grida editor wants — pixel grid,
476
- selection, snap guides, measurement — live inside this package as
477
- first-class features with their own toggles (e.g. `setPixelGrid`,
478
- `setStyle`, the chrome built from `SurfaceState`). New canonical chrome
479
- lands here; open a PR against `@grida/hud`.
982
+ ruler, selection, snap guides, measurement — live inside this package
983
+ as first-class features with their own toggles (e.g. `setPixelGrid`,
984
+ `setRuler`, `setStyle`, the chrome built from `SurfaceState`). New
985
+ canonical chrome lands here; open a PR against `@grida/hud`.
986
+
987
+ Inlined chrome carries an explicit synchronisation contract: each
988
+ inlined primitive (`primitives/pixel-grid.ts`, `primitives/ruler.ts`,
989
+ …) documents its upstream sibling in a header comment. Bug fixes must
990
+ land on both sides. The bar for inlining a new chrome is that it
991
+ resolves into a single pure draw routine over an existing context —
992
+ anything that wants to own its own canvas, its own DPR, or its own
993
+ stateful renderer class belongs in a sibling package the host mounts,
994
+ not here. See "Not a renderer" under Anti-goals.
480
995
 
481
996
  2. **Host-fed `HUDDraw` extras.** Pass extra primitives into `surface.draw(extra)`
482
997
  per frame. Best for transient, gesture-coupled overlays the host already
483
- computes (measurement lines, custom snap visualizers). Drawn on top of named
484
- chrome they're foreground, not background.
998
+ computes (measurement lines, custom snap visualizers). Drawn on top of the
999
+ substrate-band and content-band chrome (pixel grid, selection, marquee,
1000
+ handles, size meter) but **beneath the frame-band chrome** (ruler). If a
1001
+ host extra is meant to occupy the ruler strip (a corner-square fill, a
1002
+ ruler-strip widget), draw it as an extra and let the ruler clip the
1003
+ bleed — don't try to paint above the ruler. The HUD reserves the right
1004
+ to add more frame-band chrome later; hosts should not build patterns
1005
+ that depend on extras being the absolute top layer.
485
1006
 
486
1007
  3. **DOM-level escape hatch.** The host owns the container element; the surface
487
1008
  only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay
@@ -492,12 +1013,16 @@ needs, in this order of preference:
492
1013
 
493
1014
  ## Anti-goals
494
1015
 
495
- - **Not a renderer.** `primitives/HUDCanvas` is intentionally minimal Canvas2D. Skia / WebGL backends are not in scope.
1016
+ - **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.
496
1017
  - **Not a scene graph.** Surface never reads node data — only via `pick` / `shapeOf`.
497
1018
  - **Not a host of plugins.** No widget registry. Custom HUD elements go through host-fed `HUDDraw` extras.
1019
+ - **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.
498
1020
  - **Not undo-aware.** Intents carry `phase`; host owns undo.
499
1021
  - **Not a selection store.** Host owns selection; surface mirrors.
500
1022
  - **Not SVG-aware.** No `data-id`, no DOM IR, no `<style>` resolution.
1023
+ - **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.
1024
+ - **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.
1025
+ - **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.
501
1026
 
502
1027
  ## Adoption
503
1028