vintage-frames 0.15.0 → 0.16.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.
Files changed (47) hide show
  1. package/custom-elements.json +907 -99
  2. package/dist/chrome.d.ts +10 -1
  3. package/dist/chrome.js +8 -3
  4. package/dist/components/vf-desktop.d.ts +5 -2
  5. package/dist/components/vf-desktop.js +5 -5
  6. package/dist/components/vf-dialog.d.ts +14 -8
  7. package/dist/components/vf-dialog.js +27 -27
  8. package/dist/components/vf-menu-bar.js +9 -10
  9. package/dist/components/vf-menu-item.js +18 -13
  10. package/dist/components/vf-menu.d.ts +8 -3
  11. package/dist/components/vf-menu.js +85 -61
  12. package/dist/components/vf-number-field.d.ts +4 -0
  13. package/dist/components/vf-number-field.js +28 -25
  14. package/dist/components/vf-paragraph.d.ts +3 -1
  15. package/dist/components/vf-paragraph.js +7 -0
  16. package/dist/components/vf-scroll-area.d.ts +16 -1
  17. package/dist/components/vf-scroll-area.js +39 -29
  18. package/dist/components/vf-select.d.ts +5 -4
  19. package/dist/components/vf-select.js +26 -17
  20. package/dist/components/vf-text-field.d.ts +5 -5
  21. package/dist/components/vf-text-field.js +1 -1
  22. package/dist/components/vf-window.d.ts +22 -5
  23. package/dist/components/vf-window.js +113 -98
  24. package/dist/cursor.d.ts +8 -0
  25. package/dist/cursor.js +70 -73
  26. package/dist/index.d.ts +2 -1
  27. package/dist/modal-dialog.d.ts +27 -5
  28. package/dist/modal-dialog.js +63 -51
  29. package/dist/shell/catalog.d.ts +1 -1
  30. package/dist/shell/catalog.js +32 -18
  31. package/dist/shell/finder.d.ts +6 -1
  32. package/dist/shell/finder.js +182 -176
  33. package/dist/shell/index.d.ts +1 -1
  34. package/dist/shell/pure.d.ts +10 -0
  35. package/dist/shell/pure.js +4 -0
  36. package/dist/shell/shell.d.ts +16 -3
  37. package/dist/shell/shell.js +141 -126
  38. package/dist/shell/windows.d.ts +57 -13
  39. package/dist/shell/windows.js +214 -154
  40. package/dist/text-control.d.ts +46 -3
  41. package/dist/text-control.js +33 -5
  42. package/dist/top-layer.d.ts +13 -0
  43. package/dist/top-layer.js +16 -0
  44. package/docs/SPEC.md +25 -24
  45. package/editor/vscode.html-custom-data.json +36 -11
  46. package/editor/web-types.json +81 -23
  47. package/package.json +5 -1
@@ -8,9 +8,6 @@ import { nothing as s } from "lit";
8
8
  import { property as c } from "lit/decorators.js";
9
9
  //#region src/text-control.ts
10
10
  var l = class extends a {
11
- constructor(...e) {
12
- super(...e), this.value = "", this.placeholder = "", this.readonly = !1, this.name = "", this.label = "", this.scale = new n(this), this.gridSnap = new i(this), this.focusRule = new o(this);
13
- }
14
11
  static {
15
12
  this.shadowRootOptions = {
16
13
  ...a.shadowRootOptions,
@@ -42,6 +39,24 @@ var l = class extends a {
42
39
  get wellClass() {
43
40
  return `vf-field-well vf-snap${this.focusRule.marked ? " vf-focus-rule" : ""}`;
44
41
  }
42
+ constructor() {
43
+ super(), this.value = "", this.placeholder = "", this.readonly = !1, this.autoselect = !1, this.name = "", this.label = "", this.scale = new n(this), this.gridSnap = new i(this), this.focusRule = new o(this), this.committed = null, this.addEventListener("focusin", () => {
44
+ this.committed = this.value;
45
+ }), this.addEventListener("focusout", () => {
46
+ let e = this.control;
47
+ e && this.commit(e.value);
48
+ });
49
+ }
50
+ get control() {
51
+ return this.renderRoot.querySelector(".vf-field");
52
+ }
53
+ focus(e) {
54
+ this.control?.focus(e);
55
+ }
56
+ select() {
57
+ let e = this.control;
58
+ e && e.value === this.value ? e.select() : this.updateComplete.then(() => this.control?.select());
59
+ }
45
60
  connectedCallback() {
46
61
  super.connectedCallback(), this.latchFormDefault(this.value);
47
62
  }
@@ -66,13 +81,26 @@ var l = class extends a {
66
81
  handleInput(e) {
67
82
  this.value = e.target.value, this.emitValue("vf-input");
68
83
  }
84
+ committedText(e) {
85
+ return e;
86
+ }
87
+ commit(e) {
88
+ let n = this.committedText(e);
89
+ this.value = n, n !== this.committed && (this.committed = n, this.emitValue("vf-change"), t(this, "change"));
90
+ }
69
91
  handleChange(e) {
70
- this.value = e.target.value, this.emitValue("vf-change"), t(this, "change");
92
+ this.commit(e.target.value);
93
+ }
94
+ submitOnEnter(e) {
95
+ !this.isSubmitEnter(e) || !this.internals.form || (this.commit(e.target.value), this.requestImplicitSubmit() && e.preventDefault());
71
96
  }
72
97
  };
73
98
  r([c()], l.prototype, "value", void 0), r([c()], l.prototype, "placeholder", void 0), r([c({
74
99
  type: Boolean,
75
100
  reflect: !0
76
- })], l.prototype, "readonly", void 0), r([c({ reflect: !0 })], l.prototype, "name", void 0), r([c()], l.prototype, "label", void 0);
101
+ })], l.prototype, "readonly", void 0), r([c({
102
+ type: Boolean,
103
+ reflect: !0
104
+ })], l.prototype, "autoselect", void 0), r([c({ reflect: !0 })], l.prototype, "name", void 0), r([c()], l.prototype, "label", void 0);
77
105
  //#endregion
78
106
  export { l as VfTextControlBase };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The kit's entries into the top layer. Whatever enters the top layer later
3
+ * paints over what entered before, so an overlay that must stay above
4
+ * everything — the drawn cursor (src/cursor.ts) — re-promotes itself after
5
+ * each kit entry. A popup shown here escapes every stacking context on its
6
+ * way up: a window's z-index can't hold it under a palette.
7
+ */
8
+ /** Show a manual popover in the top layer, then let the overlays above it re-promote. */
9
+ export declare function showInTopLayer(el: HTMLElement): void;
10
+ /** Take a popover {@link showInTopLayer} showed back out of the top layer. */
11
+ export declare function hideFromTopLayer(el: HTMLElement): void;
12
+ /** Run `fn` after every kit entry into the top layer. Returns the unsubscribe. */
13
+ export declare function onTopLayerEntry(fn: () => void): () => void;
@@ -0,0 +1,16 @@
1
+ //#region src/top-layer.ts
2
+ var e = /* @__PURE__ */ new Set();
3
+ function t(t) {
4
+ if (!(!("showPopover" in t) || !t.isConnected || t.matches(":popover-open"))) {
5
+ t.showPopover();
6
+ for (let t of [...e]) t();
7
+ }
8
+ }
9
+ function n(e) {
10
+ "hidePopover" in e && e.matches(":popover-open") && e.hidePopover();
11
+ }
12
+ function r(t) {
13
+ return e.add(t), () => void e.delete(t);
14
+ }
15
+ //#endregion
16
+ export { n as hideFromTopLayer, r as onTopLayerEntry, t as showInTopLayer };
package/docs/SPEC.md CHANGED
@@ -46,7 +46,7 @@ Modern requirements that we deliberately keep (accessibility over purity):
46
46
  - **The name/description bridge** (`VfShadowRoleControl`, src/form-control.ts). On the controls whose role lives on a shadow-internal node (the three fields, `vf-select`, `vf-swatch`, `vf-button`), a host-level `aria-labelledby`, `aria-label` or associated `<label for>` resolves — in html-aam precedence — to the inner focusable element's `aria-label` whenever the `label` property is empty (`hostLabel`). Those six extend `VfShadowRoleControl`; the host-role controls (`vf-checkbox`, `vf-radio-group`, `vf-slider`) extend the plain `VfFormControl` and so never *carry* the bridge's API at all — an inherited `description` that renders nothing is the advertised-but-inert shape this split removes, and `verify:manifest` enforces it (a tag whose manifest lists `description` must call `renderDescription()`). `vf-button` takes the ARIA half alone (`hostAriaLabel`): a `<button>` is not a labelable element, so no caption names a native one and none names this one either — its slotted content is the name a bridge doesn't override. A `description` property (or, when it's empty, a host-level `aria-describedby`) renders as a hidden span in the control's own shadow root with the inner control's `aria-describedby` pointing at it — the shadow-internal IDREF idiom `vf-dialog`'s title patch uses — and a failing constraint's `validationMessage` joins the same node. Referenced text is flattened at render time, so an edit to a referenced element's *text* lands on the next render rather than instantly — the one divergence from native. Controls whose role sits on the host (the toggles, slider, radio group, bars) need none of this: the platform reads their host attributes directly.
47
47
  - **Constraint validation** (`VfFormControl`): a reflected `required` fails validation with `valueMissing` while the control is empty by its own definition (fields/select `value === ''`, checkbox unchecked, radio group unselected — each with its native counterpart's message), through one `syncValidity()` funnel run before each render. The native surface is complete — `checkValidity`/`reportValidity`/`validity`/`validationMessage`/ `willValidate`/`setCustomValidity` — `:invalid` matches on the host, `form.reportValidity()` blocks, and `disabled`/`readonly` bar validation per HTML's own rules. One divergence the platform imposes: `willValidate` is `true` on a `vf-button type="button"`, where a native button is barred from constraint validation. A form-associated custom element cannot opt out of the candidate set, so this is a platform limit. AT wiring is `aria-required`/`aria-invalid` on the inner control (plus internals mirrors for host-role controls) — never a forwarded native `required`, which would put UA `:user-invalid` styling on the artwork. Enter's implicit submission routes through the browser's validation, so it cannot submit past a failing constraint.
48
48
  - Components must render nothing surprising outside their box: no margins on `:host` by default.
49
- - **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's or a dialog's *content region* (the frame's inner edge below any title bar, with no inset of its own — flow content starts there too), a stack's box, a fieldset's border interior, a scroll area's scrolled plane. The style writing rides a ReactiveController (`hostUpdated`), not an `updated()` override — component subclasses routinely skip `super.updated()` — and re-applies only when the property values changed, so an unrelated update never re-asserts a coordinate. **`origin`** (reflected string: one of nine keywords, vertical then horizontal — `top left` the default, `top center`, `top right`, `center left`, `center`, `center right`, `bottom left`, `bottom center`, `bottom right`) names which point of the host's own box the pair places. With a non-default origin the controller measures the host's border box in whole system px (w × h) and writes `left` as `calc(var(--vf-scale, 1) * (L − ⌈w·fx⌉)px)` with `fx` 0, ½ or 1, `top` likewise — the ceiling sends an odd size's leftover half toward the start, the tie rule `vf-stack`'s centering and the title patch use — and keeps the offset current with a ResizeObserver (a relabel, a late font; a zoom step changes CSS px, not system px, so the same offset comes back and nothing is rewritten), each rewrite announced as `vf-placement-change`. A box with no layout (0×0, inside a closed dialog) keeps the offset it had, so it shows again in place. The width measured is the box's own wherever its corner lands: a placed box without a declared width shrinks to the room between its `left` and its parent's far edge, and the offset moves that `left`, so a first measurement with the corner at the stated point squeezed the box, and each re-measure freed a little more room — a row walked for frames after every show and could stop short. A negative right margin as wide as the written `left` gives the box its parent's whole width wherever it sits, so the first measurement is the last; a declared width is untouched (not for `fixed`, whose margins are its footprint). Never a transform: a percentage of an odd width is half a pixel, and a transform would make the host the containing block for a `position: fixed` descendant (`vf-select`'s list). `top left` is the plain path — no observer, no measurement, the same inline style. The pair is the origin point throughout: `PlacementController.seed()` returns it (the corner read from flow plus the offset, for a host with no stated pair), `moveTo()` takes the offset off before the clamp and puts it back after, so a gesture writes the point's pair and the clamp holds the box; `placementAt(clientX, clientY, child?)` on the three shadow-anchored containers folds `child`'s offset in; `fixed` folds it into its sticky thresholds. `vf-dialog` has no `origin` — its pair is the viewport's, and unset centers it. An unknown value places as `top left` and warns once per element; on its own the attribute places nothing. **`fixed`** (bare attribute, reflected) holds the placement against the *visible* region of the nearest scrolling ancestor instead of its scrolled plane: `position: sticky` in place of `absolute`, the same live offsets, the flag alone at (0,0). Sticky boxes stay in flow, so the controller erases the footprint — the host is blockified (the outer display change `absolute` makes on its own: `inline-block` → `block`, `inline-flex` → `flex`, …), `max-width: fit-content`, `z-index: 1` (over the plane's placed children), and a negative right/bottom margin equal to its own border box, kept exact by a ResizeObserver — a 0×0 margin box that flow content lays out as if it weren't there. Two engine facts stated as rules: sticky only ever pushes a box *down* from where the flow put it, so a fixed child precedes the flow content in its parent; and a scroll container that never scrolls (a plain window body is `overflow: hidden`) holds it exactly where placement would. Removing the flag returns the host to ordinary placement with the recipe unwound. `npm run verify:position`.
49
+ - **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's or a dialog's *content region* (the frame's inner edge below any title bar, with no inset of its own — flow content starts there too), a stack's box, a fieldset's border interior, a scroll area's scrolled plane. The style writing rides a ReactiveController (`hostUpdated`), not an `updated()` override — component subclasses routinely skip `super.updated()` — and re-applies only when the property values changed, so an unrelated update never re-asserts a coordinate. **`origin`** (reflected string: one of nine keywords, vertical then horizontal — `top left` the default, `top center`, `top right`, `center left`, `center`, `center right`, `bottom left`, `bottom center`, `bottom right`) names which point of the host's own box the pair places. With a non-default origin the controller measures the host's border box in whole system px (w × h) and writes `left` as `calc(var(--vf-scale, 1) * (L − ⌈w·fx⌉)px)` with `fx` 0, ½ or 1, `top` likewise — the ceiling sends an odd size's leftover half toward the start, the tie rule `vf-stack`'s centering and the title patch use — and keeps the offset current with a ResizeObserver (a relabel, a late font; a zoom step changes CSS px, not system px, so the same offset comes back and nothing is rewritten), each rewrite announced as `vf-placement-change`. A box with no layout (0×0, inside a closed dialog) keeps the offset it had, so it shows again in place. The width measured is the box's own wherever its corner lands: a placed box without a declared width shrinks to the room between its `left` and its parent's far edge, and the offset moves that `left`, so a first measurement with the corner at the stated point squeezed the box, and each re-measure freed a little more room — a row walked for frames after every show and could stop short. A negative right margin as wide as the written `left` gives the box its parent's whole width wherever it sits, so the first measurement is the last; a declared width is untouched (not for `fixed`, whose margins are its footprint). Never a transform: a percentage of an odd width is half a pixel, and a transform would make the host the containing block for a `position: fixed` descendant (an icon field's selection band). `top left` is the plain path — no observer, no measurement, the same inline style. The pair is the origin point throughout: `PlacementController.seed()` returns it (the corner read from flow plus the offset, for a host with no stated pair), `moveTo()` takes the offset off before the clamp and puts it back after, so a gesture writes the point's pair and the clamp holds the box; `placementAt(clientX, clientY, child?)` on the three shadow-anchored containers folds `child`'s offset in; `fixed` folds it into its sticky thresholds. `vf-dialog` has no `origin` — its pair is the viewport's, and unset centers it. An unknown value places as `top left` and warns once per element; on its own the attribute places nothing. **`fixed`** (bare attribute, reflected) holds the placement against the *visible* region of the nearest scrolling ancestor instead of its scrolled plane: `position: sticky` in place of `absolute`, the same live offsets, the flag alone at (0,0). Sticky boxes stay in flow, so the controller erases the footprint — the host is blockified (the outer display change `absolute` makes on its own: `inline-block` → `block`, `inline-flex` → `flex`, …), `max-width: fit-content`, `z-index: 1` (over the plane's placed children), and a negative right/bottom margin equal to its own border box, kept exact by a ResizeObserver — a 0×0 margin box that flow content lays out as if it weren't there. Two engine facts stated as rules: sticky only ever pushes a box *down* from where the flow put it, so a fixed child precedes the flow content in its parent; and a scroll container that never scrolls (a plain window body is `overflow: hidden`) holds it exactly where placement would. Removing the flag returns the host to ordinary placement with the recipe unwound. `npm run verify:position`.
50
50
  - `vf-dialog` takes the pair in **viewport** coordinates: `showModal()` puts the box in the top layer, whose containing block is the viewport rather than the nearest positioned ancestor. Unset means centered — recomputed on open and on every box/viewport/scale change, so a modal whose content upgrades after opening is never stranded; `position` is left to the UA and only the four inset/margin declarations are written.
51
51
  - **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }`, nulls on a return to flow), a public event: a page hears every move through it — a title-bar drag's steps and its release (an `outline-drag` writes once, on the release), an arrow nudge, a drop, a walk's landing, an `origin` re-measure and its own property writes — and so does a `vf-scroll-area`, which re-measures under a child dropped, nudged or set past its viewport with no code on the page. It is dispatched from the host's update with the new pair already written, so a handler that writes the pair again takes effect on the next update. `npm run verify:position` (EVENT), `npm run verify:window` (PLACEMENT EVENT). `npm run verify:icon-drag` (RE-MEASURE).
52
52
  - **The movable contract.** A host that moves under a gesture states its own rectangle, and its positioning parent is a box with a size. Both halves are the consumer's — a component can supply neither for itself — and both used to fail quietly, so each is a one-time `console.warn` (`warnMovableContract`, latched per element like `vf-window`'s size warning). `npm run verify:position` group CONTRACT.
@@ -136,7 +136,7 @@ The span construction (`vfTileSize`, `tileImage`, `tileSpan`) still sizes what i
136
136
 
137
137
  **Patterns.** The 38 standard patterns (`src/patterns.ts`; the table is docs/PATTERNS.md) are 8-byte QuickDraw `Pattern`s in palette order and QuickDraw phase. `gray-50`, `gray-25` and `dots` are the desktop dither, the scroll trough and the windoid bar's motifs, and those surfaces draw them by reference — `patternMotif` on the minimal cell, so the 2×2 / 4×2 rect data and the 30/60-px token spans are the ones they always had. `vf-container pattern` and `vf-desktop pattern` take a name or sixteen hex digits and paint the pattern as the box's **own background** (`PatternFillController` + `vfPatternFill`, src/pattern-fill.ts): the pattern's motif as a kit tile (above), literal black ink on the `--vf-white` paper the recipe supplies. A background rather than a child element, so a patterned box is no stacking context (a slotted control's fixed panel is never trapped under a later sibling) and keeps `overflow: visible`; `image-rendering` inherits, so the recipe hands `auto` back to the slot. Phase is anchored at the box's padding-box origin. Forced colors: flat Canvas, the desktop's posture. `npm run verify:pattern` asserts the library, the three motif equivalences, the tile's geometry and zero gray at the same eight densities.
138
138
 
139
- **`--vf-cursor` (hiding the pointer for a page-drawn cursor).** A page that draws its own cursor — a JS-positioned image on the system-pixel grid, which is what `applyCursor()` (`src/cursor.ts`) sets up with the kit's embedded System 7 pointer set — needs the native pointer gone from every surface, and `html { cursor: none }` alone cannot get it there: inheritance stops wherever a shadow stylesheet states a cursor of its own, and the kit states one deliberately on its chrome — control hosts, the title-bar widgets, the number stepper, the scrollbar rails, the grow box, the editable wells (whose I-beam Firefox's UA sheet would otherwise pin), and a modal's top-layer backdrop, which no page rule reaches at all. Every one of those declarations reads this token first, so `--vf-cursor: none` on `:root` (custom properties inherit through every shadow root) covers all of them. The page's own side must be a blanket, not a root rule: the UA sheets put `cursor: default` on `<button>` and `cursor: text` on `<input>`, and an element's own declaration beats anything it would inherit — so a bare `html { cursor: none }` leaves the OS arrow alive over every native control in the light DOM. The pair that empties everything is `* { cursor: none !important }` plus the token — `applyCursor()` applies both itself, once its art has decoded; state the pair by hand only under a hand-rolled overlay. Unset, each spot keeps its classic pointer — the arrow on chrome, the I-beam in an enabled well.
139
+ **`--vf-cursor` (hiding the pointer for a page-drawn cursor).** A page that draws its own cursor — a JS-positioned image on the system-pixel grid, which is what `applyCursor()` (`src/cursor.ts`) sets up with the kit's embedded System 7 pointer set — needs the native pointer gone from every surface, and `html { cursor: none }` alone cannot get it there: inheritance stops wherever a shadow stylesheet states a cursor of its own, and the kit states one deliberately on its chrome — control hosts, the title-bar widgets, the number stepper, the scrollbar rails, the grow box, the editable wells (whose I-beam Firefox's UA sheet would otherwise pin), and a modal's top-layer backdrop, which no page rule reaches at all. Every one of those declarations reads this token first, so `--vf-cursor: none` on `:root` (custom properties inherit through every shadow root) covers all of them. The page's own side must be a blanket, not a root rule: the UA sheets put `cursor: default` on `<button>` and `cursor: text` on `<input>`, and an element's own declaration beats anything it would inherit — so a bare `html { cursor: none }` leaves the OS arrow alive over every native control in the light DOM. The pair that empties everything is `* { cursor: none !important }` plus the token — `applyCursor()` applies both itself, once its art has decoded; state the pair by hand only under a hand-rolled overlay. Unset, each spot keeps its classic pointer — the arrow on chrome, the I-beam in an enabled well. Which art `applyCursor()` draws where — the four built-in kinds, and any the page names in `kinds`, each claimed with `data-vf-cursor` — is docs/CURSOR.md.
140
140
 
141
141
  ## 4. Shared recipes (in `src/styles/base.ts`)
142
142
 
@@ -190,21 +190,21 @@ Files live in `src/components/`. "Parts" = CSS shadow parts via `part=`.
190
190
  | Movable modal dialog box | `<vf-dialog heading="…">` (add `closable` for the Figure 5-1 reading) |
191
191
  | Modal dialog box | `<vf-dialog frame="plain">` |
192
192
  | Modeless dialog box | `<vf-window closable movable>` (no zoom, grow or rails) |
193
- | Utility (floating) window | `<vf-window variant="utility" movable>` |
193
+ | Utility (floating) window | `<vf-window variant="utility" closable movable>` |
194
194
 
195
195
  Every recipe also declares its size — `width`, and `height` where the window isn't content-shaped — in whole system px. The sizes are omitted from the table only to keep the distinguishing parameters legible.
196
196
 
197
197
  #### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
198
198
  Full-bleed classic desktop container.
199
- - **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it. `window-top: number` (`windowTop`, system px from the screen's top, unset by default) — the least `top` a window may take, for a page that keeps a strip of its own under the menu bar, over the windows: a window dragged on the desktop stops there and `windowArea` starts there; icons still stop at the menu bar; below the bar's bottom it changes nothing.
199
+ - **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`, reflected) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it. It reflects so a page can observe a change on the attribute; the shell saves it that way. `window-top: number` (`windowTop`, system px from the screen's top, unset by default) — the least `top` a window may take, for a page that keeps a strip of its own under the menu bar, over the windows: a window dragged on the desktop stops there and `windowArea` starts there; icons still stop at the menu bar; below the bar's bottom it changes nothing.
200
200
  - **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/ `height` to the largest whole-system-px raster whose host box — bezel included — fits the bound, per the current effective scale, and returns `{ width, height }`. The page's half of the sizing contract: call it on `resize` and `onScaleChange` (zoom and density moves change what a system px costs in CSS px), as a full-screen faux desktop does. `clearActive()` → deactivate the whole document tier (see *Deactivation* under Behavior). `activeWindow: HTMLElement | null` (read-only) → the active document-tier window, or `null` while the tier is deactivated or has no shown window; utility windows and hidden windows are never the holder. `stackingOrder: HTMLElement[]` (read-only) → the slotted windows in stacking order, bottom-most first — the document tier, then the floating tier, each by its last raise — hidden windows included where they stand; a fresh array per read. The light-DOM order catches up with it only after a gesture, and a raise by keyboard focus moves no node, so a page that saves window depths or picks the window behind another reads this rather than the DOM. `workArea` (read-only) → `{ left, top, width, height }` in system px from the screen's corner, bezel excluded: the screen less a slotted, shown `vf-menu-bar` along its top edge (measured at the call; the whole screen without one). `windowArea` (read-only) → the work area deepened to `window-top` where that is lower — the area windows are dragged in. `placementAt(clientX, clientY, child?)` → a viewport point as a placement on the screen, `{ left, top }` in whole system px on the placement lattice, measured from the raster's corner with the bezel excluded — the pair a child dropped onto the desktop is written with; with `child`, the pair to write to that element, its `origin` offset folded in (`placementIn`, src/position.ts; the anchor sits in shadow, which is why it is a method).
201
201
  - **Visual:** `display: block; position: relative;` — the paint lives on an inner screen surface (part `desktop`, `overflow: hidden` — the whole-system-px raster, inset by `bezel` when one is set). Screen surface = the `pattern` (the classic 50% dither, `gray-50`, by default), painted as the screen's own background by the pattern fill (*Patterns* under *Tiled fills*): black ink on the fill's opaque `--vf-white` paper, a repeating tile — the authentic black-on-white dither. A consumer `--vf-desktop-pattern` renders instead as a placed tile grid on the token's 30-system-px tile with no paper, so `var(--vf-desktop, #808080)` beneath shows only under a custom tile with transparent cells (or `none`). Forced colors: flat Canvas.
202
202
  - **Slots:** default (menu bar, windows, anything).
203
- - **Behavior:** manages stacking of slotted `vf-window` children: `pointerdown` *or `focusin`* on a window brings it to front (incrementing z-index counter) and sets its `active` attribute, clearing `active` on the others — the `focusin` half is the keyboard route to activation: Tab landing anywhere in a background window (its undrawn-but-focusable widgets included) raises it. Listens via delegated pointerdown/focusin listeners + `slotchange`. Windows slotted before `vf-window` is defined are re-normalized once `customElements.whenDefined('vf-window')` settles, since the upgrade reflects each window's `active = true` default back out and upgrading a slotted node doesn't re-fire `slotchange`.
203
+ - **Behavior:** manages stacking of slotted `vf-window` children: `pointerdown` *or `focusin`* on a window brings it to front (incrementing z-index counter) and sets its `active` attribute, clearing `active` on the others — the `focusin` half is the keyboard route to activation: Tab landing anywhere in a background window (its undrawn-but-focusable widgets included) raises it. A press on a title bar with ⌘ held (`metaKey`) raises nothing and activates nothing, so a background window is dragged where it stands in the stack. `npm run verify:desktop-activate` (⌘-DRAG). Listens via delegated pointerdown/focusin listeners + `slotchange`. Windows slotted before `vf-window` is defined are re-normalized once `customElements.whenDefined('vf-window')` settles, since the upgrade reflects each window's `active = true` default back out and upgrading a slotted node doesn't re-fire `slotchange`.
204
204
  - **DOM order follows z-order** (bottom-most first), so sequential focus order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync runs in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); the move is `Element.moveBefore()` where the browser has it, which keeps the focus inside the moved window — a rename field, a caret — and otherwise a re-insert, after which the focus is restored without re-raising that window (a `vf-icon` rename whose field blurred in the move and has its focus back in the same task commits nothing). Non-window children (a menu bar, page content) keep their positions. `npm run verify:window-a11y`.
205
205
  - **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant` property of an upgraded window and the attribute of one not yet upgraded, so both land right — and so does a palette made by script and appended at once, which is slotted before its attribute reflects. `npm run verify:desktop-activate` (SCRIPTED).
206
206
  - **Menu tier:** a slotted `vf-menu-bar` — or a free-standing `vf-menu` placed on the desktop — is restated at `z-index: 2_000_000` by the desktop's own `::slotted(vf-menu-bar), ::slotted(vf-menu)` rule (an outer-tree `::slotted` declaration beats the bar's inner `:host { z-index: 1000 }`), a band above the floating tier, so a dropped menu paints over palettes and document windows alike — the Menu Manager drew menus over every window. Only the drag outline, a window's opening rects and the screen-corner mask are in front. The full stack inside the screen's isolated context: consumer tile grid (−1) < document windows (counter) < utility windows (counter + 1_000_000) < menu tier (2_000_000) < drag outline and window rects (2_000_001) < corner mask (max). A popup *inside* a window (`vf-select`'s fixed panel) is a different case: it stacks within its window's context, so a palette can still cover it — KNOWN-BUGS.md. `npm run verify:archetypes` (MENU TIER).
207
- - **The drag surface:** the Finder drew a dragged icon's outline on the *screen*, clipped at its edge and phase-locked to its raster, so the screen renders the layer that outline draws on — `.drag-surface`, a full-bleed child of `.screen` after the slot, `pointer-events: none` (never a hit for the page's `elementsFromPoint`), inside the raster's clip. It is deliberately no stacking context (no z-index of its own): a stacking context is an isolated group, and the outline's `mix-blend-mode` would then composite against the empty layer instead of the windows and dither beneath; the tier goes on its children (`.drag-surface > * { z-index: 2_000_001 }`), which places the outline in `.screen`'s own context, above the menu bar and under the corner mask. An icon reaches it through the `vf-drag-surface-request` handshake (§5 vf-icon): the desktop listens on its host, and the nearest desktop fills a still-null `detail.surface`. A window asks the same way and draws there with the same pen: its zoom rects when it opens from or closes to a rect (§5 vf-window, `show` and `hide`), its outline under `outline-drag`. Not the top layer: stable Safari cannot blend an element there against the page (`XOR_UNAVAILABLE`, src/cursor.ts), and a second kit entry in the top layer would need the re-promotion handshake KNOWN-BUGS.md #1 describes. `npm run verify:icon-drag` (OUTLINE).
207
+ - **The drag surface:** the Finder drew a dragged icon's outline on the *screen*, clipped at its edge and phase-locked to its raster, so the screen renders the layer that outline draws on — `.drag-surface`, a full-bleed child of `.screen` after the slot, `pointer-events: none` (never a hit for the page's `elementsFromPoint`), inside the raster's clip. It is deliberately no stacking context (no z-index of its own): a stacking context is an isolated group, and the outline's `mix-blend-mode` would then composite against the empty layer instead of the windows and dither beneath; the tier goes on its children (`.drag-surface > * { z-index: 2_000_001 }`), which places the outline in `.screen`'s own context, above the menu bar and under the corner mask. An icon reaches it through the `vf-drag-surface-request` handshake (§5 vf-icon): the desktop listens on its host, and the nearest desktop fills a still-null `detail.surface`. A window asks the same way and draws there with the same pen: its zoom rects when it opens from or closes to a rect (§5 vf-window, `show` and `hide`), its outline under `outline-drag`. Not the top layer: stable Safari cannot blend an element there against the page (`XOR_UNAVAILABLE`, src/cursor.ts). `npm run verify:icon-drag` (OUTLINE).
208
208
  - **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). **A hidden window never holds the active state.** A page that closes windows by hiding them (`vf-window.hide()`, or the `hidden` attribute set directly — the desktop watches the attribute on every slotted window) gets no activation from the hidden ones: a window slotted hidden waits to be shown, `bringToFront()` on one gives it its place in the stack and activates nothing, and the holder hiding hands the state to the topmost shown document window, or to none. A window shown into a tier where none is active takes the state, unless the tier was deliberately deactivated. Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
209
209
  - **The work area:** a slotted `vf-menu-bar` along the screen's top takes its band off the screen (`workArea`), and the gestures on the screen stay below it: a window dragged up stops with its title bar under the bar — or at `window-top`, for windows alone (`windowArea`) — where it can still be grabbed, and an icon dropped, nudged, moved (`moveTo`, `dragTo`) or carried in a group on the desktop lands below the bar. A gesture measures the box it is clamped into once, at its start (`PlacementController.seed()`), by dispatching `vf-placement-bounds-request` (bubbles, non-composed — the drag surface's handshake); the desktop answers for a host placed on its screen (`offsetParent` the desktop: a slotted window, an icon in an unplaced field) with the screen's size, bezel excluded, and the area's top as the floor, rounded up to the host's placement lattice so a snapped landing never rounds above it (21 on a lattice of 3). A host placed in a box of its own — a window body, a placed field — is clamped in that box, from 0. `npm run verify:window` (WORK AREA), `npm run verify:icon-drag` (WORK AREA).
210
210
  - **Events:** `vf-activate` (detail `{ window: HTMLElement | null }` — the new holder, `null` on deactivation).
@@ -212,13 +212,13 @@ Full-bleed classic desktop container.
212
212
 
213
213
  #### `vf-window` (`VfWindow`, vf-window.ts)
214
214
  The desktop-window shell: the classic document window (see DragThing screenshot), parameterized down to the windoid (see the Group A recipe table).
215
- - **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default true), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule). Read-only: `sizeLimits` → `{ minWidth, minHeight, maxWidth, maxHeight }`, the sizeRect as it applies in whole system px — the declared mins or the 80×54 floor, the declared maxes or `Infinity` — for a page that sizes the window itself to stay in the grow box's range. The chrome around the content is `windowChrome()` (§2, src/window-chrome.ts): the insets in system px between the outer box and the body, or under `scrollbars` the scrolled plane's viewport, for a set of declared options — `{ variant, scrollbars, headerHeight, status }` — at the default tokens. `npm run verify:window` (CHROME).
215
+ - **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default false, like every boolean attribute, so markup can leave it out; the document window and modeless dialog box recipes state it), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule), `pattern?: string` (a 1-bit fill for the body as on `vf-container`, under the content and anchored at the content region's corner; under `scrollbars` the built-in scroll area's plane carries it, so it scrolls with the content — docs/PATTERNS.md). Read-only: `sizeLimits` → `{ minWidth, minHeight, maxWidth, maxHeight }`, the sizeRect as it applies in whole system px — the declared mins or the 80×54 floor, the declared maxes or `Infinity` — for a page that sizes the window itself to stay in the grow box's range. The chrome around the content is `windowChrome()` (§2, src/window-chrome.ts): the insets in system px between the outer box and the body, or under `scrollbars` the scrolled plane's viewport, for a set of declared options — `{ variant, scrollbars, headerHeight, status }` — at the default tokens. `npm run verify:window` (CHROME).
216
216
  - **Visual:** `vfChromeFrame` + `vfTitleBar` (§4), plus a full-size flex-column layout on the frame — title bar, header, body, status strip. `display: block`. Sets `--vf-surface: var(--vf-white, #fff)` on itself.
217
217
  - Title bar: from `vfTitleBar` — height `var(--vf-titlebar-height, 18px)`, white bg, bottom `1px solid black`, contains `.vf-stripes` layer (only when `active`). `touch-action: none` only when `[movable]`.
218
218
  - Title: centered, bold, on a white patch (`padding: 0 6px`; cap band on interior rows 4..12, 7px of white between ink and stripes — see §4 `vfTitleBar` for the traced geometry and the lattice hold) above the stripes, with `--vf-title-inset: 60px` of clearance so it ellipsizes before reaching the widgets. Inactive: no stripes, widgets undrawn (transparent ink — they keep their tab stops; see A11y below), the grow box's nested squares hidden, and every managed scroll rail inside the window blanked (see "always-a-rail" §5 vf-scroll-area) — but the title text stays black (System 7 never grayed the window title).
219
219
  - Close box: LEFT side, 11×11px, 8px from the inner-left edge, with 3px of clear white above and below it, `1px solid black`, white bg, no bevel, surrounded by a 1px white patch interrupting the stripes. `:active` (pressed) → the interior fills with the classic radiating "go-away" sunburst: black 1-bit spokes on the white face (four orthogonal 3px spokes plus four diagonal 2px ones around an empty center), traced pixel-for-pixel from the UI kit's close-button-active-state sprite.
220
220
  - Zoom box: RIGHT side, same box, plus a small box nested in its top-left corner (sharing the widget's own top/left border; only the right and bottom edges are drawn). `:active` (pressed) → shows the identical sunburst as the close box; the nested box gives way to it.
221
- - Body: no inset of its own — content starts at the content region's corner, the frame's inner edge below the title bar, where a placed child's (0,0) is; an inset is the content's (a `vf-stack pad`) — and `overflow: hidden` — the window being a fixed box, content taller than it is clipped at the frame the way the classic content region was, rather than painting out over the desktop; `scrollbars` is how the user reaches the rest. Two deliberate exemptions: `[scrollbars]` restores `overflow: visible`, because the edge-rail composition below pulls the scroll area one system px *outside* the body on every side and clipping would shave exactly that overhang off (the scroll area does its own clipping); and a control's drop-open panel is not clipped — `vf-select`'s list is `position: fixed` off the control's rect precisely to escape clipping ancestors (§5 vf-select), and it still escapes, because nothing between it and the viewport establishes a containing block for fixed descendants (the grid-snap correction is a `position: relative` left/top offset, never a transform — see §7). A `vf-menu` panel is anchored `position: absolute` and *would* clip, but a menu bar belongs to the desktop, not inside a window body.
221
+ - Body: no inset of its own — content starts at the content region's corner, the frame's inner edge below the title bar, where a placed child's (0,0) is; an inset is the content's (a `vf-stack pad`) — and `overflow: hidden` — the window being a fixed box, content taller than it is clipped at the frame the way the classic content region was, rather than painting out over the desktop; `scrollbars` is how the user reaches the rest. Two deliberate exemptions: `[scrollbars]` restores `overflow: visible`, because the edge-rail composition below pulls the scroll area one system px *outside* the body on every side and clipping would shave exactly that overhang off (the scroll area does its own clipping); and a control's drop-open panel is not clipped — `vf-select`'s list is a popover in the top layer, placed `position: fixed` off the control's rect (§5 vf-select), so no ancestor clips it and no window, palettes included, paints over it. A `vf-menu` panel is anchored `position: absolute` and *would* clip, but a menu bar belongs to the desktop, not inside a window body.
222
222
  - Grow box (if `resizable`): 15×15 at bottom-right corner, white bg, 1px black top/left borders, containing two overlapping small square outlines. Inactive: the cell and its borders stay, the nested squares go — System 7 drew a deactivated window's size box hollow, with its blanked scroll rails.
223
223
  - Header (`header` slot): a band between the title bar and the body, the full width of the window — the Finder window's header line — a white interior over a 1px black rule (`vfRule`'s `.vf-rule-bottom`), part `header` (a `<div>`, never a `<header>` element, for the banner-landmark reason the title bar is a `<div>`). Like the body it carries no inset and is a positioning anchor: (0,0) is the header's own corner, flow content starts there, an inset is the content's (a `vf-stack pad`). As tall as its content unless `header-height` states it (rule included, the way every kit bar counts its rule). Clipped like the body, above the rule — the rule is a stroke, so the slot is the box content lays out, places and clips in; a drop-open panel still escapes. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails sit in the body below it, the area's top overhang landing its frame line exactly on the header's rule, so the header spans the vertical rail's column and the rail's top arrow begins under it.
224
224
  - Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule (`vfRule`'s `.vf-rule-top`) over a white interior, `--vf-status-bar-height` (15px) in all, the grow box's own height, so a `resizable` window's grow box sits flush in the strip's right end (its top/left borders take over the rule there; the strip reserves 21px of right padding under `resizable` so text clears the cell). Slotted content rides the body face's native 12px line (`--vf-line-height`), whole-pixel centered in the 14px interior, `padding-inline: 6px`, nowrap + clipped — vertically by the slot, below the rule. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails' bottom overhang lands its frame line exactly on the strip's rule, so the two never double up — though the grow box then sits in the strip rather than the rail corner cell.
@@ -229,19 +229,19 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
229
229
  - **Methods:** `placementAt(clientX, clientY, child?)` → a viewport point as a placement in the content region — `{ left, top }` in whole system px on the placement lattice: the body's corner, or under `scrollbars` the scrolled plane with the scroll offset already in it (the built-in area's own `placementAt`). The pair a child dropped into the window is written with; with `child`, the pair to write to that element, its `origin` offset folded in. `measure()` → the built-in scroll area's `measure()`, a no-op without `scrollbars`; the area re-measures by itself on its content's box, its slot and every placement write (`vf-placement-change`, §2), so this is the escape hatch for a range that changes with none of those. `show(options?)` → `Promise<boolean>`: clears `hidden`, and with `options.from` — a box in viewport CSS px (`{ left, top, right, bottom }`; a `DOMRect` fits, a zero-size box is a point), such as `vf-icon.cellRect()` — opens the window from it with the zoom rects. The window knows nothing about icons: `from` is any box. 14 rects, rect k at the fraction 0.7^(14−k) of the way from the box to the window's border box — each edge `from + trunc((frame − from) · t)`, so they bunch at the box and the last stops at 0.7 — four on screen at a time, 17 ms per step, then four steps draining; the window draws after the eighteenth (`WINDOW_RECT_*`, `windowRectFraction`, src/motion.ts). Each rect is a canvas of its own on the desktop's drag surface (the `vf-drag-surface-request` handshake), framed once with the drag outline's pen (`xorPenCanvas`, `paintSelectionRect`, src/open-art.ts) and dotted at the screen phase (`penPhase` — black lines over the dither), and a Web Animation (`element.animate()`) shows it for its four steps, every one padded with `endDelay` to the run's length so all of them finish when the window draws. Where two rects share a pixel the later one inverts it back: each canvas blends over those beneath it. Same-task writes to `left`/`top`/`width`/`height`, before or after the call, land first, so the rects end on the frame the page set. Until the rects drain the window matches `:state(opening)` and has `opacity: 0` — laid out, focusable and a hit target, so a page can focus into it at once and a press lands on it — never `visibility`, which would drop focus and pass the press through. A press anywhere or Escape (consumed in the capture phase) finishes the rects' animations (`finish()`), as they finish the walk; a second `show()` finishes the first, as it finishes a `hide()`'s; a removal, or a `hide()` before the window draws, cancels them (`cancel()`) and resolves `false`, while a move — `vf-desktop` re-inserting a raised window — is not a removal (the disconnect checks `isConnected` a microtask later, and the reconnect re-attaches the finishers). Resolves once the window draws: `true` when the rects ran, `false` when it showed at once — no `from`, `prefers-reduced-motion`, no `vf-desktop` on its path, nothing of the rects on the screen, or not connected. Raises and activates nothing; a window already on screen goes blank for the run, so a page brings an open window forward with `bringToFront` instead. `npm run verify:window` (OPEN). `hide(options?)` → `Promise<boolean>`: sets `hidden`, and with `options.to` — a box as for `show` — closes the window to it: the window's border box is read at the call, the window hidden, and the same 14 rects run the other way, rect k shown from step 13 − k, so the trail starts at the rect nearest the frame, closes in on the box and drains into it, eighteen steps in all. A press anywhere or Escape finishes them, a `show()` finishes them and shows the window, and a second `hide()` finishes the first. Removing the window leaves them running — they are the desktop's, and the window is already gone — so a page can remove a closed window straight after the call. A `show()` still opening is taken down (cancelled, so it resolves `false`) and the window hides at once. Resolves once the rects are done: `true` when they ran, `false` when the window hid at once — no `to`, `prefers-reduced-motion`, no `vf-desktop` on its path, nothing of the rects on the screen, not connected, already hidden, or still opening. `npm run verify:window` (CLOSE).
230
230
  - **Slots:** default (body content), `header` (the header strip between the title bar and the body), `status` (the bottom status strip — see Visual).
231
231
  - **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `header`, `body`, `status-bar`, `grow-box`, plus `viewport` re-exported from the built-in scroll area when `scrollbars` is set.
232
- - **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
232
+ - **Events:** `vf-close` (detail `{ reason: 'close', altKey, shiftKey, metaKey, ctrlKey }`: the modifier keys held on the close box's click, so an Option-click can close more than one window), `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
233
233
 
234
234
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
235
235
  The modal-dialog shell: movable modal by default (the dBoxProc double frame with the striped title bar set into it — traced from a 2× System 7 capture of a movable modal), the modal dialog box with `frame="plain"` (the bare double frame; see the Group A recipe table).
236
236
  - **Attributes/props:** `open: boolean` (reflect), `heading: string`, `top: number` / `left: number` (whole system px, in **viewport** coordinates — see §1 Explicit placement; unset means centered), `width: number` / `height: number` (**declare them both** — whole system px, the same fixed box `vf-window` is. A native `<dialog>` is `width: fit-content` measured against the space left beside its own offsets, and stating an offset is how the movable modal is positioned, so an undeclared dialog squeezes itself and reflows as it is dragged toward an edge. The two fall back differently — width to 260 system px, height to the content — and it names whichever are missing, once, on the open that first shows it), `label: string` (accessible name for a dialog with no `heading`), `closable: boolean` (default **false** — the bare movable-modal bar; the close box is opt-in because the HIG's Chapter 6 text denies a movable modal one while its Figure 5-1 grants it — the parameter enables either reading), `light-dismiss: boolean` (`lightDismiss`; default **false** — a click outside the frame closes the dialog with `vf-close` reason `'outside'`. Off by default because the classic modal ignored an outside click, and a dialog that asks a question should keep ignoring it; opt in for the About box and the splash, which the classic Mac did dismiss on a click. Reflected, and read at the release, so it can be toggled on an open dialog), `frame?: 'plain'`, `returnValue: string` (property only, as on `<dialog>`: the dialog's answer, cleared on each open).
237
- - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap). `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both directions. Drag the title bar to move it (shared `DragController` with `vf-window`), stating `top`/`left` in system px; drags starting on the close widget are ignored (same composedPath guard as `vf-window`). Escape → close + `vf-close` detail `{ reason: 'escape' }`; close box/programmatic/close() → `{ reason: 'close' }`; a click outside under `light-dismiss` → `{ reason: 'outside' }`. **Answers** (`VfModalDialog`): `close(value)` closes with `value` as `returnValue`, and a `<form method="dialog">` inside the dialog closes it on submit with the submitter's `value`, a submitter's `formmethod="dialog"` too. The platform's own rule looks for a `<dialog>` around the form, and this one is in the shadow root, so the base listens for `submit` on the host and closes at the end of the submit's path (`deferActivation`), not at all if anything cancelled it. `vf-button`'s submit proxy carries its `value` with or without a `name`. `vf-close`'s detail carries `returnValue`: the value that close carried, or null (Escape, a click outside, the close box, `close()` alone, removal). Return in a text field runs the form's implicit submission through the ringed `variant="default"` submit button when the form has one, ahead of the first in tree order, so a Cancel/OK row answers OK (`requestImplicitSubmit`). `npm run verify:dialog`, ANSWERS. No backdrop dimming: `::backdrop { background: transparent; }`. **Light dismiss is target identity, not geometry** (`VfModalDialog`): the `::backdrop` hit-tests as its `<dialog>`, and the frame fills the dialog's box, so the dialog is a pointer event's *target* exactly when the pointer was outside the frame — the listeners sit on the `<dialog>` itself, attached while open. It takes both halves of the click: a `pointerdown` on the backdrop arms (any other press disarms, so a right-click macOS never releases can't leave a stale arm), and a `pointerup` on the backdrop with the same `pointerId` closes — the platform's own `closedby="any"` two-step, so a press that starts on a control and slides off, or a title-bar drag released past the frame, leaves the dialog open. Deliberately not the `click` event: UI Events dispatches a press-drag-release click at the common ancestor of the two targets, which for a press on the frame released outside is the dialog itself. The platform's `closedby` attribute is not used because its dismissal arrives as `cancel`, which the base already reads as Escape. The press is consumed either way — a modal's backdrop lets nothing beneath it see the click — which is what lets a page dismiss its About box without also selecting the icon under the pointer. `npm run verify:dialog`. **Keyboard: the Dialog Manager's two rules** (`VfModalDialog`). *Initial focus:* right after `showModal()`'s own focusing steps, focus is handed to `initialFocusTarget` — a slotted control carrying `autofocus`, else the first enabled text-entry control (`vf-text-field`, `vf-number-field`, `vf-text-area`, or a native text input/textarea), else the default button (`defaultButton`: the first enabled `vf-button[variant="default"]`, looked for among the slotted content and then the shadow tree), else nothing, leaving the browser's pick. The classic dialog put the insertion point in its first text item and gave nothing else focus; the web needs a focused control, and the default button is the one that makes Return and Space work without a Tab. Left alone, `showModal()` focuses the first focusable thing in flat-tree order — Cancel in a Cancel/OK row, or a link in the body — so Return pressed the wrong thing. Both getters are `protected`, for a consumer's own modal to override. *Return/Enter:* a `keydown` listener on the `<dialog>`, bubble phase, attached while open with the light-dismiss trio. An uncancelled, unmodified Enter activates `defaultButton` (its `click()`, so a `type="submit"` default runs its proxy path) and cancels the keydown, which is what keeps a focused Cancel from firing itself — Space presses the focused control, as on the classic Mac. Anything that took Enter for itself cancelled the keydown first and is left alone: `vf-select`'s list, `vf-list`, an icon's rename, and a text field whose form ran its implicit submission (`requestImplicitSubmit` now reports whether a form owner took the press, and the fields cancel on true — so the submission runs once, not once per layer). A link keeps its own Enter. A multi-line editor (`<textarea>`, contenteditable) keeps Return for the newline; only `code === 'NumpadEnter'` fires the button there — the split a TextEdit item drew between the two keys. With no enabled default button the press is left native. Escape is unchanged (the `cancel` path above). **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`): HTML's dialog removing steps skip the close algorithm entirely, which is exactly what a framework unmount does — the teardown routes through the same native-`close` funnel (`vf-close` fires on the removed element; nothing bubbles, it left the tree), `open` and the written origin reconcile so a re-append mounts closed, and focus returns to the element focused at open time. **The placement re-settles while open** whenever the dialog's own box resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving under zoom — the viewport resizes, or the scale changes. What survives depends on how the modal got where it is: an unplaced one re-centers (it never claimed a spot, and the stranded case is exactly this one), while a dragged or authored origin is kept and only re-clamped on screen.
237
+ - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap). `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both directions. Drag the title bar to move it (shared `DragController` with `vf-window`), stating `top`/`left` in system px; drags starting on the close widget are ignored (same composedPath guard as `vf-window`). Escape → close + `vf-close` detail `{ reason: 'escape' }`; close box/programmatic/close() → `{ reason: 'close' }`; a click outside under `light-dismiss` → `{ reason: 'outside' }`. **Answers** (`VfModalDialog`): `close(value)` closes with `value` as `returnValue`, and a `<form method="dialog">` inside the dialog closes it on submit with the submitter's `value`, a submitter's `formmethod="dialog"` too. The platform's own rule looks for a `<dialog>` around the form, and this one is in the shadow root, so the base listens for `submit` on the host and closes at the end of the submit's path (`deferActivation`), not at all if anything cancelled it. `vf-button`'s submit proxy carries its `value` with or without a `name`. `vf-close`'s detail carries `returnValue`: the value that close carried, or null (Escape, a click outside, the close box, `close()` alone, removal). Return in a text field runs the form's implicit submission through the ringed `variant="default"` submit button when the form has one, ahead of the first in tree order, so a Cancel/OK row answers OK (`requestImplicitSubmit`). `npm run verify:dialog`, ANSWERS. No backdrop dimming: `::backdrop { background: transparent; }`. **Light dismiss is target identity, not geometry** (`VfModalDialog`): the `::backdrop` hit-tests as its `<dialog>`, and the frame fills the dialog's box, so the dialog is a pointer event's *target* exactly when the pointer was outside the frame — the listeners sit on the `<dialog>` itself, attached while open. It takes both halves of the click: a `pointerdown` on the backdrop arms (any other press disarms, so a right-click macOS never releases can't leave a stale arm), and a `pointerup` on the backdrop with the same `pointerId` closes — the platform's own `closedby="any"` two-step, so a press that starts on a control and slides off, or a title-bar drag released past the frame, leaves the dialog open. Deliberately not the `click` event: UI Events dispatches a press-drag-release click at the common ancestor of the two targets, which for a press on the frame released outside is the dialog itself. The platform's `closedby` attribute is not used because its dismissal arrives as `cancel`, which the base already reads as Escape. The press is consumed either way — a modal's backdrop lets nothing beneath it see the click — which is what lets a page dismiss its About box without also selecting the icon under the pointer. `npm run verify:dialog`. **Keyboard: the Dialog Manager's two rules** (`VfModalDialog`). *Initial focus:* right after `showModal()`'s own focusing steps, focus is handed to `initialFocusTarget` — the first slotted control carrying `autofocus` or `autoselect`, else the first enabled text-entry control (`vf-text-field`, `vf-number-field`, `vf-text-area`, or a native text input/textarea), else the default button (`defaultButton`: the first enabled `vf-button[variant="default"]`, looked for among the slotted content and then the shadow tree), else nothing, leaving the browser's pick. A field marked `autoselect` has its whole text selected on every open, right after `vf-show` and once the field has rendered, so a value set before `show()` or in a `vf-show` handler is the text selected. Otherwise the modal selects no text: whether a field opens selected is the page's call. The classic dialog put the insertion point in its first text item and gave nothing else focus; the web needs a focused control, and the default button is the one that makes Return and Space work without a Tab. Left alone, `showModal()` focuses the first focusable thing in flat-tree order — Cancel in a Cancel/OK row, or a link in the body — so Return pressed the wrong thing. Both getters are `protected`, for a consumer's own modal to override. *Return/Enter:* a `keydown` listener on the `<dialog>`, bubble phase, attached while open with the light-dismiss trio. An uncancelled, unmodified Enter activates `defaultButton` (its `click()`, so a `type="submit"` default runs its proxy path) and cancels the keydown, which is what keeps a focused Cancel from firing itself — Space presses the focused control, as on the classic Mac. Anything that took Enter for itself cancelled the keydown first and is left alone: `vf-select`'s list, `vf-list`, an icon's rename, and a text field whose form ran its implicit submission (`requestImplicitSubmit` now reports whether a form owner took the press, and the fields cancel on true — so the submission runs once, not once per layer). A link keeps its own Enter. A multi-line editor (`<textarea>`, contenteditable) keeps Return for the newline; only `code === 'NumpadEnter'` fires the button there — the split a TextEdit item drew between the two keys. With no enabled default button the press is left native. Escape is unchanged (the `cancel` path above). **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`): HTML's dialog removing steps skip the close algorithm entirely, which is exactly what a framework unmount does — the teardown routes through the same native-`close` funnel (`vf-close` fires on the removed element; nothing bubbles, it left the tree), `open` and the written origin reconcile so a re-append mounts closed, and focus returns to the element focused at open time. **The placement re-settles while open** whenever the dialog's own box resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving under zoom — the viewport resizes, or the scale changes. What survives depends on how the modal got where it is: an unplaced one re-centers (it never claimed a spot, and the stranded case is exactly this one), while a dragged or authored origin is kept and only re-clamped on screen.
238
238
  - **Visual (default chrome):** `vfModalFrame` with `vfTitleBar` set into it (§4) — the bar is literally the recipe `vf-window` uses, so it is identical by construction (stripes + centered title) rather than by matching copies; the frame is the modal double frame, **not** the window's shadowed `vfChromeFrame`. Outside → in: 1px outer rule; the 18px bar directly under it, 2px in at either end with its stripes starting 2px from the rule; the 2px band as the bar's floor (the bar's rule + the inner box's 1px top border); 2px gap + 2px band down the sides and along the bottom; no shadow. The body begins 20px below the dialog's top and 5px inside its other three edges. The bar takes the default `--vf-title-inset` (16px) — 60px when `closable`, the same clearance as `vf-window`, since the centered title needs symmetric room for the widget — and sets `touch-action: none` unconditionally, having no immovable state. `closable` renders the shared close box (`vfWindowWidgets` + `closeBox()` — byte-identical to `vf-window`'s, per the `moveable modal dialog.png` reference). Body is WHITE (`--vf-surface: #fff`) and is the content region, with no inset of its own: the positioning anchor, so a child placed with `top`/`left` measures from the frame's inner edge, and flow content starts at the same corner. Both chromes are full-height flex columns and the body takes the slack, for the same reason `vf-window`'s frame is: the declared `height` lands on the `<dialog>` (see `dialogSize`), and the recipes are skin only. The frame is the flex child of the `<dialog>` itself (`dialog[open]` is a flex column in `modalDialogStyles` — `[open]`-scoped, or it would out-cascade the UA's `dialog:not([open]) { display: none }`), not a `height: 100%` block: a percentage can't resolve against the undeclared-height dialog that only the UA's `dialog:modal` max-height caps, and that spill was how a viewport-tall modal stranded its buttons off-screen. **The box never grows, but over-stuffed content scrolls instead of clipping**: the body holds a `.content` scroll region (the default slot; part `content`). Placed children anchor to the body, outside that scroller, so they hold still while it scrolls. While the content fits, nothing matches. Once it overflows (`ScrollStateController`, the always-a-rail machinery), the region reserves the rail's 15px as its own right padding, wears the drawn `vfScrollRail` rail (§4) flush against the frame's inner band, which supplies the cell's outer line, and becomes a keyboard stop (`tabindex="0"`, `role="group"`, the kit's dotted ring) so the copy is scrollable without a pointer. The rail rides the body as an overlay pinned to its right edge, deliberately out of the layout flow: a rail *column*'s two fixed 15px arrow cells would hand the region a 32px minimum height, and a short dialog would then measure as fitting with the rail shown and overflowing without it — flip-flopping forever. The drop-open exemption is unchanged.
239
239
  - **The CSS and the controller state the same thing.** `.content` is `overflow-y: hidden` until the controller flags overflow, then `overflow-y: scroll`. It is deliberately never `auto`: the controller ignores the body face's negative half-leading (`LEADING_SPILL_SYS`, src/scroll-state.ts — `vf-paragraph` sets a 12-system-px line box under a 16-system-px em, so the inline box spills 2 inkless system px past the block box and `scrollHeight` counts it), and `auto` has no way to know that. With `auto` the two disagreed, and a fixed info dialog whose copy ends in a `vf-paragraph` rubber-banded under the wheel with no rail shown — 6 CSS px at scale 3. `hidden` still scrolls programmatically, so `scrollIntoView` on a focused control is unaffected. `npm run verify:contract`, OVERFLOW group.
240
240
  - **Visual (`frame="plain"`):** the same `vfModalFrame` (§4 — 1px outer, 2px gap, 2px inner band, no shadow, per `Windows/modal dialog.png`) with no title bar, and immovable like the original dBoxProc dialog (nothing renders a drag handle). The body begins 5px inside every edge. With no bar, `heading` only names the dialog and `closable` is ignored; a title in the body is the composer's, placed like any other item.
241
241
  - **A11y:** the default chrome is named by its title patch (`id="title"`) via `aria-labelledby` when `heading` is set. Otherwise — no heading, or the plain frame, which has no patch to point at — it falls back to `aria-label`: `label` if given, else `heading`, else `'Dialog'`. An explicit `label` wins over `heading`. The close box is labeled `Close ${heading}` like `vf-window`'s.
242
242
  - **Slots:** default.
243
243
  - **Parts:** `frame`, `title-bar` and `title` (default chrome), `close-box` (when `closable`), `body`, `content`.
244
- - **Events:** `vf-close` (detail `{ reason: 'escape' | 'close' | 'outside', returnValue: string | null }`).
244
+ - **Events:** `vf-show` (detail `{ focus }`, the element given the initial focus or null) once the modal is open: in the top layer, placed, and focused. It is the page's moment to finish composing the dialog: measure copy that varies and set `height`, say. A field that should open with its text selected carries `autoselect`; `detail.focus` is that field. Fired inside `show()` once the modal has rendered, else at its first render, so it is the reliable point either way. `vf-close` (detail `{ reason: 'escape' | 'close' | 'outside', returnValue: string | null }`, plus `altKey`, `shiftKey`, `metaKey` and `ctrlKey` when the close box closed it, the shape `vf-window`'s close box gives).
245
245
 
246
246
  #### The alert box (composed — no component)
247
247
  System 7's fixed modal alert (double frame: 2px outer, 2px gap, 1px inner rule, *with* the hard shadow) is deliberately not shipped as a component. What separates an alert from a modal dialog is a *picture* — the 32×32 icon — and pictures are the consumer's assets, never the library's (see the glyph-sprites note at the top of this spec). An alert box is composed from the shells above: `vf-dialog frame="plain"` with `label` stated (there is no title bar to name it), a row `vf-stack` slotting the consumer's own 32×32 art through `vf-img`, display-face copy (System 7 alerts used chrome type), and a `vf-button-group`, each placed with `top`/`left`, all inside a `<form method="dialog">` whose buttons are `type="submit"` with a `value` each, so the alert answers with the pressed button's value. The reference page carries the live recipe, composed from `demo/icons/alert.png`.
@@ -311,21 +311,21 @@ The color-swatch button: a well of solid color — a palette cell.
311
311
  ### Group C — text & value inputs
312
312
 
313
313
  #### `vf-text-field` (`VfTextField`, vf-text-field.ts)
314
- - **Attributes/props:** `value`, `placeholder`, `disabled`, `readonly`, `type: string` (default `'text'`; pass through to input), `name`. The input-behavior attributes — `autocomplete`, `inputmode`, `enterkeyhint`, `maxlength`, `pattern`, `spellcheck`, `autocapitalize` — are **forwarded verbatim** from the host onto the inner input (observed attributes, not reactive properties: four of them are globals with IDL accessors already on `HTMLElement`, and a Lit `@property` would shadow the platform member — the `align`/`draggable` trap). The platform only honors them on the element that actually takes the input, which is in the shadow root.
314
+ - **Attributes/props:** `value`, `placeholder`, `disabled`, `readonly`, `autoselect`, `type: string` (default `'text'`; pass through to input), `name`. The input-behavior attributes — `autocomplete`, `inputmode`, `enterkeyhint`, `maxlength`, `pattern`, `spellcheck`, `autocapitalize` — are **forwarded verbatim** from the host onto the inner input (observed attributes, not reactive properties: four of them are globals with IDL accessors already on `HTMLElement`, and a Lit `@property` would shadow the platform member — the `align`/`draggable` trap). The platform only honors them on the element that actually takes the input, which is in the shadow root.
315
315
  - **Visual:** inner `<input>`: white bg, `1px solid black`, NO radius, height `var(--vf-control-height, 22px)`, `padding: 0 6px`, font tokens but `font-weight: var(--vf-font-weight, 700)`. `user-select: text`. Focus: for a **keyboard** focus, the kit's 1px dashed rule (`vfFocusUnderline`, §4) one blank system px row under the well — no dotted outline and no thickened border. A click leaves it unmarked: the insertion point is already the answer to where focus went (see §4 on why this can't be `:focus-visible`). Disabled: the text dims to gray; the black border stays. Selected text inverts to solid black-on-white (`.vf-field::selection`, using `--vf-highlight`/`--vf-highlight-text`) — the 1-bit System 7 selection, shared by all three editable fields via the `vfField` skin.
316
316
  - **Structure:** the `<input>` sits in a `.vf-field-well` wrapper, which is what the focus rule hangs from and what carries `vf-snap`. A replaced element draws no pseudo-element of its own, and the host is never the thing grid snapping moves, so the wrapper is the only box that is both the well's exact shape and on the corrected grid. Same wrapper in all three fields, assembled by `VfTextControlBase.wellClass` so the focus gate can't drift between them. Two consequences when embedding: the rule paints 2 system px **below the host's own box** (`pointer-events: none`, so it never takes a click meant for what sits under it, but a tight `overflow: hidden` ancestor clips it), and a `width` set on `::part(input)` sizes the control without moving the wrapper the rule spans — a field's width belongs on the host or on `--vf-field-width`.
317
- - **Behavior:** form-associated; syncs `value` on input; `formResetCallback` restores default. A plain Enter runs the form's **implicit submission** the way HTML defines it — activating the form's default button (first submit button in tree order, `vf-button` included), so the submission carries a real `submitter` and that button's `name`/`value`, a disabled default button submits nothing, and only a form with no submit button falls back to a bare `requestSubmit()` (`requestImplicitSubmit`, text-control.ts). When a form owner took the press the keydown is cancelled, so an enclosing `vf-dialog` does not route the same Enter to its default button a second time; with no form the press goes on to the dialog, whose default button it activates.
317
+ - **Behavior:** form-associated; syncs `value` on input; `formResetCallback` restores default. `select()` selects the whole value, as a native field's does, on the inner input; with a new value not yet rendered it selects once the value is in, so `field.value = x; field.select()` selects `x`. `focus()` focuses the inner input and keeps its caret (Chromium selects all of an input's text when a host's `delegatesFocus` hands the focus on). `autoselect` is `autofocus` plus a selection: the first control carrying either takes the opening focus of its `vf-dialog`, which selects the field's whole text on every open, or of a window the shell's window manager opens, which selects it on the window's first open only (SHELL.md). A click still places the caret. A commit — native `change`, leaving the field, or Return in a form — is announced once: a commit that brings nothing new since the field took focus or since the last commit fires nothing. In a form, Return commits first, then runs the form's implicit submission, the order a native input keeps, so `change` comes before the form's `submit`. That submission works the way HTML defines it — activating the form's default button (first submit button in tree order, `vf-button` included), so the submission carries a real `submitter` and that button's `name`/`value`, a disabled default button submits nothing, and only a form with no submit button falls back to a bare `requestSubmit()` (`requestImplicitSubmit`, text-control.ts). When a form owner took the press the keydown is cancelled, so an enclosing `vf-dialog` does not route the same Enter to its default button a second time; with no form the press goes on to the dialog, whose default button it activates.
318
318
  - **Parts:** `input`.
319
- - **Events:** `vf-input` detail `{ value }` on every keystroke; `vf-change` detail `{ value }` on commit (native change). Plus the native pair per §2: the inner input's own composed `input` crosses the boundary itself; `change` is re-dispatched from the host.
319
+ - **Events:** `vf-input` detail `{ value }` on every keystroke; `vf-change` detail `{ value }` on commit. Plus the native pair per §2: the inner input's own composed `input` crosses the boundary itself; `change` is re-dispatched from the host.
320
320
 
321
321
  #### `vf-text-area` (`VfTextArea`, vf-text-area.ts)
322
- Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding, so the text and the outer box sit exactly where the bordered field puts them. The well is the same wrapper vf-text-field uses; the focus rule spans the full frame, scroll rail included (the frame is a stroke inside the padding box, so the `-2px` well offset, §4 vfFocusUnderline). Forwards the same input-behavior attributes as vf-text-field, minus `pattern` (only an `<input>` takes it). Parts: `textarea`. Events: `vf-input`, `vf-change`, plus the native pair (§2).
322
+ Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding, so the text and the outer box sit exactly where the bordered field puts them. The well is the same wrapper vf-text-field uses; the focus rule spans the full frame, scroll rail included (the frame is a stroke inside the padding box, so the `-2px` well offset, §4 vfFocusUnderline). Forwards the same input-behavior attributes as vf-text-field, minus `pattern` (only an `<input>` takes it). `select()`, `focus()` and `autoselect` work as on vf-text-field. Parts: `textarea`. Events: `vf-input`, `vf-change`, plus the native pair (§2).
323
323
 
324
324
  #### `vf-number-field` (`VfNumberField`, vf-number-field.ts)
325
325
  A numeric text field paired with the classic "little arrows" stepper.
326
- - **Attributes/props:** `value: string`, `min?: number`, `max?: number`, `step: number` (default 1; also sets the value's decimal precision), `placeholder`, `disabled`, `readonly`, `name`, `label`.
326
+ - **Attributes/props:** `value: string`, `min?: number`, `max?: number`, `step: number` (default 1; also sets the value's decimal precision), `placeholder`, `disabled`, `readonly`, `autoselect`, `name`, `label`.
327
327
  - **Visual:** a form-associated `<input>` (white well, 1px black border, value right-aligned) with a 3px gap to the little-arrows stepper. Focus draws the dashed rule under the well like `vf-text-field` — the `.vf-field-well` wrapper is the flex item, so the rule stops at the well and never runs under the stepper, which is not where the insertion point is. The stepper is the `STEPPER` glyph (rounded 1-bit frame + hollow up/down arrows from `Little arrows.png`), rendered inline at its **native 15×25** so it stays pixel-crisp. The well and the sprite are authentically different heights (the reference sheets measure fields at 22 and the arrows at 25), so the well keeps `var(--vf-control-height, 22px)` — lining up with every sibling control — and the taller sprite sets the host's height. The odd 3px remainder is biased a whole pixel (1 above the well, 2 below) rather than centered, which would place it on a half pixel and fringe at every scale. Holding an arrow overlays its solid fill (`STEPPER_UP_FILL` / `STEPPER_DOWN_FILL`, synthesized to match the kit's hollow→filled press convention). Disabled: the value dims to gray; the box and stepper stay black.
328
- - **Behavior:** `role="spinbutton"` on the input with `aria-valuenow/min/max`. Clicking an arrow steps by `step`, clamped to `min`/`max`, rounded to `step`'s precision; press-and-hold autorepeats (300ms delay, then ~60ms). Keyboard: ArrowUp/ArrowDown step, Home/End jump to min/max. Typing is free-form; the value normalizes (clamp + round) on commit (native `change`). Form-associated (submits `value`; `formResetCallback` restores the default). `readonly` blocks stepping and editing.
328
+ - **Behavior:** `role="spinbutton"` on the input with `aria-valuenow/min/max`. Clicking an arrow steps by `step`, clamped to `min`/`max`, rounded to `step`'s precision; press-and-hold autorepeats (300ms delay, then ~60ms). Keyboard: ArrowUp/ArrowDown step, Home/End jump to min/max. Typing is free-form; the value normalizes (clamp + round) on commit, which comes as in vf-text-field: native `change`, leaving the field, or Return in a form, which commits before it submits, so a dialog's OK reads the clamped value. Form-associated (submits `value`; `formResetCallback` restores the default). `readonly` blocks stepping and editing. `select()`, `focus()` and `autoselect` work as on vf-text-field.
329
329
  - **Parts:** `input`, `stepper`.
330
330
  - **Events:** `vf-input` detail `{ value, valueAsNumber }` on every keystroke; `vf-change` detail `{ value, valueAsNumber }` on commit or step. Plus the native pair (§2): a step dispatches both from the host (a native spinner's pair — a step has no inner native event at all), a typed commit re-dispatches `change`. Forwards the input-behavior attributes like vf-text-field (no `pattern`); `inputmode`/`autocomplete` default to `decimal`/`off` when the host doesn't say otherwise.
331
331
 
@@ -334,14 +334,14 @@ The classic popup menu control ("Macintosh HD ▼").
334
334
  - **Attributes/props:** `value: string`, `disabled`, `name`, `size: 'regular' | 'small'` (reflected; any other value renders as `regular`), `no-shadow` (`noShadow: boolean`, reflected).
335
335
  - **Children:** `<vf-option value="...">Label</vf-option>` elements (default slot). `vf-option` (`VfOption`, vf-option.ts): props `value`, `disabled`, `selected` and `size` (both managed by parent — the select stamps its `size` on every option, including ones added later); renders its slot; `role="option"`.
336
336
  - **Visual (closed control):** height `var(--vf-popup-height, 18px)`, white bg, `1px solid black`, NO radius, `box-shadow: 1px 1px 0 0 var(--vf-black, #000)` (the small hard shadow visible in the screenshot; `no-shadow` removes it from the pill, and the open panel keeps its own), `padding: 0 8px 0 var(--vf-select-gutter, 16px)` — the left inset equals the option checkmark gutter so the selected label sits at the same x closed or open. *One documented 1px deviation:* the reference sheets put a closed pill's label ink 16px from its border box but an open menu's at 17 (three pill instances and four menu panels, all consistent, in the reference art). System 7 reconciled that by drawing the open panel 1px left of the pill; the kit instead uses one shared gutter with no panel offset, which keeps the closed↔open alignment exact and puts the closed pill's ink at 17. Bold label left, the black `CARET_DOWN` ▼ pixel glyph (inline SVG) right with 8px gap. The ▼ stays black even when the control is disabled (only the label dims). **Width:** the control hugs the *widest* option — no intrinsic min-width — via an invisible, height-collapsed stack of every option's text sharing the label's grid cell; so the closed pill and the open panel are always exactly the same width and the value never shifts as the selection changes. Authors wanting a floor set `min-width` on the host (or grow it in their layout, e.g. `flex: 1`). **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at `calc(-2px - depth)`, where depth is the pill's shadow (1px, 0 under `no-shadow`): a dashed rule under the *whole* pill (hard shadow + blank row below the border box), not inside the face the way vf-button underlines its label: the pill's one line already holds the label and the ▼. Spans the border box, which is the padding box the pseudo-element sizes to (the pill's line is a stroke). **Closed only**, as `vf-menu`'s is: the open list already shows where focus is, and a panel short enough not to cover the rule — a one-option menu overlays the pill exactly — would leave a stray dashed line below it. Gated on a `.vf-focus-rule` class from the page's input modality, not `:focus-visible` — see §4 for why this control can't use the selector either. `npm run verify:focus`.
337
- - **Visual (open):** panel uses the `.vf-panel` recipe but overrides `--vf-shadow-offset: 1px` so its hard shadow matches the pill's (not the 2px menu shadow). Positioned `position: fixed` to the control's exact width and left (unsnapped, so the panel's left/right edges and shadow coincide with the pill's). Item rows are height 16px — the pill's *content* height, derived as `calc(var(--vf-popup-height, 18px) - 2px)` so a re-themed pill moves its rows with it — `padding: 0 20px 0 var(--vf-select-gutter, 16px)`; the panel opens with the selected row's cell laid directly over the closed pill (its top border on the pill's top border, extending downward), so the selected label's position and surrounding whitespace are identical closed and open. The currently-selected item shows a ✓ checkmark in the left `--vf-select-gutter` column; hovered/active item inverts (black bg, white text); disabled options gray.
337
+ - **Visual (open):** panel uses the `.vf-panel` recipe but overrides `--vf-shadow-offset: 1px` so its hard shadow matches the pill's (not the 2px menu shadow). A manual popover (`popover="manual"`), shown in the top layer while open, so it paints over every window, palettes included — a window is a stacking context no z-index inside it can leave; the drawn cursor re-promotes itself above it (src/top-layer.ts). Positioned `position: fixed` to the control's exact width and left (unsnapped, so the panel's left/right edges and shadow coincide with the pill's). Item rows are height 16px — the pill's *content* height, derived as `calc(var(--vf-popup-height, 18px) - 2px)` so a re-themed pill moves its rows with it — `padding: 0 20px 0 var(--vf-select-gutter, 16px)`; the panel opens with the selected row's cell laid directly over the closed pill (its top border on the pill's top border, extending downward), so the selected label's position and surrounding whitespace are identical closed and open. The currently-selected item shows a ✓ checkmark in the left `--vf-select-gutter` column; hovered/active item inverts (black bg, white text); disabled options gray.
338
338
  - **Visual (open, clipped):** a list taller than the screen is **clipped, never scrolled** — System 7 put no scrollbar on a menu. The panel is drawn once at a whole number of row slots, and a `.clip` element inside its stroke is `overflow: hidden` (the panel itself would clip at its outer edge, over its frame); the rows ride a `.rows` strip rolled by `transform` inside the clip, and the edge slot with items beyond it is covered by an opaque white **arrow slot** (`part="scroll-arrow"`, `aria-hidden`) carrying `CARET_UP` / `CARET_DOWN`. Traced from a real System 7 popup clipped at the screen edge (Find File's criteria menu under Infinite Mac, 2×): the 11×6 triangle sits 13px in from the panel's content edge and 5px down its 16px row, and the arrow occupies a **full** row slot. The pointer resting on an arrow rolls the list one row per `MENU_SCROLL_INTERVAL_MS` (66ms, motion.ts — the interaction itself, so *not* reduced-motion gated); the arrow retires when its direction runs out. Both can show at once. Four invariants, all in `src/popup-overflow.ts`:
339
339
  - **The clamp is quantized to the pill lattice.** Rows are clipped a whole row at a time off `pillTop − selectedIndex × rowHeight`, so a clipped popup still opens with its selected row exactly over the closed pill — what an un-quantized pixel clamp loses. One deliberate exception: when the selected row would land *under* an arrow (the pill within one row of a screen edge with items beyond it), the scroll shifts by one and the overlay gives way by exactly one row. Rare, and purely geometric.
340
340
  - **The panel is as tall as the *list* asked for, not as tall as the rows it can currently show** — capped only by the screen band. A list that fits the band but not where the pill would put it keeps every slot and slides whole rows, while the item strip stays welded to the pill; the slots the strip no longer reaches are drawn as **empty white**. The blank is the exact travel the list rolls through: scrolling to that end lands the strip flush with the panel, precisely full, both arrows retired. This is what System 7 drew — 5 empty rows above `name` in Find File's criteria popup, 2 above `Athens` in Character Set's font menu. **The two directions are one rule:** a panel pushed up (the pill is low) reserves its blank at the top and rolls down into it; a panel pushed down (the pill is high with a late item selected) reserves it at the bottom and rolls up into it. Only the sign of the scroll integer distinguishes them — it is free to leave the `[0, rowCount − visibleSlots]` range that would fill every slot, and that departure *is* the blank. It follows that the blank is a one-way starting position: an arrow means "rows are really hidden this way", so whichever end the blank is on, the arrow pointing back at it is off.
341
341
  - **The panel box never moves or resizes while open:** scrolling rolls the items inside it.
342
342
  - **Three slots minimum** when overflowing, even if that intrudes on the insets below — with both arrows shown, fewer leaves nothing to pick. The screen-edge band comes from `--vf-popup-inset-top` / `--vf-popup-inset-bottom` (§3), whose 4px defaults reproduce the old viewport margins exactly, so a list that fits is untouched by any of this. `npm run verify:select-overflow`.
343
343
  - **Visual (small):** `size="small"` sets the pill and its rows in the body face (`vfBodyDecls`): a `var(--vf-popup-height-small, 12px)` pill over 10px rows. Content rows: 1 blank, 7 for the capitals, 2 for the descenders, which end on the last content row. The label's line box is 8px (`--vf-popup-height − 4px`) with the descender rows below it as padding — a 10px line box centers the 16px em one row lower and puts the second descender row on the border. The pill label pads the em's full 4px descent and takes 2px back with a negative margin, so its clip never leaves scrollable overflow; each option clips its own row (`overflow: clip`). The ▼ is `CARET_DOWN_SMALL` — `CARET_DOWN` without its widest row, 9×5 — pinned 3 rows down the content (3 blank rows above, 2 below), its middle row level with the x-height's middle row; the panel's scroll arrows are `CARET_UP_SMALL` / `CARET_DOWN_SMALL` at (14, 2) in their 10px slots, 14 keeping the regular caret's center column. The ✓ sits at (3, 1), centered in the row. The ✓ glyph, the gutter, the insets, the hard shadow and the focus rule are the regular pill's. Glyphs reaching more than 8 rows above the baseline are clipped at the top: the accented capitals (É, Å, Ñ …), `å ƒ ∫ ⌘ ⇧ ▶ ◀`, and the Apple logo. Centered in a row beside a `vf-label` of either face, the small pill's baseline sits 1px above the label's; placed, a body-face label lines up at the pill's `top` − 1 and a display-face label at `top` − 3. A live `size` change closes an open panel. `npm run verify:control-heights`, `npm run verify:baseline`.
344
- - **Behavior:** form-associated. Opens on pointerdown (mouse/touch), Space/Enter/ArrowDown, or a synthesised click (assistive tech). Two pointer styles coexist, disambiguated by the gesture and resolved at the first release: **System 7 press-drag-release** (press the pill, drag onto an item, release to pick — releasing on the current item or off the list cancels) and **modern click-to-open** (a quick in-place click leaves the list open for a second click). The only timed distinction is an in-place release — a tap (<200ms) keeps the list open, a held press closes it; any press that travels to another item is a drag-pick regardless of duration. Highlight tracking during a press hit-tests by coordinates (so it works under touch's implicit pointer capture) and never moves DOM focus off the control. Panel is positioned `position: fixed` from `getBoundingClientRect()` so it escapes clipping containers; closes on outside pointerdown, Escape, blur, scroll. Keyboard while open: arrows move active item, Enter/Space select, Escape cancels, Home/End jump, and printable keys run the shared Finder first-letter type-ahead (`src/type-ahead.ts` — the same buffer `vf-list` and the menus use), moving the highlight to the match. On select: classic blink (invert toggles ~3 times in ~250ms) then close + `vf-change`. A press that *begins* on an arrow is neither a pick nor a dismissal (so a click on one leaves the list up); a press-drag *released* on one closes with no change, like any release on a non-item — and while a drag is in an arrow's zone the highlight is dropped, because the arrow row is not an item. That zone reaches past the panel edge in its own direction, the classic ergonomics of slamming the pointer to the screen edge.
344
+ - **Behavior:** form-associated. Opens on pointerdown (mouse/touch), Space/Enter/ArrowDown, or a synthesised click (assistive tech). Two pointer styles coexist, disambiguated by the gesture and resolved at the first release: **System 7 press-drag-release** (press the pill, drag onto an item, release to pick — releasing on the current item or off the list cancels) and **modern click-to-open** (a quick in-place click leaves the list open for a second click). The only timed distinction is an in-place release — a tap (<200ms) keeps the list open, a held press closes it; any press that travels to another item is a drag-pick regardless of duration. Highlight tracking during a press hit-tests by coordinates (so it works under touch's implicit pointer capture) and never moves DOM focus off the control. Panel is in the top layer, positioned `position: fixed` from `getBoundingClientRect()`, so it escapes clipping containers and stacking contexts; closes on outside pointerdown, Escape, blur, scroll. Keyboard while open: arrows move active item, Enter/Space select, Escape cancels, Home/End jump, and printable keys run the shared Finder first-letter type-ahead (`src/type-ahead.ts` — the same buffer `vf-list` and the menus use), moving the highlight to the match. On select: classic blink (invert toggles ~3 times in ~250ms) then close + `vf-change`. A press that *begins* on an arrow is neither a pick nor a dismissal (so a click on one leaves the list up); a press-drag *released* on one closes with no change, like any release on a non-item — and while a drag is in an arrow's zone the highlight is dropped, because the arrow row is not an item. That zone reaches past the panel edge in its own direction, the classic ergonomics of slamming the pointer to the screen edge.
345
345
  - **Parts:** `control`, `label`, `arrow`, `panel`, `scroll-arrow`.
346
346
  - **Events:** `vf-change` detail `{ value }`, plus the native `input`/`change` pair per committed pick (§2).
347
347
 
@@ -364,7 +364,7 @@ The classic popup menu control ("Macintosh HD ▼").
364
364
 
365
365
  #### `vf-menu-bar` (`VfMenuBar`, vf-menu-bar.ts)
366
366
  - **Attributes/props:** `label: string` — accessible name for the menubar, mirrored as host `aria-label` (guarded: a consumer's own `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected) — draws the System 7 screen-corner mask over the bar's top-left/top-right corners. `shortcuts: boolean` (reflected) — makes every slotted menu's item `shortcut`s live key equivalents, one declaration for the whole bar the way MenuKey() answered for every menu at once (see vf-menu-item for the claim contract). Off by default: key equivalents are page-global, and only *the* menu bar should own them.
367
- - **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` — 19 white system px over the 1px black rule, the Menus.png bar strip exactly — white bg, the rule `vfRule`'s `.vf-rule-bottom` on the bar (§4), children laid out horizontally from left. Adjacent titles are pulled 5px into each other (`margin-inline-start: -5px` on every slotted menu, absorbed by 14px of bar start padding — 9px of bar before the first plate, as System 7 placed the Apple title, plus the first title's 5px share): Menus.png spaces title ink 14px apart while each title's plate runs 10px left / 9px right of its own ink, so neighboring plates *overlap* by 5px, as the originals did. With `rounded`, two 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in `--vf-black` *over* the bar: on the classic screen the rounding was the ROM's black corner mask sitting on top of the menu bar, not a shape of the bar's own, so it is ink over any backdrop rather than a cutout.
367
+ - **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` — 19 white system px over the 1px black rule, the Menus.png bar strip exactly — white bg, the rule `vfRule`'s `.vf-rule-bottom` on the bar (§4), children laid out horizontally from left. Adjacent titles are pulled 4px into each other (`margin-inline-start: -4px` on every slotted menu, absorbed by 13px of bar start padding — 9px of bar before the first plate plus the first title's 4px share): title ink sits 15px apart while each title's plate runs 10px left / 9px right of its own ink, so neighboring plates *overlap* by 4px. With `rounded`, two 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in `--vf-black` *over* the bar: on the classic screen the rounding was the ROM's black corner mask sitting on top of the menu bar, not a shape of the bar's own, so it is ink over any backdrop rather than a cutout.
368
368
  - **Behavior:** container/controller for slotted `vf-menu` children. Pressing a menu label → opens it (label inverts while open). While any menu is open, hovering another label switches to it (classic behavior). Escape / outside click / item selection closes. `role="menubar"`, behind a first-connect ownership latch so a consumer's own role survives upgrade; the shadow `.bar` is `role="presentation"` and each slotted `vf-menu` host `role="none"`, so the `menubar → menuitem` ownership chain has no generics in it. While a menu is open: ArrowLeft/Right move between menus, ArrowDown/Up walk the open menu's items, Home/End jump to its first/last enabled item, and printable keys run the shared Finder first-letter type-ahead over the items (`src/type-ahead.ts`; Space stays the focused item's activation key, and the prefix resets on menu switch or close). The bar also **owns the press-drag-release gesture** across its menus (`MenuPressController`, `src/menu-press.ts` — see `vf-menu`), since one press may travel over several of them: it binds the opening `pointerdown` and hands the controller its own open/close rules, so the gesture changes *when* a menu opens, never *how*.
369
369
  - **Slots:** default (vf-menu elements); `end` — the bar's right end, where a clock goes: a flex row of its own pushed to the end with an auto start margin and held 9px in, mirroring the first title's inset, setting `--vf-label-line-height` to the bar's height so a `vf-label` there rides the titles' baseline. Empty, it is `display: none` and takes no part in the row, so a page that pushes its own item to the end keeps the whole of the slack. `npm run verify:archetypes` (END SLOT). **Parts:** `bar`.
370
370
 
@@ -378,12 +378,13 @@ The classic popup menu control ("Macintosh HD ▼").
378
378
  - *System 7 press-drag-release* — press the title, slide onto a command, release over it to run it. Sliding sideways onto another title switches menus mid-press. Releasing over a disabled row, a separator, the title, or off the menu closes with nothing chosen (the classic "release outside").
379
379
  - *Modern click-to-open* — a quick in-place tap (under `PRESS_HOLD_MS`, 200ms) on a title leaves the menu dropped for a second, independent click; a *held* in-place press closes it, and a press on an already-dropped title closes it. Time is consulted only for an in-place release: any press that travels is a drag-pick however long it took. Hit-testing is by **coordinates**, not event target, so tracking survives touch's implicit pointer capture (every move is delivered to the pressed title); the row under the pointer carries `vf-menu-item[active]` rather than relying on `:hover`, which capture defeats. The trailing `click` the browser synthesises after a press is swallowed by the label and by the row (the same guard `vf-select` uses), so a keyboard/assistive-tech click — which arrives with no preceding pointerdown — is the only click that still toggles or activates.
380
380
  - **Slots:** default (vf-menu-item / vf-separator), `label` (replaces the `label` text in the bar — e.g. the Apple menu's `vf-img` apple; the `label` attribute stays set as the accessible name, mirrored to the bar item's `aria-label`). **Parts:** `label`, `panel`.
381
+ - **An icon title.** When the `label` slot holds only art (`vf-img`, `img`, `svg`, `picture` or `canvas`), the title is drawn as an icon: the art goes in a 16×16 cell, 6 system px into its plate and 6 short of the plate's end (`padding-inline` 6/6 in place of the text title's 9/8), on the plate's top row: one row above where centering on the bar's line would put it. Art drawn from x 2, y 1 of its cell, as the apple is (its ink is 11 × 14 at x 2..12, y 1..14), then has 8 px of plate before its ink: as the first title, the cell lands at x 15, its ink at x 17..27 on rows 2..15, and the next title's plate at x 33, its ink at x 43. A text title, from `label` or text in the slot, keeps the text geometry. `npm run verify:archetypes` (ICON TITLE).
381
382
 
382
383
  #### `vf-menu-item` (`VfMenuItem`, vf-menu-item.ts)
383
384
  - **Attributes/props:** `disabled`, `checked` (shows ✓ in left gutter), `checkable` (declares a toggle up front — see Behavior), `shortcut: string` (e.g. `"⌘H"`, drawn in the left-aligned shortcut column; a **live key equivalent** under a `shortcuts` grant — see Behavior), `value?: string` (defaults to text content), `active` (reflect; the transient press-drag highlight, managed by the menu — mirrors `vf-option[active]`, not an authoring API).
384
385
  - **Visual:** height `var(--vf-menu-row-height, 16px)` — `Menus.png` puts every menu row on a 16px pitch (3px above + the 9px glyph + 4px below), so a pulldown row matches a popup row exactly; the line box is locked to the same expression so an inherited line-height can never overflow the panel. `padding: 0 12px 0 var(--vf-select-gutter, 16px)` (left gutter for ✓, shared with `vf-select`/`vf-option` — `Menus.png` puts a pulldown's label ink at the same inset as a popup's; a shortcut row trades the 12 for 1px, the slot being the clearance there). The shortcut is left-aligned in a right-anchored `--vf-menu-shortcut-column` (23px) slot — every ⌘ at the same x, 23px from the right border, the widest letters running to within ~3px of it (`Menus.png`'s File pulldown) — with an 8px label↔shortcut min gap. `color: var(--vf-disabled)` when disabled. Hover, `[active]`, keyboard focus (not disabled): full-width inversion — each with its own `.blink-off` override at matching specificity, so a drag-picked row keeps its flag through the blink and the release reads as the highlight flashing off. A disabled row dims its ✓ along with the label — a **documented deviation** from the §1 "dim the label, chrome glyphs stay black" rule, because authentic System 7 greyed the whole disabled row.
385
386
  - **Behavior:** `role="menuitem"` — or `role="menuitemcheckbox"` with `aria-checked` when the item is *checkable*: either `checkable` is set, or the item has ever been `checked`. Set `checkable` on a toggle that starts **off**, which would otherwise announce as a plain command until its first flip (a boolean `checked` attribute can't express "checkable but off"). The role is re-derived on every connect, so re-parenting a checkable item keeps it; an author-supplied `role` is left alone. On activation — a click, Enter/Space, or the public `activate()` the menu's press gesture calls for the row a drag was released over (which the row's own `click` never sees, since a press that started on the title dispatches its click above both): classic **blink** (invert toggles 3 times over ~250ms via timer; skipped under `prefers-reduced-motion`, selecting at once), then dispatch `vf-menu-select` detail `{ value, item }` and signal ancestors to close the menu — the close signal is skipped when the ancestor menu is already closed (the key-equivalent path below), since the close paths return focus to the bar label and would yank it from wherever the user was typing. Disabling an item mid-blink cancels it and drops the pending `vf-menu-select`.
386
- - **Key equivalents** (the MenuKey() half of `shortcut`): under an ancestor `vf-menu[shortcuts]` / `vf-menu-bar[shortcuts]` grant, a document-level keydown matching the shortcut activates the item from anywhere on the page, menu open or not — File → Save *shows* ⌘S and *answers* it, no page-side plumbing. Matching is exact on modifiers (⌘=Meta, ⌃=Control, ⌥=Alt, ⇧=Shift — the same mapping `aria-keyshortcuts` announces) with `event.key` compared case-insensitively, except that a declared ⌘ is met by Control too — Ctrl standing in for ⌘ off the Mac, vf-icon's own ⌘O reading — while a shortcut spelling ⌃ itself keeps meaning the Control key alone; the grant is checked per event, so toggling it needs no re-wiring. The claim contract: a `defaultPrevented` stroke is already someone's (a page handler that ran first keeps its key); a match claims with `preventDefault()` and no `stopPropagation`, so the first connected item wins a contested key and later page listeners still observe the claimed event; a **disabled** item claims nothing — the stroke falls through, so a grayed Undo leaves ⌘Z to the focused field's own native undo; auto-repeat strokes are claimed but activate once; a shortcut with no ⌘/⌃/⌥ and a single printable key renders and announces but never matches (a bare letter claimed globally would hijack typing — a named key like "F1" needs no modifier). Activation is the normal path — blink, `vf-menu-select` — and a **closed** ancestor menu answers by flashing its bar title on the same cadence (MenuKey's acknowledgment; skipped with the blink under `prefers-reduced-motion`): the panel never opens and focus never moves. `npm run verify:menu-keys`.
387
+ - **Key equivalents** (the MenuKey() half of `shortcut`): under an ancestor `vf-menu[shortcuts]` / `vf-menu-bar[shortcuts]` grant, a document-level keydown matching the shortcut activates the item from anywhere on the page, menu open or not — File → Save *shows* ⌘S and *answers* it, no page-side plumbing. Matching is exact on modifiers (⌘=Meta, ⌃=Control, ⌥=Alt, ⇧=Shift — the same mapping `aria-keyshortcuts` announces) with `event.key` compared case-insensitively, except that a declared ⌘ is met by Control too — Ctrl standing in for ⌘ off the Mac, vf-icon's own ⌘O reading — while a shortcut spelling ⌃ itself keeps meaning the Control key alone; the grant is checked per event, so toggling it needs no re-wiring. The claim contract: a `defaultPrevented` stroke is already someone's (a page handler that ran first keeps its key); a match claims with `preventDefault()` and no `stopPropagation`, so the first connected item wins a contested key and later page listeners still observe the claimed event; a **disabled** item claims nothing — the stroke falls through, so a grayed Undo leaves ⌘Z to the focused field's own native undo; auto-repeat strokes are claimed but activate once; a shortcut with no ⌘/⌃/⌥ and a single printable key renders and announces but never matches (a bare letter claimed globally would hijack typing — a named key like "F1" needs no modifier). The arrow glyphs ← ↑ → ↓ name the arrow keys: "⌘→" matches `ArrowRight` and announces as `Meta+ArrowRight`; a bare arrow counts as a single key and never matches, so it can't take the arrows from a list, a menu or a field. Activation is the normal path — blink, `vf-menu-select` — and a **closed** ancestor menu answers by flashing its bar title on the same cadence (MenuKey's acknowledgment; skipped with the blink under `prefers-reduced-motion`): the panel never opens and focus never moves. `npm run verify:menu-keys`.
387
388
  - **Slots:** default (label). **Parts:** `item`, `check`, `label`, `shortcut`.
388
389
  - **Events:** `vf-menu-select` — menu-specific by design. A plain `vf-select` would collide with the `<vf-select>` popup on any delegated ancestor listener, since both bubble and compose while `<vf-select>` itself commits with `vf-change`. Parallels the existing `vf-menu-*` coordination names.
389
390
 
@@ -397,7 +398,7 @@ Classic list box.
397
398
 
398
399
  #### `vf-scroll-area` (`VfScrollArea`, vf-scroll-area.ts)
399
400
  A container whose scrollbars look like System 7.
400
- - **Attributes/props:** `axis: 'vertical' | 'horizontal' | 'both'` (default `'vertical'`, reflected) — which scroll rails to reserve as permanent placeholders (see "always-a-rail" below); `corner: boolean` (default false, reflected) — reserves the bottom-right corner cell on a single-axis rail (the rail stops 15px short of the frame, the viewport spans the rest), for a grow box to land in; inert on `both`, which always has the cell; `label: string` — accessible name for the viewport (`aria-label` on the viewport, since an `aria-label` on the host cannot reach into the shadow DOM). The viewport is a **Tab stop only while its content actually overflows** — the same state `ScrollStateController` measures for the rails; a fitting scroll area used to be a focusable stop with `role: generic` and no name, a dead Tab press. Whenever it is a stop it carries a role: `role="region"` when `label` names it (a named landmark), `role="group"` when not (an unnamed region is inert, so that role is reserved for the labelled case).
401
+ - **Attributes/props:** `axis: 'vertical' | 'horizontal' | 'both'` (default `'vertical'`, reflected) — which scroll rails to reserve as permanent placeholders (see "always-a-rail" below); `corner: boolean` (default false, reflected) — reserves the bottom-right corner cell on a single-axis rail (the rail stops 15px short of the frame, the viewport spans the rest), for a grow box to land in; inert on `both`, which always has the cell; `label: string` — accessible name for the viewport (`aria-label` on the viewport, since an `aria-label` on the host cannot reach into the shadow DOM); `pattern?: string` — a 1-bit fill for the scrolled plane, as on `vf-container` (`vfPatternFill` on the plane): it fills the visible area, since the plane is never smaller than the viewport, runs on past the content, and scrolls with it, its phase anchored at the content's top-left; unset, the plane is white, and an unrecognized value paints nothing and warns once (docs/PATTERNS.md). The viewport is a **Tab stop only while its content actually overflows** — the same state `ScrollStateController` measures for the rails; a fitting scroll area used to be a focusable stop with `role: generic` and no name, a dead Tab press. Whenever it is a stop it carries a role: `role="region"` when `label` names it (a named landmark), `role="group"` when not (an unnamed region is inert, so that role is reserved for the labelled case).
401
402
  - **Visual:** `display: block`; the snapped wrapper carries a real 1px black frame and a grid reserving each rail as its own edge column/row — `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the white inner viewport carrying no inset of its own, so slotted content and the (0,0) of placed children sit exactly 1 system px from the frame box at every scale; an inset is the content's (a `vf-stack pad`). Consumer sets width/height on host. The scrolled plane — the wrapper the slot renders into, and the positioned anchor for placed children — is `width: fit-content; min-width: 100%`: never narrower than the viewport, as wide as content that cannot wrap, so the controllers' ResizeObserver on it sees a row growing sideways the way it sees copy growing down (a block wrapper's auto width was the viewport's whatever the row did, and a row gaining a cell left the thumb and the overflow state stale until something else re-measured); copy wraps as before, and a `position: sticky; left: 0` child holds across the scroll, the plane being its containing block. `min-height: 100%` likewise: a `fixed` child (§2 Explicit placement) is a sticky box the plane contains, and a plane shorter than the child's stated `top` would clamp it to the plane's bottom edge; content that fits still fits. In the overflowing case a percentage-width or centered block child resolves against the grown plane, not the viewport. A `slotchange` re-measures; `measure()` covers a scroll range that changes with no box changing. The rails are the drawn `vfScrollRail` subtree (§4 — the classic 16px cell counting the frame line: divider, 14px channel, 15px arrow cells, the fixed 16px thumb, the dot-dither trough), rendered as later siblings of the viewport and synced to its native scrolling by `ScrollRailController`; the viewport carries the `vf-scroll` class, which hides the native bar without touching the native scrolling. When both rails are reserved, the corner cell joins them, supplying the interior dividers the adjacent arrow cells leave to it; `corner` reserves the same 15×15 cell beside a single rail, its other divider continuing the rail's line across the cell — DrawGrowIcon's form, for the grow box. Every engine renders the same rail; the old `::-webkit-scrollbar` skin, its `.vf-scroll-frame` overlay contortion (WebKit quantized native scrollbar rects to whole CSS px) and the Firefox `scrollbar-color` fallback are all retired.
402
403
  - **Always-a-rail behavior:** each *reserved* axis (per `axis`) renders its rail element as a permanent placeholder — arrow buttons on an empty white channel (dither off, no thumb; System 7 drew the arrows on any bar in an active window, and a scroller outside a window always counts as active) — until the content overflows that axis, when the dither and thumb fill in and the arrows go live (an idle axis's arrows are drawn but inert — the press guard in `ScrollRailController` skips them). `ScrollStateController` (`src/scroll-state.ts`) measures both axes and writes `data-overflow-x` / `data-overflow-y` (`"true"` / `"false"`) on the scroll element; the recipe keys the dither and thumb off those attributes. The unreserved axis still scrolls natively (wheel, keyboard) but draws no rail. Shared by vf-list and vf-text-area; a future `@container scroll-state(scrollable)` query could replace the JS for slotted-content components.
403
404
  - **Inactive-window blanking:** the HIG's non-frontmost window must not display interactive scroll UX, so the controller also finds the nearest `vf-window` up the composed tree (light-DOM ancestor for a slotted scroller, shadow ancestor for `vf-window[scrollbars]`'s own edge rails), watches its reflected `active` attribute, and toggles a presence-only `data-window-inactive` on the scroll element. While present, the recipe empties dither/thumb/arrows on BOTH axes regardless of overflow — the bare channel, arrows included (unlike the idle rail, which keeps them), exactly as System 7 blanked a deactivated window's bars (its List Manager/TextEdit deactivated in-window scrollbars too). No `vf-window` ancestor → the attribute never appears: dialogs have no inactive state and a bare scroll component always draws live. Like the overflow half, this signal could one day go declarative — a custom property cascaded under `vf-window:not([active])` gating the recipe via an `@container style()` query; the rails being ordinary DOM, that migration is a plain selector swap (see the FUTURE note in scroll-state.ts).
@@ -465,7 +466,7 @@ The static caption: "Name:" beside a field, "Mode" over a radio group, a readout
465
466
  #### `vf-paragraph` (`VfParagraph`, vf-paragraph.ts)
466
467
  A paragraph of copy on the kit's body face and grid.
467
468
  - **Attributes/props:** `face: 'display' | 'body'`, `dim: boolean`, `width`/`height: number` (whole system px, via `VfSized` — the measure the copy wraps to, and a box the copy overflows rather than grows; set them on a placed paragraph, which otherwise shrink-wraps to its longest line).
468
- - **Visual:** `display: block`, the **body face** by default, `line-height: var(--vf-paragraph-line-height, var(--vf-line-height, 12px))` — Geneva 9's native strike line, the 16px em centering into it the way the icon plate's does; `face="display"` defaults the box to 16px, Chicago 12's own line (ascent 12 + descent 3 + leading 1), so wrapped chrome copy leaves the native 7 blank rows between a baseline and the caps below it. Selectable (prose, so it re-enables the text selection `vfBase` suppresses). Renders a real `<p>` in the shadow root, so the copy keeps paragraph semantics for AT. **No margin** (§2): paragraph spacing is the page's, in whole pixels like everything else.
469
+ - **Visual:** `display: block`, the **body face** by default, `line-height: var(--vf-paragraph-line-height, var(--vf-line-height, 12px))` — Geneva 9's native strike line, the 16px em centering into it the way the icon plate's does; `face="display"` defaults the box to 16px, Chicago 12's own line (ascent 12 + descent 3 + leading 1), so wrapped chrome copy leaves the native 7 blank rows between a baseline and the caps below it. Selectable (prose, so it re-enables the text selection `vfBase` suppresses), and selected copy inverts to the 1-bit highlight (`--vf-highlight` ground, `--vf-highlight-text` ink), as a field's does: `:host::selection`, since the copy is the host's own light-DOM text, and text in a slotted element (a `<b>`, a link) inherits it (`::slotted(*)::selection` styles nothing). `npm run verify:text` (SELECTION). Renders a real `<p>` in the shadow root, so the copy keeps paragraph semantics for AT. **No margin** (§2): paragraph spacing is the page's, in whole pixels like everything else.
469
470
  - **Slots:** default (the copy). **Parts:** `paragraph`.
470
471
 
471
472
  ### Group F — images
@@ -484,7 +485,7 @@ The Finder icon — pixel art in a reserved cell with its name on a plate below,
484
485
  - **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art redraws as the Finder's open-window ghost — outline held in solid black, interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the slotted art itself, by compositing alone: a silhouette via `source-in`; a 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette shifted one pixel each way (off-canvas composites as transparent, so the raster's own edge erodes too and an interior hole rings itself); the lattice via `createPattern` anchored at the art's top-left, where 32 and 16 being whole multiples of the tile keeps a field of icons in one phase; then the fill composed over the un-eroded silhouette, so the ring the erosion removed reads as the outline. Never `getImageData` — a canvas that drew a cross-origin image is tainted, which forbids *reading* pixels but not drawing or displaying them, so the ghost works for CORS-less sources too — and never `ctx.filter` (Safari's shaky corner; compositing operators are universal). Keeping the interior *opaque white* is what lets a selected open icon invert under the same `filter: invert(1)` with no second treatment. The slot stays in the tree while the ghost paints, hidden via a class gated on a successful derivation — art the pipeline cannot draw (nothing slotted, a failed load, an inline `<svg>`) keeps rendering as itself. The ghost re-derives on slotchange, the art's `load`/`error`, and `open`/`size` changes, keyed by `currentSrc` so a refresh that changed nothing is a string compare; the source's `alt` is carried onto the ghost (`role="img"` + `aria-label`) so a graphic that names its icon keeps doing so while its slot is hidden. The ghost displays on `vf-img`'s terms: natural raster size in system px, `image-rendering: pixelated`, one image pixel per system pixel.
485
486
  - **Behavior:** clicking selects, Shift/⌘ toggles, and a press elsewhere clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. For an icon alone, elsewhere is anywhere outside it. For an icon in a field, only a press on another icon or on a field (`vf-icon-field`, or a `role="listbox"` element of the page's own) clears it; a press on the menu bar, a menu, a dialog, a window's title bar or another window leaves the selection, so a menu command still has its icons to act on and a selection survives an application switch. Either way the press calls off a pending rename and a half-finished tap pair. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). **The instrument never changes what the icon means**, so a finger or a pen opens it too, on a *tap pair* the icon classifies from its own pointer events rather than from `dblclick`: two taps on the icon, the second pressed within `TAP_PAIR_MS` (500) of the first and no further than `TAP_PAIR_SLOP_PX` (24 CSS px) from it, `vf-open` firing on the second release. A platform reserves the double-tap for its zoom and may deliver no `dblclick` for one, and a movable icon's press is `preventDefault()`ed for the drag, which can suppress it as well; the pair depends on neither. A tap is a press released where it landed — one that became a drag, or that the platform cancelled, is not half of a pair — and a pair does not chain. The mouse keeps the platform's `dblclick`, which follows the user's own double-click setting; each press takes exactly one of the two routes, so a platform that also delivers a `dblclick` for a double-tap opens the icon once. The frame sets `touch-action: manipulation`, which takes the double-tap zoom off the icon and leaves panning and pinching to the page. Both are additions in the kit's idiom — System 7 had the mouse alone. Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape (the frame is the handle and sets `touch-action: none` while movable, as the title bars do), plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
486
487
  - **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step past the **drag slop** — `DRAG_SLOP_PX` (4 CSS px) of travel from the press for the mouse, `DRAG_SLOP_COARSE_PX` (10) for a finger or pen (`dragSlop(event)`, `src/motion.ts`). Inside it the press is a click or a tap: the icon stays on its spot, a rename the press armed stays armed, and no drag event fires, so a click never fires a drop and a tap never nudges an icon off its cell. The delta is measured from the press all the same, so the outline begins under the pointer. CSS px rather than system px, because the slop measures the hand and not the art; `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press; in a box that *scrolls* (a `vf-scroll-area`'s plane, which a window body is under `scrollbars`) only the origin holds, since the rails reach anything placed past it and the Finder put an icon below a window's fold and scrolled to it — `#anchorScrolls`, a flat-tree walk from the icon toward its positioning parent through the slots it is assigned to, true when a scroll area is met before the parent — and whose `preventDefault()` writes nothing, the page re-parenting or placing the icon itself; `vf-drag-cancel` `{}` on Escape or a `pointercancel`, nothing written. `left`/`top` in the detail are the proposal *unclamped* (the origin plus the lattice-snapped delta); only the default action clamps, so a page that cancels gets the raw gesture. The drop is dispatched with the outline gone, the drag state cleared and the pointer capture released, so re-parenting inside the handler is safe (Chromium drops the `click` of a press whose node was re-inserted at `pointerup`; the icon listens for no click and times its double-click on presses).
487
- - **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the silhouette clipped to the cell as the cell clips the art, the one-pixel erosion by eight `destination-in` draws on a layer the outline's size (the layer is transparent around the art, so its edge erodes as the off-canvas edge did) — the 3×3 box, the region inset `FrameRgn` painted a frame from, rather than the ghost's four-neighbor plus: a 45° edge comes out two pixels wide with both parities of the checker, where a one-pixel diagonal is all one parity and the pen would keep all of it or none, and over the dither "none" is the dither's own ink diagonal — the plate's rectangle filled solid into the same silhouette before the erosion (the Finder's drag region was the mask united with the name's rect, framed once — where the two abut, the outline wraps around both and no edge runs between them; apart, each is ringed on its own), the ring the erosion removes via `destination-out`; compositing only, so cross-origin art derives too. Art the pipeline cannot draw falls back to the cell's rectangle — never a blank. One canvas the size of the frame widened to the plate — a name wider than a declared `width` overhangs the frame on both sides — at one image px per system px; the ring is derived once per drag, and each step re-dots it against a 2×2 checker (`destination-in`) at the pen's phase for where it lands on the *screen* (`penPhase`): ink where `x + y` is odd in screen coordinates, the diagonal the desktop dither (`gray-50`, ink at even) leaves white, so the dots fall between the dither's ink wherever the outline lands, as QuickDraw's screen-anchored pattern pen did — over a white body a dotted black line, over the dither a black line, each dot flipping a white pixel with the dither's own ink filling in between. Painted with the cursor's XOR recipe, `filter: invert(1)` under `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`, sized and placed as live `sysLength()`s so a scale change mid-gesture cannot smear it; its origin is that box's whole-system-px offset from the surface plus the delta. **The surface is the desktop's, not the top layer:** the icon dispatches `vf-drag-surface-request` (bubbles, `composed: false`, `detail.surface` null) on the first step, and the nearest `vf-desktop` on its light-DOM path fills it with its drag surface (§5 vf-desktop); a non-composed event from a slotted icon still travels through the slots it is assigned to, so the handshake works from inside a window body. Without a desktop the canvas is appended to the icon's own `.frame` (the vf-snap box, `position: relative`), translated by the delta alone and clipped by whatever clips the icon — the honest fallback, with the same gesture and events. Not `position: fixed` inside the icon: a `vf-window` is a stacking context, so a fixed outline inside it would paint at the window's z, under every other window (KNOWN-BUGS.md #1 in another costume). One drag, no opt-in: the Finder never moved the icon itself, the drop lands where it always did, and the kit ships no taste knobs. Escape is heard by a document `keydown` listener scoped to the gesture (`DocumentListenersController`, capture phase, the key stopped there so an enclosing `vf-dialog` never reads it as a dismissal) rather than the icon's own handler, because a `movable`-only icon never takes focus — an addition in the kit's idiom (§1), not a claim about the Finder. `DragController` gained the two trailing parameters this needs (`onDrag(x, y, event)`, `onDragEnd(event, cancelled)`) and a `cancel()`; `vf-window` and `vf-dialog` ignore them. A selected icon re-parented at the drop keeps its outside-press listener: it re-attaches on connect, since the controller detaches on disconnect and `updated()` does not re-run for an unchanged property. `npm run verify:icon-drag`.
488
+ - **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the silhouette clipped to the cell as the cell clips the art, the one-pixel erosion by eight `destination-in` draws on a layer the outline's size (the layer is transparent around the art, so its edge erodes as the off-canvas edge did) — the 3×3 box, the region inset `FrameRgn` painted a frame from, rather than the ghost's four-neighbor plus: a 45° edge comes out two pixels wide with both parities of the checker, where a one-pixel diagonal is all one parity and the pen would keep all of it or none, and over the dither "none" is the dither's own ink diagonal — the plate's rectangle filled solid into the same silhouette before the erosion (the Finder's drag region was the mask united with the name's rect, framed once — where the two abut, the outline wraps around both and no edge runs between them; apart, each is ringed on its own), the ring the erosion removes via `destination-out`; compositing only, so cross-origin art derives too. Art the pipeline cannot draw falls back to the cell's rectangle — never a blank. One canvas the size of the frame widened to the plate — a name wider than a declared `width` overhangs the frame on both sides — at one image px per system px; the ring is derived once per drag, and each step re-dots it against a 2×2 checker (`destination-in`) at the pen's phase for where it lands on the *screen* (`penPhase`): ink where `x + y` is odd in screen coordinates, the diagonal the desktop dither (`gray-50`, ink at even) leaves white, so the dots fall between the dither's ink wherever the outline lands, as QuickDraw's screen-anchored pattern pen did — over a white body a dotted black line, over the dither a black line, each dot flipping a white pixel with the dither's own ink filling in between. Painted with the cursor's XOR recipe, `filter: invert(1)` under `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`, sized and placed as live `sysLength()`s so a scale change mid-gesture cannot smear it; its origin is that box's whole-system-px offset from the surface plus the delta. **The surface is the desktop's, not the top layer:** the icon dispatches `vf-drag-surface-request` (bubbles, `composed: false`, `detail.surface` null) on the first step, and the nearest `vf-desktop` on its light-DOM path fills it with its drag surface (§5 vf-desktop); a non-composed event from a slotted icon still travels through the slots it is assigned to, so the handshake works from inside a window body. Without a desktop the canvas is appended to the icon's own `.frame` (the vf-snap box, `position: relative`), translated by the delta alone and clipped by whatever clips the icon — the honest fallback, with the same gesture and events. Not `position: fixed` inside the icon: a `vf-window` is a stacking context, so a fixed outline inside it would paint at the window's z, under every other window. One drag, no opt-in: the Finder never moved the icon itself, the drop lands where it always did, and the kit ships no taste knobs. Escape is heard by a document `keydown` listener scoped to the gesture (`DocumentListenersController`, capture phase, the key stopped there so an enclosing `vf-dialog` never reads it as a dismissal) rather than the icon's own handler, because a `movable`-only icon never takes focus — an addition in the kit's idiom (§1), not a claim about the Finder. `DragController` gained the two trailing parameters this needs (`onDrag(x, y, event)`, `onDragEnd(event, cancelled)`) and a `cancel()`; `vf-window` and `vf-dialog` ignore them. A selected icon re-parented at the drop keeps its outside-press listener: it re-attaches on connect, since the controller detaches on disconnect and `updated()` does not re-run for an unchanged property. `npm run verify:icon-drag`.
488
489
  - **The selection travels.** A drag that begins on a *selected* icon carries every other selected, movable icon of its field (`closest('vf-icon-field, [role="listbox"]')`, document order) — the Finder outlined the whole selection under a drag on any member. Each follower is joined through `vf-icon.followDrag(surface)` (internal to the group drag): it seeds its own origin, builds its own outline on the surface the leader's handshake found (or in its own frame), and hands back a handle `{ icon, origin, size, move(dx, dy), drop(dx, dy), end() }`. The leader moves every outline by its lattice delta on each step; every event's detail carries the set as `icons`, the leader first (`VfIconDragDetail`). The drop's default action clamps the delta **once for the whole group** — per axis, the tightest of every member's room to its edges, so the arrangement is kept and the group stops when its outermost icon meets the container (a group no edge can hold is held off the near edge only, the single-icon rule; `PlacementController.bounds` is the box, measured at the press) — then writes each member's origin plus that delta through its own `moveTo`. A cancel, or a cancelled drop, takes every outline down and writes nothing for anyone. **The press keeps the selection:** an icon's outside-press listener leaves the selection alone when a plain press lands on an icon already selected in the same field (document capture runs before the pressed icon's own handler, so `selected` reads as the press found it); a click on such an icon, released with no drag, collapses the selection to it on the `pointerup` — so a click still single-selects, and the same press could have dragged them all. A press on an unselected icon makes it the selection first, so it drags alone; a Shift press that deselects the pressed icon drags it alone too; a selected icon in another field, or one that cannot move, stays. `npm run verify:icon-drag` (GROUP).
489
490
  - **The page drags it too** (`moveTo`, `dragTo`; `vf-icon-field.dragIcons` below). The Finder's Clean Up walked a container's icons one at a time, each icon's outline travelling from where it sat to its cell and the icon landing when the outline arrived — the drag's vocabulary, with the Finder for a hand. `dragTo(left, top)` → `Promise<boolean>`: the landing is resolved first (`PlacementController.resolve` — the pair `moveTo` would write, clamped whole and snapped, without writing it — so the outline goes where the icon will land, not where a pair past the edge pointed), the outline built and placed as a drag's is (`#buildOutline`, on the desktop's surface through the handshake or in the frame), then stepped from the origin to the landing by `runOutlineTravel` (`src/motion.ts`): whole steps of `WALK_STEP_PX` (16) every `WALK_STEP_MS` (25), each re-dotted at the screen's phase through `#placeOutlineAt`, the last step exactly the landing, and one interval later the outline down and the icon written through `PlacementController.moveTo` — one `vf-placement-change`, no drag event (the page is the mover and already knows). Constant speed rather than a fixed frame count: most of a Clean Up's moves are short, and a fixed count would crawl a one-pixel correction across as many frames as a trip across the screen. A timer chain, not `requestAnimationFrame`, so a 120 Hz display does not double the speed. Resolves to whether the outline travelled — false when the icon landed at once: already at its landing, `prefers-reduced-motion` (the blink's posture), nothing to draw with, mid-rename or under the hand's own press, or not in the DOM (nothing written then). Not gated on `movable`, which says the hand may drag; the page may always place its icon. **Finished, never cancelled:** a document `pointerdown` or Escape (capture, scoped to the travel, the key stopped so a dialog never dismisses) lands the icon at once — the user wants the machine back, and the move was the page's; so does a second `dragTo`, a `moveTo`, or disconnecting (outline down, nothing written). `moveTo(left, top)` is the landing alone — the drop's write: the container measured now (`seed()`), clamped whole, snapped, announced once; a `left`/`top` write stays the authored, unclamped pair (the `selected` / `setSelected()` line). The three cadence numbers are set by eye against the Finder and asserted nowhere. `npm run verify:icon-drag` (WALK).
490
491
  - **An icon alone is a picture; an icon in a field is an option.** `option` is invalid without a `listbox` that owns it — written unconditionally the browser drops the role *and* `aria-selected`, which is how a `selectable` icon reached AT as a bare generic in every configuration the kit shipped. So the role follows the container: `role="option"` + `aria-selected` when a `vf-icon-field` or a `[role="listbox"]` ancestor claims it (`closest('vf-icon-field, [role="listbox"]')` — the field's role lives in internals and never lands as an attribute), otherwise `role="img"` named from `label` (else the art's `alt`), since `img` is not a name-from-content role. Not `button` — that promises Enter/Space activate, while here Return renames and the open route is ⌘O / ⌘↓. Re-derived on every connect, so re-parenting between the two contexts re-grades the icon. **The container for a field of icons is `vf-icon-field`** (below); a consumer's own element carrying `role="listbox"` still owns an icon the same way. `vf-desktop` cannot be it, since it also holds windows and a menu bar and a non-`option` child of a listbox is invalid the same way. Divergence from APG, recorded: its listbox options share one roving tab stop, the kit's stay one stop each.