@grida/hud 0.2.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 +499 -36
- package/dist/core/index.d.mts +181 -0
- package/dist/core/index.d.ts +181 -0
- package/dist/core/index.js +301 -0
- package/dist/core/index.mjs +291 -0
- package/dist/cursors/index.d.mts +1 -1
- package/dist/cursors/index.d.ts +1 -1
- package/dist/cursors/index.js +1 -1
- package/dist/cursors/index.mjs +1 -1
- package/dist/index-BrfEdWbQ.d.ts +3140 -0
- package/dist/index-Cmbe2X5b.d.mts +3140 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +55 -2
- package/dist/index.mjs +3 -3
- package/dist/overlay-CVV4s3IL.d.ts +241 -0
- package/dist/overlay-dsG32baA.d.mts +241 -0
- package/dist/primitives/bedrock.d.mts +47 -0
- package/dist/primitives/bedrock.d.ts +47 -0
- package/dist/primitives/bedrock.js +71 -0
- package/dist/primitives/bedrock.mjs +65 -0
- package/dist/react.d.mts +3 -2
- package/dist/react.d.ts +2 -1
- package/dist/react.js +1 -1
- package/dist/react.mjs +1 -1
- package/dist/surface-BHQVvRFC.js +7356 -0
- package/dist/surface-NHSzUR8r.mjs +6902 -0
- package/dist/types-3wwFisZs.d.mts +296 -0
- package/dist/types-3wwFisZs.d.ts +296 -0
- package/package.json +12 -2
- package/dist/index-Cp0X4SV7.d.ts +0 -947
- package/dist/index-DhGdcuQz.d.mts +0 -947
- package/dist/surface-BvMmXoEl.mjs +0 -2471
- package/dist/surface-ofSNTJ8H.js +0 -2607
- /package/dist/{cursor-BFGUuD2M.d.mts → cursor-CxS8EMvm.d.mts} +0 -0
- /package/dist/{cursor-CIYvFshz.d.ts → cursor-CxS8EMvm.d.ts} +0 -0
- /package/dist/{cursor-BieMVb71.mjs → cursor-DW-uAPVE.mjs} +0 -0
- /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:
|
|
@@ -143,6 +280,8 @@ surface.setSelection(ids); // read-only mirror from host
|
|
|
143
280
|
surface.setStyle(partial);
|
|
144
281
|
surface.setReadonly(v);
|
|
145
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);
|
|
146
285
|
surface.dispose();
|
|
147
286
|
|
|
148
287
|
// Input
|
|
@@ -360,7 +499,117 @@ Layer order within a frame (back-to-front):
|
|
|
360
499
|
3. Marquee rect
|
|
361
500
|
4. Resize/rotate handles
|
|
362
501
|
5. Size meter pill
|
|
363
|
-
6. Host-fed extras
|
|
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.
|
|
364
613
|
|
|
365
614
|
## Transformed selections
|
|
366
615
|
|
|
@@ -388,6 +637,59 @@ The `resize` gesture operates in the local frame: `applyResize` takes a `Selecti
|
|
|
388
637
|
|
|
389
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.
|
|
390
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.
|
|
692
|
+
|
|
391
693
|
## Primitives — what `HUDCanvas` can draw
|
|
392
694
|
|
|
393
695
|
| Primitive | Coordinate space | Used by |
|
|
@@ -411,34 +713,46 @@ packages/grida-canvas-hud/
|
|
|
411
713
|
├── README.md
|
|
412
714
|
├── index.ts # Surface, HUDCanvas, public types
|
|
413
715
|
├── react.tsx # useHUDSurface hook
|
|
414
|
-
├── primitives/ #
|
|
716
|
+
├── primitives/ # Tier 2 — un-opinionated draw + math atoms
|
|
415
717
|
│ ├── canvas.ts # HUDCanvas
|
|
416
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)
|
|
417
721
|
│ ├── snap-guide.ts # HUDDraw builder
|
|
418
722
|
│ ├── measurement-guide.ts # HUDDraw builder
|
|
419
723
|
│ ├── marquee.ts # legacy HUDDraw builder (surface emits its own marquee)
|
|
420
724
|
│ └── lasso.ts # HUDDraw builder
|
|
421
|
-
├── event/ # the math core
|
|
725
|
+
├── event/ # the math core (shared dispatch, cross-class)
|
|
422
726
|
│ ├── event.ts # SurfaceEvent, Modifiers, PointerButton
|
|
423
727
|
│ ├── gesture.ts # SurfaceGesture + transitions
|
|
424
728
|
│ ├── hit-regions.ts # screen-space AABB region registry
|
|
425
729
|
│ ├── handles.ts # 8 resize + 4 rotate geometry & hit-test
|
|
426
730
|
│ ├── click-tracker.ts # dblclick / multi-click detection
|
|
427
731
|
│ ├── cursor.ts # CursorIcon, ResizeDirection, RotationCorner
|
|
428
|
-
│ ├── intent.ts # Intent types + builders
|
|
732
|
+
│ ├── intent.ts # Intent types + builders (unions across all classes)
|
|
429
733
|
│ ├── transform.ts # screen ↔ doc helpers
|
|
430
734
|
│ └── state.ts # SurfaceState — pure dispatch entry
|
|
431
|
-
├──
|
|
432
|
-
│ ├──
|
|
433
|
-
│ ├──
|
|
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)
|
|
434
743
|
│ └── style.ts # HUDStyle defaults + merge
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
440
752
|
```
|
|
441
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
|
+
|
|
442
756
|
## Naming conventions
|
|
443
757
|
|
|
444
758
|
- File names are **kebab-case `.ts`** throughout.
|
|
@@ -446,25 +760,155 @@ packages/grida-canvas-hud/
|
|
|
446
760
|
- Public method names on `Surface` and `HUDCanvas` are **camelCase** (`setSize`, `setTransform`, `setSelection`) — matches the existing primitive renderer API.
|
|
447
761
|
- The top-level class is **`Surface`**, not `HUDSurface`. The package name already says "hud".
|
|
448
762
|
|
|
449
|
-
## Testing
|
|
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__/`:
|
|
450
841
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
| `decision.test.ts` | one test per named scenario in the selection-intent classifier |
|
|
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
|
+
```
|
|
466
856
|
|
|
467
|
-
|
|
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.
|
|
468
912
|
|
|
469
913
|
## Extending the HUD
|
|
470
914
|
|
|
@@ -473,15 +917,30 @@ layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real
|
|
|
473
917
|
needs, in this order of preference:
|
|
474
918
|
|
|
475
919
|
1. **Named built-in chrome.** Things every Grida editor wants — pixel grid,
|
|
476
|
-
selection, snap guides, measurement — live inside this package
|
|
477
|
-
first-class features with their own toggles (e.g. `setPixelGrid`,
|
|
478
|
-
`setStyle`, the chrome built from `SurfaceState`). New
|
|
479
|
-
lands here; open a PR against `@grida/hud`.
|
|
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.
|
|
480
933
|
|
|
481
934
|
2. **Host-fed `HUDDraw` extras.** Pass extra primitives into `surface.draw(extra)`
|
|
482
935
|
per frame. Best for transient, gesture-coupled overlays the host already
|
|
483
|
-
computes (measurement lines, custom snap visualizers). Drawn on top of
|
|
484
|
-
chrome
|
|
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.
|
|
485
944
|
|
|
486
945
|
3. **DOM-level escape hatch.** The host owns the container element; the surface
|
|
487
946
|
only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay
|
|
@@ -492,12 +951,16 @@ needs, in this order of preference:
|
|
|
492
951
|
|
|
493
952
|
## Anti-goals
|
|
494
953
|
|
|
495
|
-
- **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.
|
|
496
955
|
- **Not a scene graph.** Surface never reads node data — only via `pick` / `shapeOf`.
|
|
497
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.
|
|
498
958
|
- **Not undo-aware.** Intents carry `phase`; host owns undo.
|
|
499
959
|
- **Not a selection store.** Host owns selection; surface mirrors.
|
|
500
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.
|
|
501
964
|
|
|
502
965
|
## Adoption
|
|
503
966
|
|