vintage-frames 0.1.0

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 (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/custom-elements.json +19807 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  5. package/dist/chrome.d.ts +73 -0
  6. package/dist/chrome.js +94 -0
  7. package/dist/components/vf-button-group.d.ts +50 -0
  8. package/dist/components/vf-button-group.js +68 -0
  9. package/dist/components/vf-button.d.ts +169 -0
  10. package/dist/components/vf-button.js +234 -0
  11. package/dist/components/vf-checkbox.d.ts +60 -0
  12. package/dist/components/vf-checkbox.js +135 -0
  13. package/dist/components/vf-container.d.ts +95 -0
  14. package/dist/components/vf-container.js +86 -0
  15. package/dist/components/vf-desktop.d.ts +243 -0
  16. package/dist/components/vf-desktop.js +310 -0
  17. package/dist/components/vf-dialog.d.ts +105 -0
  18. package/dist/components/vf-dialog.js +280 -0
  19. package/dist/components/vf-fieldset.d.ts +34 -0
  20. package/dist/components/vf-fieldset.js +76 -0
  21. package/dist/components/vf-grid.d.ts +124 -0
  22. package/dist/components/vf-grid.js +133 -0
  23. package/dist/components/vf-icon.d.ts +356 -0
  24. package/dist/components/vf-icon.js +507 -0
  25. package/dist/components/vf-img.d.ts +63 -0
  26. package/dist/components/vf-img.js +65 -0
  27. package/dist/components/vf-label.d.ts +117 -0
  28. package/dist/components/vf-label.js +151 -0
  29. package/dist/components/vf-list-item.d.ts +48 -0
  30. package/dist/components/vf-list-item.js +98 -0
  31. package/dist/components/vf-list.d.ts +77 -0
  32. package/dist/components/vf-list.js +256 -0
  33. package/dist/components/vf-menu-bar.d.ts +64 -0
  34. package/dist/components/vf-menu-bar.js +236 -0
  35. package/dist/components/vf-menu-item.d.ts +91 -0
  36. package/dist/components/vf-menu-item.js +252 -0
  37. package/dist/components/vf-menu.d.ts +95 -0
  38. package/dist/components/vf-menu.js +346 -0
  39. package/dist/components/vf-number-field.d.ts +60 -0
  40. package/dist/components/vf-number-field.js +248 -0
  41. package/dist/components/vf-option.d.ts +49 -0
  42. package/dist/components/vf-option.js +119 -0
  43. package/dist/components/vf-paragraph.d.ts +73 -0
  44. package/dist/components/vf-paragraph.js +66 -0
  45. package/dist/components/vf-progress-bar.d.ts +69 -0
  46. package/dist/components/vf-progress-bar.js +272 -0
  47. package/dist/components/vf-radio-group.d.ts +109 -0
  48. package/dist/components/vf-radio-group.js +105 -0
  49. package/dist/components/vf-radio.d.ts +59 -0
  50. package/dist/components/vf-radio.js +135 -0
  51. package/dist/components/vf-scroll-area.d.ts +75 -0
  52. package/dist/components/vf-scroll-area.js +124 -0
  53. package/dist/components/vf-select.d.ts +334 -0
  54. package/dist/components/vf-select.js +658 -0
  55. package/dist/components/vf-separator.d.ts +38 -0
  56. package/dist/components/vf-separator.js +55 -0
  57. package/dist/components/vf-slider.d.ts +80 -0
  58. package/dist/components/vf-slider.js +255 -0
  59. package/dist/components/vf-stack.d.ts +188 -0
  60. package/dist/components/vf-stack.js +168 -0
  61. package/dist/components/vf-swatch.d.ts +98 -0
  62. package/dist/components/vf-swatch.js +201 -0
  63. package/dist/components/vf-text-area.d.ts +77 -0
  64. package/dist/components/vf-text-area.js +121 -0
  65. package/dist/components/vf-text-field.d.ts +53 -0
  66. package/dist/components/vf-text-field.js +79 -0
  67. package/dist/components/vf-window.d.ts +157 -0
  68. package/dist/components/vf-window.js +377 -0
  69. package/dist/cursor-art.d.ts +37 -0
  70. package/dist/cursor-art.js +41 -0
  71. package/dist/cursor.d.ts +49 -0
  72. package/dist/cursor.js +171 -0
  73. package/dist/define.d.ts +45 -0
  74. package/dist/define.js +15 -0
  75. package/dist/document-listeners.d.ts +62 -0
  76. package/dist/document-listeners.js +29 -0
  77. package/dist/drag.d.ts +49 -0
  78. package/dist/drag.js +30 -0
  79. package/dist/events.d.ts +79 -0
  80. package/dist/events.js +25 -0
  81. package/dist/focus-modality.d.ts +94 -0
  82. package/dist/focus-modality.js +56 -0
  83. package/dist/form-control.d.ts +247 -0
  84. package/dist/form-control.js +127 -0
  85. package/dist/glyphs.d.ts +117 -0
  86. package/dist/glyphs.js +100 -0
  87. package/dist/grid-snap.d.ts +73 -0
  88. package/dist/grid-snap.js +135 -0
  89. package/dist/index.d.ts +145 -0
  90. package/dist/index.js +71 -0
  91. package/dist/menu-press.d.ts +60 -0
  92. package/dist/menu-press.js +97 -0
  93. package/dist/modal-dialog.d.ts +148 -0
  94. package/dist/modal-dialog.js +155 -0
  95. package/dist/motion.d.ts +72 -0
  96. package/dist/motion.js +22 -0
  97. package/dist/number.d.ts +24 -0
  98. package/dist/number.js +12 -0
  99. package/dist/open-art.d.ts +40 -0
  100. package/dist/open-art.js +35 -0
  101. package/dist/pixel-frame.d.ts +102 -0
  102. package/dist/pixel-frame.js +77 -0
  103. package/dist/popup-overflow.d.ts +157 -0
  104. package/dist/popup-overflow.js +45 -0
  105. package/dist/position.d.ts +187 -0
  106. package/dist/position.js +74 -0
  107. package/dist/scale.d.ts +259 -0
  108. package/dist/scale.js +133 -0
  109. package/dist/scroll-rail.d.ts +112 -0
  110. package/dist/scroll-rail.js +194 -0
  111. package/dist/scroll-state.d.ts +113 -0
  112. package/dist/scroll-state.js +55 -0
  113. package/dist/size.d.ts +47 -0
  114. package/dist/size.js +30 -0
  115. package/dist/styles/base.d.ts +38 -0
  116. package/dist/styles/body-font.d.ts +7 -0
  117. package/dist/styles/body-font.js +9 -0
  118. package/dist/styles/display-font.d.ts +7 -0
  119. package/dist/styles/display-font.js +9 -0
  120. package/dist/styles/recipes/body-face.d.ts +14 -0
  121. package/dist/styles/recipes/body-face.js +6 -0
  122. package/dist/styles/recipes/display-face.d.ts +23 -0
  123. package/dist/styles/recipes/display-face.js +10 -0
  124. package/dist/styles/recipes/field.d.ts +20 -0
  125. package/dist/styles/recipes/field.js +68 -0
  126. package/dist/styles/recipes/focus.d.ts +76 -0
  127. package/dist/styles/recipes/focus.js +39 -0
  128. package/dist/styles/recipes/host.d.ts +5 -0
  129. package/dist/styles/recipes/host.js +62 -0
  130. package/dist/styles/recipes/pattern.d.ts +80 -0
  131. package/dist/styles/recipes/pattern.js +82 -0
  132. package/dist/styles/recipes/scroll-rail.d.ts +69 -0
  133. package/dist/styles/recipes/scroll-rail.js +222 -0
  134. package/dist/styles/recipes/shadow.d.ts +13 -0
  135. package/dist/styles/recipes/shadow.js +5 -0
  136. package/dist/styles/recipes/static-text.d.ts +24 -0
  137. package/dist/styles/recipes/static-text.js +17 -0
  138. package/dist/styles/recipes/surface.d.ts +28 -0
  139. package/dist/styles/recipes/surface.js +27 -0
  140. package/dist/styles/recipes/tile.d.ts +122 -0
  141. package/dist/styles/recipes/tile.js +40 -0
  142. package/dist/styles/recipes/title-bar.d.ts +49 -0
  143. package/dist/styles/recipes/title-bar.js +134 -0
  144. package/dist/styles/recipes/toggle.d.ts +10 -0
  145. package/dist/styles/recipes/toggle.js +20 -0
  146. package/dist/styles/register-embedded-font.d.ts +64 -0
  147. package/dist/styles/register-embedded-font.js +30 -0
  148. package/dist/text-control.d.ts +131 -0
  149. package/dist/text-control.js +83 -0
  150. package/dist/tile-grid.d.ts +111 -0
  151. package/dist/tile-grid.js +53 -0
  152. package/dist/toggle-control.d.ts +60 -0
  153. package/dist/toggle-control.js +41 -0
  154. package/dist/track-width.d.ts +42 -0
  155. package/dist/track-width.js +30 -0
  156. package/dist/type-ahead.d.ts +35 -0
  157. package/dist/type-ahead.js +20 -0
  158. package/dist/zoom.d.ts +204 -0
  159. package/dist/zoom.js +105 -0
  160. package/docs/SPEC.md +2471 -0
  161. package/editor/vscode.html-custom-data.json +1320 -0
  162. package/editor/web-types.json +3237 -0
  163. package/package.json +118 -0
package/docs/SPEC.md ADDED
@@ -0,0 +1,2471 @@
1
+ # Vintage Frames — System 7 Component Specification
2
+
3
+ Lit 3 web components that faithfully emulate the look and feel of classic Mac OS
4
+ System 7 (1991–1997). This document is the single source of truth for visual
5
+ design and public APIs. Every component MUST follow it.
6
+
7
+ > **Glyph sprites:** the individual 1-bit control glyphs (checkbox ✕, radio
8
+ > ring/dot, menu ✓, popup ▼, scroll arrows) are each reconstructed
9
+ > pixel-for-pixel as an inline-SVG fill path in `src/glyphs.ts` (shared,
10
+ > `currentColor`-themeable, zero raster assets) and consumed by the components
11
+ > below — the authoritative source for these marks. A glyph is *geometry*,
12
+ > so redrawing it loses nothing. A **picture** cannot be redrawn without
13
+ > loss, and the kit ships no raster art at all: an icon is the
14
+ > consumer's asset, slotted through `vf-img` (the demo's 32×32 alert icon is
15
+ > `demo/icons/alert.png`).
16
+
17
+ ## 1. Design principles
18
+
19
+ The System 7 look, distilled:
20
+
21
+ - **1px solid black borders** on everything — even when disabled. Dimming a
22
+ control greys its label/text only; the border, box and glyph stay solid black
23
+ (System 7 dims the label, not the control).
24
+ - **Hard offset shadows** — `2px 2px 0 0 #000` for windows/menus/panels,
25
+ `1px 1px 0 0 #000` for small popup controls. Never blurred, never rgba.
26
+ - **Racing-stripe title bars** — 6 horizontal 1px black pinstripes on the white
27
+ title bar; the title text and window widgets sit on solid white patches that
28
+ interrupt the stripes.
29
+ - **Chicago-style type** — bold, dark, tight. One size for almost everything.
30
+ - **1-bit monochrome palette** — black and white only, plus two grays: light
31
+ gray (`#C0C0C0`, the kit's dim tone) for dimmed/disabled chrome, and mid gray
32
+ (`#808080`) as the base tone under the desktop's 50% dither.
33
+ Scroll troughs use a looser 25% black-on-white dot dither. Surfaces are flat
34
+ solid white — no bevels.
35
+ - **No gradients, no border-radius, no CSS transitions** — interactions are
36
+ instant. The single sanctioned animation is the classic menu-item "blink" on
37
+ selection and the indeterminate progress stripes — both suppressed under
38
+ `prefers-reduced-motion: reduce` (the blink selects immediately). Even the
39
+ button's rounded
40
+ corners are not `border-radius` arcs: they are stepped `clip-path`
41
+ silhouettes traced pixel-for-pixel from the reference sheet
42
+ (`src/pixel-frame.ts`), so the 1-bit staircase renders with no antialiasing.
43
+ - **Selection inverts** — selected/active states are white-on-black inversion,
44
+ not tinted highlights.
45
+
46
+ Modern requirements that we deliberately keep (accessibility over purity):
47
+
48
+ - `:focus-visible` gets `outline: var(--vf-focus-outline, 1px dotted currentColor); outline-offset: 2px;`
49
+ — but only where the control has no face to carry the mark instead. The kit
50
+ prefers a **1px dashed rule** (`vfFocusUnderline`, §4) and reaches for the
51
+ ring last: `vf-button`, `vf-checkbox`, `vf-radio`, the three editable fields
52
+ and `vf-menu` draw it under the label, the box, the circle, the well and the
53
+ bar title; `vf-select`, `vf-swatch` and `vf-slider` under the whole box —
54
+ clear of the hard shadow for the first two, the rail's full width for the
55
+ slider. That leaves the ring to the window widgets, `vf-list-item` and
56
+ `vf-scroll-area`. System 7 had no keyboard-focus indicator to copy — these
57
+ are modern affordances the kit **adds**, drawn on the 1-bit grid so they
58
+ read as native.
59
+ - Full ARIA roles + keyboard support per component.
60
+ - Form-associated custom elements where noted (`static formAssociated = true`
61
+ + `ElementInternals`).
62
+ - **Forced-colors mode (Windows High Contrast) is honored.** The kit
63
+ re-declares its two-color palette in the user's own system colors:
64
+ `vfBase` remaps the tokens to system colors under
65
+ `@media (forced-colors: active)` (`--vf-black: CanvasText`,
66
+ `--vf-white: Canvas`, the `Highlight` pair, `GrayText`), which survive the
67
+ forced override and carry every token-routed paint site — borders, faces,
68
+ glyphs, silhouettes, inversions — at once. The three channels forcing
69
+ deletes get per-site care: the focus-underline and stripe *gradients* are
70
+ exempted with `forced-color-adjust: none` (still `currentColor`, so still
71
+ the user's ink); the literal-black url() *tiles* that carry meaning
72
+ (windoid dots, barber stripes, the pressed sunburst) repaint as masks over
73
+ the ink token, while the decorative desktop dither goes flat Canvas; and the
74
+ two press-feedback *box-shadows* are replaced (checkbox border thickening)
75
+ or exempted (the widget patch ring). Inverted rows are also exempted so the
76
+ mode's text backplate can't land a Canvas slab on the highlight bar. The
77
+ swatch fill is exempted as *content* — the color is what the control
78
+ exists to show. The scroll rails are ordinary DOM (§4 `vfScrollRail`):
79
+ their arrows
80
+ are `currentColor` inline SVG and follow the palette by themselves, and the
81
+ trough repaints as the ink token masked by its own dither art (the windoid
82
+ idiom). `npm run verify:forced-colors` asserts the rendered pixels on dark
83
+ and light forced themes.
84
+
85
+ ## 2. Code conventions (mandatory)
86
+
87
+ - Tag names: `vf-*`. Class names: PascalCase (`vf-radio-group` → `VfRadioGroup`).
88
+ - One component per file: `src/components/vf-<name>.ts`.
89
+ - Lit 3 + TypeScript strict, experimental decorators: `@customElement`,
90
+ `@property`, `@state`, `@query`, `@queryAssignedElements`.
91
+ - Relative imports use the `.js` extension (e.g. `../styles/base.js`).
92
+ - Shared styles: `import { vfBase, vfStripes, vfPanel } from '../styles/base.js'`
93
+ and compose: `static override styles = [vfBase, css\`...\`]`.
94
+ - Every component file ends with:
95
+ ```ts
96
+ declare global {
97
+ interface HTMLElementTagNameMap { 'vf-button': VfButton }
98
+ }
99
+ ```
100
+ - All colors/metrics via `var(--vf-*, <default>)` **with the default inlined**
101
+ so components work with zero global CSS. Never hardcode a color without a var.
102
+ - Boolean public props reflect: `@property({ type: Boolean, reflect: true })`.
103
+ - Events: `CustomEvent` with `{ bubbles: true, composed: true }` and an object
104
+ `detail`. Names are listed per component (`vf-change`, `vf-close`, …).
105
+ **Internal coordination events are the exception**: the menu handshakes
106
+ (`vf-menu-toggle-request`, `vf-menu-hover`, `vf-menu-close-request`) and the
107
+ list's `vf-list-item-disabled-change` pass `composed: false` — parent and
108
+ child share one light tree, and a private, cancelable protocol must not leak
109
+ out of a consumer's shadow boundary into their delegated listeners.
110
+ - **Form controls also fire the native `input`/`change` pair** (`emitNative`,
111
+ src/events.ts), alongside `vf-input`/`vf-change` — a form-associated element
112
+ that contributes to `FormData` but never fires `change` is half a native
113
+ control: form-level delegation hears nothing and framework bindings (React
114
+ `onChange`, Vue `v-model`) have nothing to bind to. Native semantics hold:
115
+ fired from **user interaction only** (a programmatic value write fires
116
+ nothing), with native flags (`input` composed, `change` not). The fields
117
+ re-dispatch only `change` from the host — their inner control's own `input`
118
+ is composed and retargets across the shadow boundary by itself, so a host
119
+ re-dispatch would double-fire every keystroke.
120
+ - Disabled pattern: reflected `disabled` attr; the **label/text** dims to
121
+ `--vf-disabled` gray while borders, boxes and glyphs stay black; interaction
122
+ handlers early-return; set `aria-disabled`/`disabled` on internals.
123
+ - **Host ARIA is written through `ElementInternals`, never `setAttribute`.**
124
+ An internals value is a *default*: a consumer's own `role`/`aria-*` on the
125
+ tag outranks it, which is the platform's own precedence. A host
126
+ `setAttribute` has the opposite precedence — the component wins and the
127
+ consumer is silently overwritten — so the two are not interchangeable, and
128
+ the kit uses internals everywhere (`role`, `ariaLabel`, `ariaSelected`,
129
+ `ariaChecked`, `ariaDisabled`, `ariaValueNow`, `ariaKeyShortcuts`, …). This
130
+ is also why no component needs a first-connect "do I own the role?" latch:
131
+ an internals default is never on the host to be misread as consumer-supplied
132
+ on a later connect. **Consequence:** these values do not
133
+ reflect to attributes, so `getAttribute('role')` reads `null` on a
134
+ `vf-list-item` whose role computes to `option` — read the accessibility tree
135
+ (or `el.internals`-free equivalents like `matches(':state(…)')` for state).
136
+ Shadow-internal nodes still take ordinary template attributes.
137
+ - Form-associated controls (`VfFormControl`) also implement
138
+ `formStateRestoreCallback` — the stored state is the last submitted string,
139
+ mapped back onto each control's own value semantics (`applyFormState`;
140
+ checkbox restores the flag, slider parses the number) — and expose
141
+ `:state(form-disabled)` while an ancestor `<fieldset disabled>` disables
142
+ them, the one disabled state consumer CSS can't otherwise see.
143
+ - **The name/description bridge** (`VfShadowRoleControl`, src/form-control.ts).
144
+ On the controls whose role lives on a
145
+ shadow-internal node (the three fields, `vf-select`, `vf-swatch`,
146
+ `vf-button`), a
147
+ host-level `aria-labelledby`, `aria-label` or associated `<label for>`
148
+ resolves — in html-aam precedence — to the inner focusable element's
149
+ `aria-label` whenever the `label` property is empty (`hostLabel`).
150
+ Those six extend `VfShadowRoleControl`; the host-role controls
151
+ (`vf-checkbox`, `vf-radio-group`, `vf-slider`) extend the plain
152
+ `VfFormControl` and so never *carry* the bridge's API at all — an inherited
153
+ `description` that renders nothing is the advertised-but-inert shape this
154
+ split removes, and `verify:manifest` enforces it (a tag whose manifest
155
+ lists `description` must call `renderDescription()`). `vf-button` takes
156
+ the ARIA half alone
157
+ (`hostAriaLabel`): a `<button>` is not a labelable element, so no caption
158
+ names a native one and none names this one either — its slotted content is
159
+ the name a bridge doesn't override. A `description` property (or, when it's empty, a
160
+ host-level `aria-describedby`) renders as a hidden span in the control's own
161
+ shadow root with the inner control's `aria-describedby` pointing at it — the
162
+ shadow-internal IDREF idiom `vf-dialog`'s title patch uses — and a failing constraint's
163
+ `validationMessage` joins the same node. Referenced text is flattened at
164
+ render time, so an edit to a referenced element's *text* lands on the next
165
+ render rather than instantly — the one divergence from native. Controls
166
+ whose role sits on the host (the toggles, slider, radio group, bars) need
167
+ none of this: the platform reads their host attributes directly.
168
+ - **Constraint validation** (`VfFormControl`): a reflected `required` fails
169
+ validation with `valueMissing` while the control is empty by its own
170
+ definition (fields/select `value === ''`, checkbox unchecked, radio group
171
+ unselected — each with its native counterpart's message), through one
172
+ `syncValidity()` funnel run before each render. The native surface is
173
+ complete — `checkValidity`/`reportValidity`/`validity`/`validationMessage`/
174
+ `willValidate`/`setCustomValidity` — `:invalid` matches on the host,
175
+ `form.reportValidity()` blocks, and `disabled`/`readonly` bar validation
176
+ per HTML's own rules. One divergence the platform imposes: `willValidate`
177
+ is `true` on a `vf-button type="button"`, where a native button is barred
178
+ from constraint validation. A form-associated custom element cannot opt
179
+ out of the candidate set, so this is a platform limit. AT wiring is
180
+ `aria-required`/`aria-invalid` on the
181
+ inner control (plus internals mirrors for host-role controls) — never a
182
+ forwarded native `required`, which would put UA `:user-invalid` styling on
183
+ the artwork. Enter's implicit submission routes through the browser's
184
+ validation, so it cannot submit past a failing constraint.
185
+ - Components must render nothing surprising outside their box: no margins on
186
+ `:host` by default.
187
+ - **Explicit placement** (`VfPositioned`, src/position.ts): every free-standing
188
+ component takes `top`/`left` in whole system px — set either and the host is
189
+ absolutely positioned within its parent (`position: absolute` plus both
190
+ offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset
191
+ coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed).
192
+ Set neither and the element renders in flow; removing both unwinds every
193
+ inline declaration. The DITL model: a dialog can be laid out by positioning
194
+ its items just as validly as by stacking them. Excluded: the owned
195
+ rows/options of a managing container (`vf-option`, `vf-menu-item`,
196
+ `vf-list-item`, `vf-menu`). Containers are deliberate anchors — the desktop
197
+ raster, a window's *content region* (the frame's inner edge below the title
198
+ bar; the 12px body inset governs flow content only), a dialog's content area,
199
+ a stack's box, a fieldset's border interior, a scroll area's scrolled plane.
200
+ The style writing rides a ReactiveController (`hostUpdated`), not an
201
+ `updated()` override — component subclasses routinely skip `super.updated()`
202
+ — and re-applies only when the property values changed, so an unrelated
203
+ update never re-asserts a coordinate. `npm run verify:position`.
204
+ - `vf-dialog` takes the pair in **viewport** coordinates: `showModal()` puts
205
+ the box in the top layer, whose containing block is the viewport rather
206
+ than the nearest positioned ancestor. Unset means centered — recomputed on
207
+ open and on every box/viewport/scale change, so a modal whose content
208
+ upgrades after opening is never stranded; `position` is left to the UA and
209
+ only the four inset/margin declarations are written.
210
+ - **Gestures write through the same properties** (`PlacementController`):
211
+ a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow
212
+ nudge state `top`/`left`, and `vf-window`'s grow box states
213
+ `width`/`height` (`VfSized`) — all in whole system px, so a moved element
214
+ is placed the way an authored one is and holds its spot through a zoom.
215
+ Writing resolved CSS px instead was the bug: `--vf-scale` moved under the
216
+ constant and every zoom step re-read it as a different number of system px
217
+ (by the ratio the scale itself moved — visible even at zoom levels where
218
+ the target does not change, and nothing else on the page does). Values are
219
+ snapped to `snapSys` at gesture time and never re-snapped afterwards:
220
+ re-rounding onto each new lattice compounds (62 → 63 → 64), and whole
221
+ system px is whole device px at every rung regardless.
222
+ `npm run verify:zoom` group (e).
223
+ - **The movable contract.** A host that moves under a gesture states its own
224
+ rectangle, and its positioning parent is a box with a size. Both halves are
225
+ the consumer's — a component can supply neither for itself — and both used
226
+ to fail quietly, so each is a one-time `console.warn`
227
+ (`warnMovableContract`, latched per element like `vf-window`'s size
228
+ warning). `npm run verify:position` group CONTRACT.
229
+ - `vf-window[movable]` states `top`/`left` **and** `width`/`height`; a WIND
230
+ resource carried the whole rect. `vf-icon[movable]` states `top`/`left`
231
+ only — it is not `VfSized`, its height is its content's, and `width` is
232
+ the cell pitch. `vf-dialog` is exempt: `showModal()` anchors it to the
233
+ viewport, and unset means centered.
234
+ - What the first half actually requires is being **out of flow** when the
235
+ gesture starts; `top`/`left` are the kit's way and the only one that
236
+ scales, but a stylesheet's own `position: absolute` satisfies it and
237
+ `seed()` reads that case from the computed offsets (the showcase places
238
+ its desktop icons this way — `demo.css`). A host still in flow has to be
239
+ taken out of it by its first move, which reflows everything after it and
240
+ collapses an auto-height parent to whatever is left. **The clamp therefore
241
+ runs against the box measured once by `seed()`, not a fresh one per
242
+ move**: re-reading it would hold the rest of the gesture inside a box the
243
+ host never sat in, walking it to the parent's origin while the user drags
244
+ the other way.
245
+ - Both faults are checked when a **gesture starts**, not on update: that is
246
+ when the contract becomes observable and when layout is settled, and an
247
+ `updated()` check would race a stylesheet that positions the host and
248
+ latch a warning that was never true.
249
+ - A parent with no box at all falls back to the viewport, the same as no
250
+ parent — clamping into nothing would leave the host unable to move, and
251
+ with no positioned ancestor the initial containing block and the viewport
252
+ agree anyway. The warning explains the requirement; the fallback keeps
253
+ the gesture usable meanwhile.
254
+ - Do NOT run repo-wide `tsc` while building an individual component group —
255
+ sibling files may not exist yet. A later phase compiles everything.
256
+
257
+ ## 3. Design tokens
258
+
259
+ Use with inline fallback: `var(--vf-white, #ffffff)`. Consumers override at
260
+ `:root` (or any narrower scope) in their own CSS — the kit ships no stylesheet.
261
+ A page whose *own* rules read these tokens should also re-declare the palette
262
+ as system colors under `@media (forced-colors: active)` (see §1): the
263
+ components carry that remap themselves in `vfBase`, but a component cannot do
264
+ it for the page.
265
+
266
+ Every length in this doc is a **system pixel** value; components multiply it by
267
+ `--vf-scale` in `calc()` (see the note after the table).
268
+
269
+ | Token | Default | Used for |
270
+ | --- | --- | --- |
271
+ | `--vf-scale` | *(display factor, derived per display — see note)* | multiplies every length token below (see note) |
272
+ | `--vf-font-family` | `'VF Body', 'Geneva', 'Helvetica Neue', Helvetica, Arial, sans-serif` | body text (list rows, page copy) |
273
+ | `--vf-font-family-display` | `'VF Display', 'Chicago', 'ChicagoFLF', 'Charcoal', 'Geneva', 'Helvetica Neue', Helvetica, Arial, sans-serif` | chrome text (menus, buttons, titles, fields) |
274
+ | `--vf-font-size` | `16px` | body face size |
275
+ | `--vf-font-size-display` | `16px` | chrome face size |
276
+ | `--vf-line-height` | `12px` | the body face's native line — the pitch wrapped body copy sits on (`vf-paragraph`, `vf-label[face="body"]`); retheming the body face to another strike states this alongside `--vf-font-family` / `--vf-font-size` |
277
+ | `--vf-line-height-display` | `16px` | the display face's native line (Chicago 12's ascent 12 + descent 3 + leading 1) — wrapped chrome copy: `vf-paragraph[face="display"]`, `vf-label`, `vf-text-area`; the display retheme's third number |
278
+ | `--vf-font-weight` | `700` | all text (Chicago is inherently bold) |
279
+ | `--vf-black` | `#000000` | borders, text, stripes, selection bg |
280
+ | `--vf-white` | `#ffffff` | content wells, control faces |
281
+ | `--vf-surface` | *(set by containers)* | bg behind legends/label patches; `vf-window` and `vf-dialog` both set it to white |
282
+ | `--vf-disabled` | `#C0C0C0` | dimmed text, borders, glyphs (the kit's dim gray) |
283
+ | `--vf-desktop` | `#808080` | base color under the desktop dither — occluded by the default (opaque) tile, so it only shows through a custom `--vf-desktop-pattern` |
284
+ | `--vf-desktop-pattern` | *(1-bit tile)* | the desktop dither's art — a 50% checker drawn as opaque black-on-white rects, on a 30-system-px tile (see *Tiled fills*; override the whole tile — consumer art renders as a placed tile grid at that same geometry) |
285
+ | `--vf-shadow-offset` | `2px` | window/menu hard shadow offset |
286
+ | `--vf-control-height` | `22px` | text fields — `vf-text-field`, `vf-text-area`, the `vf-number-field` well |
287
+ | `--vf-button-height` | `20px` | `vf-button` face (the default ring's inner box is 80×20) |
288
+ | `--vf-button-group-gap` | `12px` | gap between buttons in a `vf-button-group` — the default clears the default ring's 4px reach on both sides, so rings never collide; retheming it below **8px** is the one value that puts two adjacent `variant="default"` rings into each other |
289
+ | `--vf-popup-height` | `18px` | `vf-select` pill (border box; its 1px hard shadow makes the sheet's 157×19 ink box) |
290
+ | `--vf-popup-inset-top` | `4px` | room a clipped popup panel keeps clear at the **top** screen edge. Environmental rather than per-instance — a page has one menu bar and every popup on it should respect the same reserve — so it is declared once on `:root` or the `vf-desktop`: `24px` clears a `vf-menu-bar` (the 20px bar plus the default 4). Authored (unscaled) system px, like `--vf-popup-height`; `positionPanel` reads it back off the panel and converts with `sys()` |
291
+ | `--vf-popup-inset-bottom` | `4px` | the same reserve at the **bottom** screen edge |
292
+ | `--vf-menu-row-height` | `16px` | `vf-menu-item` row pitch (`Menus.png`; kept separate from `--vf-popup-height` so re-theming the popup pill doesn't move pulldown rows); `vf-menu` also spends one full row on every slotted `vf-separator` — the MDEF's divider-as-item, rule 8px in (H/2 above, H/2−1 below) |
293
+ | `--vf-menu-shortcut-column` | `23px` | `vf-menu-item` shortcut slot, right-anchored with the text left-aligned in it so every ⌘ lands at the same x (`Menus.png`) — the MDEF reserve, ⌘'s 11px advance + the face's widest letter (M/W, 12px); widen it to line up longer shortcuts ("⌘⇧S") |
294
+ | `--vf-label-line-height` | follows the face | `vf-label`'s own line box, above the face tokens: unset, the box is the face's native line (`--vf-line-height-display` / `--vf-line-height`); set, it overrides both faces for captions alone — keep an override even, or the baseline lands on a half pixel |
295
+ | `--vf-paragraph-line-height` | follows the face | `vf-paragraph`'s own line box, above the face tokens — same contract as the label's, for paragraphs alone |
296
+ | `--vf-icon-label-height` | `12px` | `vf-icon`'s name plate line box — the Finder's own plate height, tighter than the face's 16px em, which centers in it (keep an override even, or the baseline lands on a half pixel) |
297
+ | `--vf-icon-gap` | `2px` | space between a `vf-icon`'s art cell and its name plate |
298
+ | `--vf-select-gutter` | `16px` | checkmark column: `vf-select` left inset / `vf-option` + `vf-menu-item` ✓ column (shared so the value doesn't shift on open) |
299
+ | `--vf-field-width` | `180px` | default width of `vf-text-field` / `vf-text-area` |
300
+ | `--vf-field-placeholder` | `#767676` | placeholder text in the editable fields — kept off `--vf-disabled`: a placeholder sits in an *enabled* well and holds AA contrast (4.54:1 on white), where the disabled gray's 1.82:1 is exempt as an inactive control |
301
+ | `--vf-number-field-width` | `4em` | width of `vf-number-field`'s input, in its own text (an em, not a system px length — it sizes to the digits) |
302
+ | `--vf-list-max-height` | `200px` | `vf-list` max height before its rail takes over (the host adds the 2px frame) |
303
+ | `--vf-titlebar-height` | `18px` | window/dialog title bars |
304
+ | `--vf-titlebar-height-utility` | `12px` | the slim `vf-window[variant="utility"]` (windoid) bar — 11px interior + 1px bottom rule, traced from `Windows/utility-window.png` |
305
+ | `--vf-dots-pattern` | *(1-bit tile)* | the windoid bar's dot-grid dither — a 2×2 motif, one black pixel at its origin, on a 30-system-px tile (`vfDots`; override the whole tile like `--vf-desktop-pattern`) |
306
+ | `--vf-swatch-checker` | *(tile)* | `vf-swatch`'s no-color transparency checker — a 4×4 motif of 2×2 white/`#c0c0c0` checks, on a 60-system-px tile (override the whole tile like `--vf-desktop-pattern`) |
307
+ | `--vf-menubar-height` | `20px` | `vf-menu-bar` |
308
+ | `--vf-separator-color` | `var(--vf-black, #000)` | `vf-separator` rule color — `vf-menu` sets it to `--vf-disabled` for the dimmed menu rule |
309
+ | `--vf-separator-style` | `solid` | `vf-separator` rule style — `vf-menu` sets `dotted` (see `Menus.png`) |
310
+ | `--vf-focus-outline` | `1px dotted currentColor` | focus-visible outline — `currentColor`, not black, so the multi-select keyboard cursor stays visible riding a selected row's inverted bar |
311
+ | `--vf-focus-offset` | `2px` | its `outline-offset` (negative to inset the ring) |
312
+ | `--vf-focus-underline-offset` | `4px` | where the dashed focus rule sits, from the underlined element's padding-box bottom (negative drops it below) — see §4 |
313
+ | `--vf-progress-fill` | `#000000` | determinate progress fill (solid black) |
314
+ | `--vf-progress-track` | `#ffffff` | progress track (white) |
315
+ | `--vf-progress-stripes` | *(1-bit tile)* | the indeterminate barber stripes — a 12×12 motif drawn as rects so the staircase stays whole system px at any scale, on a 60-system-px tile (override the whole tile) |
316
+ | `--vf-scrollbar-thumb` | `#ffffff` | scrollbar thumb/elevator (white) |
317
+ | `--vf-scrollbar-track` | `#ffffff` | the scroll trough's base color under the dot-dither (white) |
318
+ | `--vf-highlight` | `#000000` | selection background |
319
+ | `--vf-highlight-text` | `#ffffff` | selection foreground |
320
+ | `--vf-cursor` | *(unset — every spot keeps its classic pointer)* | read by every place the kit states a cursor of its own (control hosts, title-bar widgets, the stepper, scrollbar rails, the grow box, the editable wells' I-beam, a modal's backdrop) — set `none` on `:root` to blank them all for a page-drawn cursor (see note) |
321
+
322
+ **`--vf-scale` (display scaling).** Every length above is authored in *system
323
+ pixels* and multiplied by `--vf-scale`. It defaults to the true-size factor for
324
+ the current display **and zoom** — `devicePxPerSystemPx(trueDpr) / trueDpr`,
325
+ where the target is `round(96/72 × trueDpr)`: the whole number of device pixels
326
+ nearest the classic 1/72 inch, 96 being CSS's reference dpi. One system pixel
327
+ therefore maps to a whole count of device pixels (1 on a 1× display, 3 on a 2×
328
+ one, 4 on a 3× one) and the 1-bit art stays crisp at any density and any zoom
329
+ level — applied per component
330
+ by a `ScaleController` (`src/scale.ts`), which re-adapts on dpr and zoom
331
+ changes. `trueDpr` is device px per CSS px *including* browser zoom
332
+ (`truePixelRatio()`, `src/zoom.ts`): `window.devicePixelRatio` reports that
333
+ number in Chrome/Firefox but not Safari, which pins its dpr to the hardware and
334
+ goes stale about the rasterization density at any non-100% zoom. A consumer or
335
+ ancestor `--vf-scale` always wins (set it to `1` to pin the fixed authored
336
+ sizes), and because it is a plain inherited multiplier, nesting never
337
+ compounds. JS-driven geometry (slider rail/thumb, select panel, window resize)
338
+ converts between system and CSS px with the `sys()` / `toSys()` helpers.
339
+
340
+ **Line boxes are lengths too.** Every line-height in the kit is stated in whole
341
+ system px (`--vf-menu-row-height`, `--vf-label-line-height`,
342
+ `--vf-paragraph-line-height`, …), never as a ratio. A ratio resolves to whatever
343
+ it resolves to — `1.65 × 17px` is `28.05px` — and each line pushes everything
344
+ after it further off the device-pixel grid, which is the single most common way
345
+ a page fringes an otherwise-correct component (see the layout contract in
346
+ README). Re-theme these tokens with whole numbers.
347
+
348
+ **One size.** Both embedded faces are single 16-design-px masters and
349
+ render at exactly that size — one design px = one system px, always. "Smaller"
350
+ is a *family* switch: System 7's fine print was Geneva 9 — the collection's
351
+ smallest strike, which **is** the body face — so a dialog's disk-space caption
352
+ is `face="body"` (usually `dim`), the way `vf-icon size="small"` swaps to the
353
+ 16×16 art. A genuinely different
354
+ size is a different strike (`fonts/imported/` holds the classic collection),
355
+ registered like the embedded ones and themed in through the font family/size
356
+ tokens with its own whole-pixel metrics.
357
+
358
+ **Grid snapping.** Whole system pixels only put an edge on the device grid
359
+ *relative to the component's own origin*; a page that lands that origin on a
360
+ fractional device pixel smears the whole 1-bit interior. `applyGridSnap()`
361
+ (`src/grid-snap.ts`) opts a page into having every component measure its own
362
+ paint and cancel the fractional remainder, so the origin is the component's
363
+ responsibility rather than the page's. The correction is a `left`/`top` offset
364
+ on the component's own paint root — the shadow element carrying the `.vf-snap`
365
+ class (`vfBase`), relatively positioned, driven by two reserved custom
366
+ properties (`--vf-snap-dx`/`-dy`) the controller writes on the host. A
367
+ *layout*-stage shift, because a compositing-stage `transform` leaves the
368
+ subtree rastered at its old position and only removes ~80% of the fringe.
369
+ Nothing outside the shadow root is written but those two properties, so the
370
+ correction cannot collide with a consumer's positioning or with `vf-window`'s
371
+ drag coordinates. Absolutely positioned satellites that anchor to the host
372
+ (`vf-menu`'s panel, the default button's ring) compose the same properties
373
+ into their insets; rows and options inside a corrected container
374
+ (`vf-list-item`, `vf-menu-item`, `vf-option`) and layout-only hosts
375
+ (`vf-button-group`, `vf-radio-group`) carry no target and ride their
376
+ surroundings. It corrects the origin only: a fractional *size*, and a
377
+ `--vf-scale × trueDpr` that isn't whole, are still the page's to get
378
+ right.
379
+
380
+ **Tiled fills.** A repeating `background-image` is the one place where "one
381
+ system pixel is a whole number of device pixels" is not enough on its own. That
382
+ contract holds — but the CSS *length* expressing it need not be one the engine
383
+ can store: Chromium lays out in 1/64 CSS px (Gecko in 1/60), and at
384
+ `--vf-scale` 4/3 a 2-system-px tile is 2.6667 CSS px, held as 2.65625. A single
385
+ edge survives that, because paint snaps each box to the device grid on its own —
386
+ which is why every border, stepped corner and magnified icon rasterizes exactly
387
+ at 4/3. A tiled fill does not: it is ONE snapped box holding N *unsnapped*
388
+ repeats, each placed at `k × tileSize`, so the error compounds until the tile
389
+ boundary has walked a whole device pixel. Unfixed, 75% of the desktop dither
390
+ rasterizes to mid-gray at dpr 1.5.
391
+
392
+ The span construction (`vfTileSize`, `tileImage`, `tileSpan`) makes the
393
+ stored length exact for every scale the *density* ladder derives: a fill
394
+ authored as a **motif** tiles at `lcm(motif, 15)` system px — a whole number
395
+ of motifs, so the art is unchanged, and holdable because a scale `p/q` needs
396
+ `q` to divide `64 × span`, the 64 absorbs every power of two, and every
397
+ derived scale (1, 3/2, 4/3, 8/5, 6/5, 5/4) has an odd part of 1, 3 or 5. But
398
+ **zoom mints scales the lattice cannot hold** — 20/17 at Safari's 85%, 30/23
399
+ at its 115%, arbitrary primes with no finite cover (ZOOM-TILE-DRIFT.md) — so
400
+ the four convertible surfaces no longer repeat in CSS at all
401
+ (`src/tile-grid.ts`, TILE-GRID-PLAN.md):
402
+
403
+ - **Kit art renders as one whole-surface raster** — the motif, stated once as
404
+ rect data (`TileRect[]`), encoded at one image px per system px
405
+ (`tileRaster`) and magnified nearest-neighbor
406
+ (`image-rendering: pixelated`, the `vf-img` mechanism). Nearest-neighbor
407
+ can only produce source colors and one box has no interior seams, so the
408
+ fill is 1-bit at every scale, holdable or not. (Raster, not SVG, because
409
+ Chromium rasterizes an SVG image at the box's *stored* fractional size and
410
+ ignores `image-rendering` for SVG — measured; the crisp path does not exist.)
411
+ - **A consumer pattern token renders as a flat grid of placed tiles** at the
412
+ token's documented 30/60-system-px tile geometry: each tile positioned by
413
+ one single-multiplication `calc()` quantized once, every box paint-snapped
414
+ independently — never laid out with CSS grid/flex/flow, whose summed track
415
+ sizes would re-import the accumulation. The token still overrides the whole
416
+ **tile**, not the motif; a token swapped at runtime without touching the
417
+ component needs a `requestUpdate()`.
418
+
419
+ The span construction stays load-bearing for the CSS-repeated underlays
420
+ beneath the opaque kit fills and for the forced-colors mask branches (no mask
421
+ pipeline rasterizes exactly at a zoom-minted scale, so forced-colors-plus-zoom
422
+ remains the one accepted residual). The scroll trough — once the one surface
423
+ that could not convert, a `::-webkit-scrollbar` pseudo hosting no children —
424
+ is ordinary DOM now (§4 `vfScrollRail`) and renders through `tileRaster` like
425
+ the desktop dither: 1-bit at every scale, zoom-minted ones included. `npm run
426
+ verify:tile` asserts zero gray on the four converted surfaces at eight
427
+ densities — the ladder plus 1.7 and 2.3, the emulated stand-ins for Safari's
428
+ broken rungs; `npm run verify:scrollbars` asserts the same on the trough.
429
+
430
+ **`--vf-cursor` (hiding the pointer for a page-drawn cursor).** A page that
431
+ draws its own cursor — a JS-positioned image on the system-pixel grid, which
432
+ is what `applyCursor()` (`src/cursor.ts`) sets up with the kit's embedded
433
+ System 7 pointer set — needs the native pointer gone from every surface, and
434
+ `html { cursor: none }` alone cannot get it there: inheritance stops wherever a
435
+ shadow stylesheet states a cursor of its own, and the kit states one
436
+ deliberately on its chrome — control hosts, the title-bar widgets, the number
437
+ stepper, the scrollbar rails, the grow box, the editable wells (whose I-beam
438
+ Firefox's UA sheet would otherwise pin), and a modal's top-layer backdrop,
439
+ which no page rule reaches at all. Every one of those declarations reads this
440
+ token first, so `--vf-cursor: none` on `:root` (custom properties inherit
441
+ through every shadow root) covers all of them. The page's own side must be a
442
+ blanket, not a root rule: the UA sheets put `cursor: default` on `<button>`
443
+ and `cursor: text` on `<input>`, and an element's own declaration beats
444
+ anything it would inherit — so a bare `html { cursor: none }` leaves the OS
445
+ arrow alive over every native control in the light DOM. The pair that empties
446
+ everything is `* { cursor: none !important }` plus the token — `applyCursor()`
447
+ applies both itself, once its art has decoded; state the pair by hand only
448
+ under a hand-rolled overlay. Unset, each
449
+ spot keeps its classic pointer — the arrow on chrome, the I-beam in an
450
+ enabled well.
451
+
452
+ ## 4. Shared recipes (in `src/styles/base.ts`)
453
+
454
+ - `vfBase` — host font, `box-sizing: border-box` everywhere, the `.vf-snap`
455
+ grid-correction hook (see Grid snapping above), `user-select: none`
456
+ (text inputs re-enable), `:host([hidden]) { display: none !important }`.
457
+ - `vfBodyDecls` — the three declarations that put text on the Geneva
458
+ body face (family, 16px, smoothing), for composing into one rule; the mirror
459
+ of `vfDisplayDecls`, and the body face's single definition (`vfBase` applies
460
+ it to every host; `face="body"` switches back to it).
461
+ - `vfStaticText` — the `face` / `size` / `dim` host switches shared by
462
+ `vf-label` and `vf-paragraph`. All `:host([attr])`, one specificity step above
463
+ the plain `:host` rule each component sets its own default face in.
464
+ - `vfStripes` — a `.vf-stripes` class for the racing-stripe layer. Position it
465
+ absolutely inside the title bar, inset `3px 1px` (top/bottom 3px, left/right
466
+ 1px) so exactly six 1px stripes show at the 18px bar height, their top and
467
+ bottom edges aligned with the close box's and one system px of clear white
468
+ between the stripes and the frame border — the same buffer the widgets'
469
+ patch ring keeps. The paint is split by engine, each side the measured best
470
+ (the full three-engine matrix is in `scripts/verify-tile.mjs`): Blink — and
471
+ any engine failing both gate properties — draws a 12-unit SVG (the band's
472
+ 11 rows plus an empty pad row, viewBox stretched onto a 12px-tall box,
473
+ crispEdges). Twelve, not eleven, because Blink pixel-snaps painted boxes to
474
+ whole CSS px and an 11-system-px box has no legal CSS height at scale 3/2;
475
+ 12 divides by 2 and 3, so the box never rounds at any integer display's
476
+ scale, and the SVG measured whole-rhythm, close-box-registered and
477
+ zero-gray at dpr 1/1.5/2/3 — retiring the `repeating-linear-gradient`,
478
+ which it strictly dominates. Gecko and WebKit (gated by `@supports
479
+ (-moz-appearance: none) or (-webkit-backdrop-filter: blur(1px))`, each
480
+ property parsing in its one engine only) render six placed solid rows
481
+ (`chromeTitleBar` supplies the spans): both engines device-snap solid
482
+ quads exactly, and each misrendered the gradient — Gecko's GPU pipeline
483
+ softens a hard stop at default zoom, WebKit landed the sixth stripe a
484
+ device row thin at dpr 3 and the zoom-minted scales.
485
+ - `vfDots` — the windoid bar's counterpart to `vfStripes`: a `.vf-dots` layer
486
+ inset `2px` top/bottom and **flush left/right** (the close-up reference runs
487
+ the dots into the side borders; the `Windows/` sheet's 2px side inset is the
488
+ artist's, not the bar's), a 2×2 motif carrying one black pixel at its origin
489
+ (`--vf-dots-pattern` to retheme). A width-declaring window renders the exact
490
+ fill into the layer (see *Tiled fills*); the layer's own CSS-repeated SVG
491
+ tile is the fallback for a window with no declared width.
492
+ - `vfTileSize` / `tileImage` — the span a CSS-REPEATING fill takes, and its
493
+ art. Every metric in the kit is `calc(var(--vf-scale, 1) * Npx)`, and paint
494
+ snaps each box to the device grid on its own — but a tiled fill is one
495
+ snapped box holding N *unsnapped* repeats, each placed at `k × tileSize`, so
496
+ a tile size the layout grid cannot hold drifts a fraction of a device pixel
497
+ further with every repeat. Load-bearing for the underlays
498
+ and the forced-colors masks; the converted surfaces (the scroll trough now
499
+ among them) render through
500
+ `vfTileGrid` / `tileGrid` / `tileRaster` instead. See *Tiled fills* below.
501
+ - `vfHardShadowDecls` — the hard 1-bit drop shadow on its own, for composing
502
+ into a surface that supplies its own border:
503
+ `box-shadow: var(--vf-shadow-offset, 2px) var(--vf-shadow-offset, 2px) 0 0 var(--vf-black, #000)`.
504
+ No blur, no spread — System 7's only depth cue. Every raised surface in the kit
505
+ composes this one declaration (`vfPanel`, `vfChromeFrame`; `vf-swatch`
506
+ composes it without either surface class).
507
+ - `vfPanel` — a `.vf-panel` class for menus/popups:
508
+ white bg, `border: 1px solid var(--vf-black, #000)`, `vfHardShadowDecls`.
509
+ - `vfChromeFrame` — a `.vf-frame` class for the framed containers (`vf-window`,
510
+ `vf-dialog`): the same white face, 1px black border and hard offset shadow as
511
+ `vfPanel`, no bevels. Skin only — each component supplies its own layout,
512
+ since a window frame is a full-size flex column while a dialog's is a block the
513
+ native `<dialog>` shrink-wraps.
514
+ - `vfModalFrame` — the dBoxProc modal-dialog frame (`vf-dialog frame="plain"`):
515
+ `.vf-modal-frame` (white face, 1px black border, **no shadow**) around
516
+ `.vf-modal-frame-inner` (`margin: 2px; border: 2px solid`). System 7's alert
517
+ box drew the mirror trace — 2px outer, 2px gap, 1px inner rule, *with* the
518
+ hard shadow (`Windows/modal dialog.png` vs the alert reference) — a
519
+ different chrome the kit does not ship: an alert box is a consumer
520
+ composition over this frame, not a component.
521
+ - `vfWindowWidgets` — the title-bar window widgets (`.box` base, `.close`
522
+ left / `.zoom` right at 11×11 with the 1px white patch ring, the pressed
523
+ go-away sunburst, the nested zoom square), shared by `vf-window` and a
524
+ `closable` `vf-dialog` so the two components' widgets match by construction.
525
+ The templates that pair with it (`closeBox`/`zoomBox`/`widgetLabel`) live in
526
+ `src/chrome.ts` with `chromeTitleBar`. Geometry is the standard bar's;
527
+ `vf-window`'s utility variant overrides sizes under its own selector.
528
+ - `vfTitleBar` — the striped title bar shared by `vf-window` and `vf-dialog`:
529
+ a `.vf-title-bar` row (`height: var(--vf-titlebar-height, 18px)`, 1px bottom
530
+ rule, `overflow: hidden`) and the `.vf-title` patch inside it (display face,
531
+ white bg, `padding: 0 6px`, `z-index: 1` over the stripes, `nowrap` +
532
+ ellipsis). Put a `.vf-stripes` layer in as the bar's first child.
533
+ - Title geometry is whole system px, traced from the InfiniteMac reference:
534
+ the face's 16px line box on interior rows 1..16 (`align-items: flex-start`
535
+ + 1px top margin, `line-height: var(--vf-line-height-display, 16px)`)
536
+ lands the 9px cap band on rows 4..12 — 4px of white above and below — and
537
+ 6px padding plus the letters' 1px side bearing leaves 7px of white between
538
+ ink and stripes. Neither axis is flex-centered onto a fraction: vertically
539
+ the row is stated, horizontally the flex-centered patch is held on the
540
+ placement lattice by `TitleCenterController` (src/chrome.ts), which
541
+ cancels the parity half-pixel through the controller-owned
542
+ `--vf-title-dx`.
543
+ - The title's clearance for anything else in the bar is `--vf-title-inset`
544
+ (default 16px); `vf-window` sets 60px so an ellipsized title can't run under
545
+ its close/zoom widgets.
546
+ - `touch-action` is deliberately NOT in the recipe: `vf-dialog`'s bar is always
547
+ a drag handle, `vf-window`'s only when `[movable]`, and suppressing touch
548
+ scrolling on a bar that can't be dragged would be a behavior change.
549
+ - The markup and the four DragController pointer bindings that go with it are
550
+ `chromeTitleBar()` in `src/chrome.ts` (internal — it bakes in the kit's own
551
+ `part` names, so it is not part of the public toolkit). Its third argument
552
+ picks the texture layer class: `'vf-stripes'` (default) or `'vf-dots'`
553
+ (`vf-window variant="utility"`).
554
+ - `vfScrollRail` — the System 7 scroll rail, drawn by the kit as ordinary
555
+ shadow DOM (`src/scroll-rail.ts` renders the subtree and drives it; this is
556
+ its skin). The native scrollbar is hidden — `scrollbar-width: none` plus a
557
+ `::-webkit-scrollbar { display: none }` for older WebKit, the last scrollbar
558
+ pseudo in the kit — never the native scrolling: wheel, trackpad momentum,
559
+ keyboard, touch and AT scrolling stay the platform's, and
560
+ `ScrollRailController` syncs the rail to `scrollTop`/`scrollLeft` while
561
+ driving the classic interactions (thumb drag; trough paging by a viewport
562
+ minus one line, repeating until the thumb reaches the pointer; arrow lines
563
+ with auto-repeat and the hollow→solid pressed glyph). Geometry: the classic
564
+ 16px cell whose outermost line is the component's own 1px frame border — the
565
+ rail element is the 15 inside it, a 1px divider on the content side plus the
566
+ 14px channel; 15px arrow cells whose glyphs are the 16-unit sprites windowed
567
+ to their 14×14 interior; the **fixed** 16px thumb (System 7's box, never
568
+ proportional; whole-system-px travel); the 25% dot-dither trough rendered as
569
+ a whole-surface `tileRaster` (1-bit at every scale, zoom included) on
570
+ `var(--vf-scrollbar-track)`. States key off the attributes
571
+ `ScrollStateController` writes — an idle axis keeps its arrows (drawn but
572
+ inert) and drops the dither and thumb, an inactive window blanks both axes
573
+ to the bare white channel, arrows included, a degenerate track drops the
574
+ thumb, then everything (the Control Manager's decision table). The rail subtree is `aria-hidden` and
575
+ pointer-only; scrolling's keyboard/AT contract stays on the viewport. Every
576
+ engine renders the same rail — no Firefox fallback — and it is
577
+ pixel-asserted headless (`npm run verify:scrollbars`), which the
578
+ `::-webkit-scrollbar` skin it replaced never could be.
579
+ - `vfFocusRing` / `vfFocus` / `vfFocusUnderline` — the two focus indicators.
580
+ Neither is a System 7 reproduction: the machine drew no keyboard focus at all
581
+ (see §1), so both are additions rendered in its vocabulary.
582
+ - `vfFocusRing` is the dotted-outline declaration pair — `outline:
583
+ var(--vf-focus-outline, 1px dotted currentColor)` plus a scaled
584
+ `--vf-focus-offset` (default +2px) — interpolated into whatever selector a
585
+ component focuses on; `vfFocus` wraps it as a `.vf-focus:focus-visible`
586
+ class for the controls where focus and ring share one element.
587
+ `currentColor`, not black, for the same reason the underline uses it: the
588
+ ring must invert with the ink it sits on, or the multi-select keyboard
589
+ cursor disappears riding a selected list row's black bar.
590
+ - `vfFocusUnderline` is the alternative for a control that can carry the mark
591
+ on its own face: an `::after` on that element, 1 system px tall, spanning
592
+ its box, dashed 1px on / 1px off via a `repeating-linear-gradient` in
593
+ `currentColor` — so it inverts to white with the label on a pressed face.
594
+ The component suppresses the UA outline in the same rule set, and the
595
+ underlined element needs `position: relative`. Two placements:
596
+ - **Inside the control**, under the ink it marks: `vf-button` underlines its
597
+ label, `vf-checkbox` its box, `vf-radio` its circle (narrowed to 9 of its
598
+ 13px, since that shape is round — §5), the three editable fields their
599
+ well (via `vfField`'s `.vf-field-well`) and `vf-menu` its bar title.
600
+ - **Below the control**, under its whole box, where there is no room
601
+ inside: a `vf-select`'s single line is already the label and the ▼, every
602
+ pixel inside a `vf-swatch` is the color it exists to show, and a
603
+ `vf-slider`'s only interior is the handle — which moves, so marking it
604
+ marked the *value* rather than the control. `vf-select` always casts the
605
+ hard shadow and `vf-swatch` does under `shadow`, which the rule has to
606
+ clear — ink lying outside every box the pseudo-element could size itself
607
+ to; the slider's runs the rail's full width and the handle occludes it in
608
+ passing (z-index 1), exactly as it occludes the rail behind it.
609
+ - `--vf-focus-underline-offset` places it, measured (like any `bottom`)
610
+ from the element's **padding** box up to the rule's bottom edge, so
611
+ positive insets it and negative drops it below. The contract is one blank
612
+ system px row between the rule and the ink above it, so a negative offset
613
+ counts every row of ink in between: `4px` (the default) for a text box,
614
+ whose ink stops at the baseline 6px above its bottom (2px half-leading
615
+ over the 16px em + the 4px descent); `-2px` for a well whose ink ends at
616
+ its own bottom edge (the radio's circle, a field's wrapper) and for
617
+ `vf-menu`'s title box; `-3px` for the checkbox, adding its 1px border;
618
+ `-4px` for `vf-select`, adding its 1px hard shadow as well; `3px` for
619
+ `vf-slider`, counting back up from the track's bottom to a row under the
620
+ rail; and for `vf-swatch` a `calc()` that composes the depth actually in
621
+ play — `0px` flat, a rethemeable `--vf-shadow-offset` under `shadow` —
622
+ rather than hard-coding one. `npm run verify:focus`.
623
+ - `vf-menu` is the one that anchors to a **box** rather than to the
624
+ baseline the button uses: its title box is
625
+ shrunk to the face's own em (`line-height: 1`), whose bottom edge is the
626
+ descent line, so one offset serves both a descender and the Apple menu's
627
+ slotted 16px `vf-img`. The button's rule, one row under the baseline, is
628
+ crossed by both.
629
+ - The two controls that **drop open** — `vf-menu` and `vf-select` — draw the
630
+ rule only while closed. An open menu or list already shows where focus
631
+ is (a whole inverted cell, a dropped panel), so the rule is reserved for
632
+ the closed state. Both keep the underlying
633
+ keyboard-focus state through the open state, so the rule returns by itself
634
+ on close.
635
+ - The mark is **keyboard-only** everywhere, but four controls can't say so
636
+ with `:focus-visible`, for two opposite reasons:
637
+ - The **fields**, because that selector is specified to match *any* focus
638
+ of an element which takes keyboard input, so it is already true for a
639
+ text field clicked with the mouse — where on a button it is false.
640
+ - **`vf-select`, `vf-menu` and `vf-slider`**, because each suppresses the
641
+ browser's own mouse focus (a press-drag gesture owns the pointer) and
642
+ calls `focus()` itself, and Blink reads a scripted focus as a *visible*
643
+ one. So a plain mouse press on any of the three ends `:focus-visible`.
644
+
645
+ All four gate on a `.vf-focus-rule` class driven by
646
+ {@link FocusRuleController} (`src/focus-modality.ts`), which resolves the
647
+ page's last input modality against the host's own focus: one refcounted
648
+ capture-phase `pointerdown` + `keydown` pair on the document, read at
649
+ `focusin`, defaulting to `'keyboard'` so assistive tech and `autofocus`
650
+ are still marked, and dropped at a `focusout` that leaves the component
651
+ (focus moving *within* it — a pill to its option rows, a title to its
652
+ dropped panel — keeps the mark). Typing after a click does not reveal it,
653
+ and neither does a click on the control's `vf-label` caption — a pointer
654
+ landing focus from outside the control is why the tracker is page-wide
655
+ rather than a local `pointerdown` latch.
656
+ - Two escape hatches, for the routes a focus event can't report.
657
+ `suppress()`: a press on an **already-focused** control moves no focus
658
+ and fires no `focusin`, so `vf-menu` and `vf-slider` call it from their
659
+ own `pointerdown`. `reveal()`: `vf-slider` calls it from a handled
660
+ arrow key, so a slider grabbed with the mouse and then nudged with the
661
+ keys starts showing its rule.
662
+
663
+ `npm run verify:focus` pins all of it, including the two browser
664
+ behaviors that make it necessary.
665
+
666
+ ## 5. Component specifications
667
+
668
+ Files live in `src/components/`. "Parts" = CSS shadow parts via `part=`.
669
+
670
+ ### Group A — chrome & shells
671
+
672
+ **The window archetypes are parameter recipes, not components.** The 1992 HIG
673
+ names five standard windows, but its own figures disagree about their anatomy
674
+ (Figure 5-1 and Figure 6-1 label the same two artworks opposite ways — the
675
+ close box migrates between the movable modal and the modeless dialog). So the
676
+ kit's two shells stay neutral parameter surfaces — `vf-window` (desktop-
677
+ resident) and `vf-dialog` (top-layer modal) — and the archetypes are the
678
+ documented one-liners below. The components *enable* HIG compliance via
679
+ specific author choice; they don't enforce one reading. The canonical recipes
680
+ follow the Chapter 6 body text (movable modal: bare bar; modeless: close box),
681
+ treating Figure 5-1's labels as the erratum — both readings stay composable.
682
+
683
+ | Archetype (1992 HIG) | Recipe |
684
+ | --- | --- |
685
+ | Document window | `<vf-window closable zoomable movable resizable scrollbars="both">` |
686
+ | Movable modal dialog box | `<vf-dialog heading="…">` (add `closable` for the Figure 5-1 reading) |
687
+ | Modal dialog box | `<vf-dialog frame="plain">` |
688
+ | Modeless dialog box | `<vf-window closable movable>` (no zoom, grow or rails) |
689
+ | Utility (floating) window | `<vf-window variant="utility" movable>` |
690
+
691
+ Every recipe also declares its size — `width`, and `height` where the window
692
+ isn't content-shaped — in whole system px. The sizes are omitted from the
693
+ table only to keep the distinguishing parameters legible.
694
+
695
+ #### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
696
+ Full-bleed classic desktop container.
697
+ - **Attributes/props:** `width`, `height: number` (system px, defaults
698
+ 512×342 — the compact Mac's raster) — the screen's own size, the way a
699
+ WIND resource declared a window's. The host box renders at exactly
700
+ `width + 2·bezel` per axis (a live `sysLength` written as host inline
701
+ size on every update), always a whole number of system px. **Pure CSS
702
+ sizing is not supported**: the inline size wins over any stylesheet, so
703
+ the page sets the numbers — directly or via `fitWithin` — and positions
704
+ the explicitly sized desktop with its own CSS, keeping any
705
+ sub-system-pixel slack in the page's own layout.
706
+ `bezel: number` (system px, default 0) — the black screen surround, the
707
+ CRT's unlit margin between raster and case, added onto the declared screen
708
+ on every side. The screen owns flow, an absolute window's containing block
709
+ and the drag clip, so a dragged window crops at the raster's edge. Its two
710
+ *top* corners wear the `SCREEN_CORNER` mask (the classic framebuffer
711
+ rounded only the top pair; the raster's bottom corners ran square), painted
712
+ above everything like the hardware mask — the masks land over a slotted
713
+ menu bar's corners, so the bar needs no `rounded` of its own inside a
714
+ bezeled desktop. The bezel width is written onto the host as
715
+ `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` —
716
+ component-owned, not a theming token).
717
+ - **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/
718
+ `height` to the largest whole-system-px raster whose host box — bezel
719
+ included — fits the bound, per the current effective scale, and returns
720
+ `{ width, height }`. The page's half of the sizing contract: call it on
721
+ `resize` and `onScaleChange` (zoom and density moves change what a system
722
+ px costs in CSS px), as the showcase does in `demo/demo.ts`.
723
+ - **Visual:** `display: block; position: relative;` — the paint lives on an
724
+ inner screen surface (part `desktop`, `overflow: hidden` — the
725
+ whole-system-px raster, inset by `bezel` when one is set).
726
+ Screen surface = classic 50% dither: a 2×2 motif with an opaque white base
727
+ and two black pixels on the diagonal, rendered as the exact fill (one
728
+ whole-surface raster; a consumer `--vf-desktop-pattern` renders as a placed
729
+ tile grid on the token's 30-system-px tile — see *Tiled fills*) over a
730
+ CSS-repeated underlay of the same art. The art is opaque black-on-white —
731
+ the authentic System 7 dither — so it covers `var(--vf-desktop, #808080)`
732
+ beneath it; that base color shows only under a custom pattern with
733
+ transparent cells (or `none`).
734
+ - **Slots:** default (menu bar, windows, anything).
735
+ - **Behavior:** manages stacking of slotted `vf-window` children: `pointerdown`
736
+ *or `focusin`* on a window brings it to front (incrementing z-index counter)
737
+ and sets its `active` attribute, clearing `active` on the others — the
738
+ `focusin` half is the keyboard route to activation: Tab landing anywhere in a
739
+ background window (its undrawn-but-focusable widgets included) raises it.
740
+ Listens via delegated pointerdown/focusin listeners + `slotchange`. Windows
741
+ slotted before `vf-window` is
742
+ defined are re-normalized once `customElements.whenDefined('vf-window')`
743
+ settles, since the upgrade reflects each window's `active = true` default back
744
+ out and upgrading a slotted node doesn't re-fire `slotchange`.
745
+ - **DOM order follows z-order** (bottom-most first), so sequential focus
746
+ order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync
747
+ runs at pointer-gesture end (a mid-gesture node move would clear the
748
+ pointer capture a title-bar drag or grow-box resize holds) and on
749
+ programmatic `bringToFront`, never from a focus-driven raise (moving the
750
+ window focus just entered would re-order the tab sequence mid-traversal);
751
+ focus surviving its own window's move is restored without re-raising that
752
+ window. Non-window children (a menu bar, page content) keep their
753
+ positions. `npm run verify:window-a11y`.
754
+ - **Floating tier:** `vf-window[variant="utility"]` children stack in a z
755
+ band `1_000_000` above the document tier (one shared monotonic counter, so
756
+ a palette stays above every document window), restack only among
757
+ themselves, and stand outside the single-active invariant both ways:
758
+ clicking a palette doesn't deactivate the active document window, and
759
+ activating a document window never clears a palette's `active` — System 7
760
+ windoid behavior while the app is frontmost. The tier test reads the
761
+ `variant` *attribute*, so a not-yet-upgraded element still lands right.
762
+ - **Parts:** `desktop`.
763
+
764
+ #### `vf-window` (`VfWindow`, vf-window.ts)
765
+ The desktop-window shell: the classic document window (see DragThing
766
+ screenshot), parameterized down to the windoid (see the Group A recipe table).
767
+ - **Attributes/props:** `heading: string` (title text), `width: number` /
768
+ `height: number` (**declare them both** — whole system px, so the window keeps
769
+ its proportion to the chrome inside it at every density. A window is a fixed
770
+ box in both axes, the way a WIND resource was: one that grows with its body is
771
+ one the user can neither predict nor control via the grow box. Each missing
772
+ dimension falls back to something different — width to block layout, height to
773
+ the content — and the window names whichever are missing, once, in the
774
+ console), `active: boolean`
775
+ (default **true**; reflect), `closable: boolean` (default true),
776
+ `zoomable: boolean` (default false), `movable: boolean` (default false),
777
+ `resizable: boolean` (default false), `flush: boolean` (default false —
778
+ removes body padding), `variant?: 'utility'` (the slim windoid chrome),
779
+ `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails).
780
+ - **Visual:** `vfChromeFrame` + `vfTitleBar` (§4), plus a full-size flex-column
781
+ layout on the frame. `display: block`. Sets
782
+ `--vf-surface: var(--vf-white, #fff)` on itself.
783
+ - Title bar: from `vfTitleBar` — height `var(--vf-titlebar-height, 18px)`,
784
+ white bg, bottom `1px solid black`, contains `.vf-stripes` layer (only when
785
+ `active`). `touch-action: none` only when `[movable]`.
786
+ - Title: centered, bold, on a white patch (`padding: 0 6px`; cap band on
787
+ interior rows 4..12, 7px of white between ink and stripes — see §4
788
+ `vfTitleBar` for the traced geometry and the lattice hold) above the
789
+ stripes, with `--vf-title-inset: 60px` of clearance so it ellipsizes before
790
+ reaching the widgets. Inactive: no stripes, widgets undrawn (transparent
791
+ ink — they keep their tab stops; see A11y below), the grow box's
792
+ nested squares hidden, and every managed scroll rail inside the window
793
+ blanked (see "always-a-rail" §5 vf-scroll-area) — but the title text stays
794
+ black (System 7 never grayed the window title).
795
+ - Close box: LEFT side, 11×11px, 8px from the inner-left edge, with 3px of
796
+ clear white above and below it, `1px solid black`, white bg, no bevel,
797
+ surrounded by a 1px white patch interrupting the stripes. `:active`
798
+ (pressed) → the interior fills with the classic radiating "go-away"
799
+ sunburst: black 1-bit spokes on the white face (four orthogonal 3px spokes
800
+ plus four diagonal 2px ones around an empty center), traced pixel-for-pixel
801
+ from the UI kit's close-button-active-state sprite.
802
+ - Zoom box: RIGHT side, same box, plus a small box nested in its top-left
803
+ corner (sharing the widget's own top/left border; only the right and bottom
804
+ edges are drawn). `:active` (pressed) → shows the identical sunburst as the
805
+ close box; the nested box gives way to it.
806
+ - Body: `padding: 12px` (0 if `flush` or `scrollbars`), and `overflow: hidden`
807
+ — the window being a fixed box, content taller than it is clipped at the
808
+ frame the way the classic content region was, rather than painting out over
809
+ the desktop; `scrollbars` is how the user reaches the rest. Two deliberate
810
+ exemptions: `[scrollbars]` restores `overflow: visible`, because the
811
+ edge-rail composition below pulls the scroll area one system px *outside*
812
+ the body on every side and clipping would shave exactly that overhang off
813
+ (the scroll area does its own clipping); and a control's drop-open panel is
814
+ not clipped — `vf-select`'s list is `position: fixed` off the control's rect
815
+ precisely to escape clipping ancestors (§5 vf-select), and it still escapes,
816
+ because nothing between it and the viewport establishes a containing block
817
+ for fixed descendants (the grid-snap correction is a `position: relative`
818
+ left/top offset, never a transform — see §7). A `vf-menu` panel is anchored
819
+ `position: absolute` and *would* clip, but a menu bar belongs to the desktop,
820
+ not inside a window body.
821
+ - Grow box (if `resizable`): 15×15 at bottom-right corner, white bg, 1px black
822
+ top/left borders, containing two overlapping small square outlines. Inactive:
823
+ the cell and its borders stay, the nested squares go — System 7 drew a
824
+ deactivated window's size box hollow, with its blanked scroll rails.
825
+ - Edge scroll rails (if `scrollbars`): the body slot renders inside a shadow
826
+ `vf-scroll-area` (its `axis` = the attribute's value, `label` = the
827
+ heading, `viewport` part re-exported) carrying the TeachText composition
828
+ internally — `calc(100% + 2px·scale)` with `margin: -1px·scale`, one
829
+ system px under the frame on every side, so the area's own frame border
830
+ repaints the window's border lines and a `resizable` window's grow box
831
+ (z-index 1) lands exactly over the rail-corner cell. Same caveats as the
832
+ slotted composition (see vf-scroll-area §5), which remains supported for
833
+ inset wells.
834
+ - Utility variant (`variant="utility"`): the slim windoid bar traced from
835
+ `Windows/utility-window.png` — `--vf-titlebar-height-utility` (12px = 11px
836
+ interior + 1px rule), the `vfDots` dither instead of stripes (flush to the
837
+ side borders — see §4 vfDots), 7×7 widgets (`top: 2px`; close `left: 7px`,
838
+ zoom `right: 8px` — the art is asymmetric by that pixel) with a
839
+ 2px patch ring where the striped bar's is 1px (`--vf-widget-ring`,
840
+ internal geometry: the windoid sheet clears two px of dither beside its
841
+ widgets), and the nested zoom square shrunk so its edges land
842
+ at sprite col/row 3. No title patch: the display face's 16px line box
843
+ can't sit in an 11px interior, so `.vf-title` is `display: none` under the
844
+ variant (a retheming consumer can re-show it via `::part(title)`) and the
845
+ heading names the widgets. A pressed windoid widget inverts whole —
846
+ black interior under a white (invisible) borderline — rather than
847
+ flashing the big bar's 9×9 sunburst, which can't land on a 5×5 interior.
848
+ - **A11y:** the close/zoom `aria-label`s are qualified by the title when there is
849
+ one (`Close ${heading}` / `Zoom ${heading}`, falling back to bare `Close` /
850
+ `Zoom`) — several windows are open at once by design, so a bare repeated
851
+ "Close" gives an AT user no way to tell which window a widget belongs to. The
852
+ frame is `role="group"`, named by the title patch via `aria-labelledby` when
853
+ there is a heading (the utility variant's hidden patch still names it —
854
+ AccName resolves hidden labelledby targets) — `group` rather than `region`
855
+ deliberately, so a desktop of windows doesn't pollute landmark navigation;
856
+ the title bar is a `<div>`, never a `<header>`, which would map to an
857
+ unnamed `banner` landmark even inside the shadow root. An inactive window's
858
+ widgets stay in the tree and the tab order but paint no ink (transparent
859
+ border/background/patch ring — the bare System 7 bar): a background window
860
+ whose body holds nothing focusable is still reachable, activated by
861
+ `vf-desktop`'s `focusin` raise the moment Tab lands on a widget, and never
862
+ drops focus to `<body>` when it deactivates. `npm run verify:window-a11y`.
863
+ - **Behavior:** close box click → `vf-close` (does NOT remove itself; consumer
864
+ decides). Zoom box click → `vf-zoom`. If `movable`: dragging the title bar
865
+ moves the window — the drag seeds its origin from the current offset position
866
+ (once, converting to system px) and then states `left`/`top` via pointer
867
+ capture, in whole system px like any authored placement. If `resizable`:
868
+ dragging the grow box states `width`/`height` the same way.
869
+ - **Slots:** default (body content).
870
+ - **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `body`,
871
+ `grow-box`, plus `viewport` re-exported from the built-in scroll area when
872
+ `scrollbars` is set.
873
+ - **Events:** `vf-close`, `vf-zoom` (detail `{}`).
874
+
875
+ #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
876
+ The modal-dialog shell: movable modal by default (see "Format" screenshot,
877
+ striped title bar over a white body), the dBoxProc modal dialog box with
878
+ `frame="plain"` (see the Group A recipe table).
879
+ - **Attributes/props:** `open: boolean` (reflect), `heading: string`,
880
+ `top: number` / `left: number` (whole system px, in **viewport** coordinates
881
+ — see §1 Explicit placement; unset means centered),
882
+ `width: number` / `height: number` (**declare them both** — whole system px,
883
+ the same fixed box `vf-window` is. A native `<dialog>` is `width: fit-content`
884
+ measured against the space left beside its own offsets, and stating an offset
885
+ is how the movable modal is positioned, so an undeclared dialog squeezes
886
+ itself and reflows as it is dragged toward an edge. The two fall back
887
+ differently — width
888
+ to 260 system px, height to the content — and it names whichever are missing,
889
+ once, on the open that first shows it), `label: string` (accessible name for a
890
+ dialog with no `heading`),
891
+ `closable: boolean` (default **false** — the bare movable-modal bar; the
892
+ close box is opt-in because the HIG's Chapter 6 text denies a movable modal
893
+ one while its Figure 5-1 grants it — the parameter enables either reading),
894
+ `frame?: 'plain'`.
895
+ - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap).
896
+ `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both
897
+ directions. Drag the title bar to move it (shared `DragController` with
898
+ `vf-window`), stating `top`/`left` in system px; drags starting on
899
+ the close widget are ignored (same composedPath guard as `vf-window`).
900
+ Escape → close + `vf-close` detail `{ reason: 'escape' }`;
901
+ close box/programmatic/close() → `{ reason: 'close' }`. No backdrop dimming:
902
+ `::backdrop { background: transparent; }`.
903
+ **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`):
904
+ HTML's dialog removing steps skip the close algorithm entirely, which is
905
+ exactly what a framework unmount does — the teardown routes through the same
906
+ native-`close` funnel (`vf-close` fires on the removed element; nothing
907
+ bubbles, it left the tree), `open` and the written origin reconcile so a
908
+ re-append mounts closed, and focus returns to the element focused at open
909
+ time. **The placement re-settles while open** whenever the dialog's own box
910
+ resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving
911
+ under zoom — the viewport resizes, or the scale changes. What survives
912
+ depends on how the modal got where it is: an unplaced one re-centers (it
913
+ never claimed a spot, and the stranded case is exactly this one), while a
914
+ dragged or authored origin is kept and only re-clamped on screen.
915
+ - **Visual (default chrome):** `vfChromeFrame` + `vfTitleBar` (§4) — literally
916
+ the same two recipes `vf-window` uses, so the bar is identical by
917
+ construction (stripes + centered title) rather than by matching copies. It
918
+ takes the default `--vf-title-inset` (16px) — 60px when `closable`, the same
919
+ clearance as `vf-window`, since the centered title needs symmetric room for
920
+ the widget — and sets `touch-action: none` unconditionally, having no
921
+ immovable state. `closable` renders the shared close box (`vfWindowWidgets`
922
+ + `closeBox()` — byte-identical to `vf-window`'s, per the
923
+ `moveable modal dialog.png` reference). Body is WHITE
924
+ (`--vf-surface: #fff`), separated from title bar by 1px black line,
925
+ `padding: 16px`. An optional `buttons` slot renders a bottom-right
926
+ `vf-button-group` footer that only takes space when populated (equal-width,
927
+ faces aligned).
928
+ Both chromes are full-height flex columns and the body takes the slack, for
929
+ the same reason `vf-window`'s frame is: the declared `height` lands on the
930
+ `<dialog>` (see `dialogSize`), and the recipes are skin only. The frame is
931
+ the flex child of the `<dialog>` itself (`dialog[open]` is a flex column in
932
+ `modalDialogStyles` — `[open]`-scoped, or it would out-cascade the UA's
933
+ `dialog:not([open]) { display: none }`), not a `height: 100%` block: a
934
+ percentage can't resolve against the undeclared-height dialog that only the
935
+ UA's `dialog:modal` max-height caps, and that spill was how a viewport-tall
936
+ modal stranded its buttons off-screen. **The box never grows, but
937
+ over-stuffed content scrolls instead of clipping**: the body is a flex
938
+ column of a `.content` scroll region (heading + default slot; part
939
+ `content`) over the pinned footer. While the content fits, nothing matches —
940
+ rendering is pixel-identical to the old block flow. Once it overflows
941
+ (`ScrollStateController`, the always-a-rail machinery), the region reserves
942
+ the 16px channel as its own right padding, wears the drawn `vfScrollRail`
943
+ rail (§4) boxed by a 1px `.scroll-frame` overlay, and becomes a keyboard
944
+ stop (`tabindex="0"`, `role="group"`, the kit's dotted ring) so the copy is
945
+ scrollable without a pointer. The rail rides the wrapper as an overlay
946
+ pinned to its right edge, deliberately out of the layout flow: a rail
947
+ *column*'s two fixed 15px arrow cells would hand the region a 32px minimum
948
+ height, and a short dialog would then measure as fitting with the rail
949
+ shown and overflowing without it — flip-flopping forever. The drop-open
950
+ exemption is unchanged.
951
+ - **The CSS and the controller state the same thing.** `.content` is
952
+ `overflow-y: hidden` until the controller flags overflow, then
953
+ `overflow-y: scroll`. It is deliberately never `auto`: the controller
954
+ ignores the body face's negative half-leading (`LEADING_SPILL_SYS`,
955
+ src/scroll-state.ts — `vf-paragraph` sets a 12-system-px line box under a
956
+ 16-system-px em, so the inline box spills 2 inkless system px past the
957
+ block box and `scrollHeight` counts it), and `auto` has no way to know
958
+ that. With `auto` the two disagreed, and a fixed info dialog whose copy
959
+ ends in a `vf-paragraph` rubber-banded under the wheel with no rail
960
+ shown — 6 CSS px at scale 3. `hidden` still scrolls
961
+ programmatically, so `scrollIntoView` on a focused control is unaffected.
962
+ `npm run verify:contract`, OVERFLOW group.
963
+ - **Visual (`frame="plain"`):** `vfModalFrame` (§4 — 1px outer, 2px gap, 2px
964
+ inner band, no shadow, per `Windows/modal dialog.png`), no title bar, and
965
+ immovable like the original dBoxProc dialog (nothing renders a drag handle).
966
+ A `heading` renders as a centered display-face heading at the top of the
967
+ body (`margin-bottom: 16px`) — the way those dialogs drew their title in
968
+ content — and `closable` is ignored, there being no bar to carry the widget.
969
+ - **A11y:** named by its own title patch — or, on the plain frame, its
970
+ body-top heading; both carry `id="title"` — via `aria-labelledby` when
971
+ `heading` is set. With no heading there is nothing to point at —
972
+ `aria-labelledby` would resolve to an empty node and leave the dialog
973
+ unnamed — so it falls back to `aria-label`, taking `label` if given and
974
+ otherwise `'Dialog'`. An explicit `label` wins over `heading`. The close box
975
+ is labeled `Close ${heading}` like `vf-window`'s.
976
+ - **Slots:** default, `buttons`.
977
+ - **Parts:** `frame`, `title-bar` (default chrome), `title`, `close-box`
978
+ (when `closable`), `body`, `content`, `footer`, `buttons`.
979
+ - **Events:** `vf-close`.
980
+
981
+ #### The alert box (composed — no component)
982
+ System 7's fixed modal alert (double frame: 2px outer, 2px gap, 1px inner
983
+ rule, *with* the hard shadow) is deliberately not shipped as a component.
984
+ What separates an alert from a modal dialog is a *picture* — the 32×32
985
+ icon — and pictures are the consumer's assets, never the library's (see the
986
+ glyph-sprites note at the top of this spec). An alert box is composed from
987
+ the shells above: `vf-dialog frame="plain"` with `label` stated (there is no
988
+ title bar to name it), a row `vf-stack` slotting the consumer's own 32×32
989
+ art through `vf-img`, display-face copy (System 7 alerts used chrome type),
990
+ and the `buttons` slot. The showcase and blog demos compose theirs from
991
+ `demo/icons/alert.png`, and the reference page
992
+ carries the live recipe.
993
+
994
+ #### `vf-separator` (`VfSeparator`, vf-separator.ts)
995
+ - 1px black rule. `vertical: boolean` attr → 1px wide, auto height.
996
+ Horizontal default: `display: block; height: 1px; background: var(--vf-black, #000);`
997
+ When used inside menus it should render as the classic dimmed **dotted**
998
+ rule spanning the full panel width (see Menus.png) — implement via
999
+ `--vf-separator-color` + `--vf-separator-style` custom props (menu panel sets
1000
+ them to `#C0C0C0` / `dotted`, and spends a full `--vf-menu-row-height` on
1001
+ the divider: 8px margin above the rule, 7 below). `role="separator"`.
1002
+
1003
+ ### Group B — buttons & toggles
1004
+
1005
+ #### `vf-button` (`VfButton`, vf-button.ts)
1006
+ - **Attributes/props:** `variant?: 'default'` (the double-ring default button,
1007
+ e.g. "Install"),
1008
+ `disabled`, `type: 'button' | 'submit' | 'reset'` (reflected; default
1009
+ `'button'`), plus the submission overrides `formaction`, `formenctype`,
1010
+ `formmethod`, `formnovalidate` and `formtarget`, each honored on
1011
+ `type="submit"` only, as HTML honors them — they are handed to the native
1012
+ proxy below, so the behavior is the platform's rather than an emulation.
1013
+ (`formAction`'s *getter* returns the string as set, where the native IDL
1014
+ returns it resolved against the base URL; the submission resolves normally.)
1015
+ `type` is read the way HTML reads an enumerated attribute — ASCII
1016
+ case-insensitively, unrecognized values falling to the default — so
1017
+ `type="SUBMIT"` is a submit button. Two deliberate departures: HTML's
1018
+ missing-value default is `submit` and this one is `button`, because an
1019
+ element that silently submits the form it sits in is surprising; and the
1020
+ invalid-value default follows the missing one rather than HTML's `submit`,
1021
+ so a misspelling does nothing instead of submitting.
1022
+ - **Visual:** inner `<button>`: height `var(--vf-button-height, 20px)`,
1023
+ `min-width: 64px`, `padding: 0 14px`, bold black text, font per tokens.
1024
+ The button is 20px, not the fields' 22px: both 1x sheets measure the face at
1025
+ 80×20, and the default ring's *inner* box is exactly that, so the ring traces
1026
+ assume a 20px face.
1027
+ The label rides in its own `.label` span (the flex item, shrink-wrapped to
1028
+ the text) so the focus rule below can span the text rather than the padded
1029
+ face.
1030
+ The rounded rect is NOT `border-radius` (which antialiases): the button
1031
+ paints no box of its own; two pseudo-element layers carry stepped
1032
+ `clip-path` silhouettes traced from the reference sheet (`src/pixel-frame.ts`,
1033
+ traced from the 1x button reference sheet; `npm run verify:buttons` checks
1034
+ the compiled polygons render those profiles faithfully): `::before` fills `var(--vf-black)` clipped to the
1035
+ outer silhouette (corner insets `[3,1,1]`, then straight), `::after` fills
1036
+ `var(--vf-white)` clipped to the face (row 1 corner insets `[3,2]`, then 1px
1037
+ inside) — the 1px frame, corner steps included, is the QuickDraw-style
1038
+ difference of the two. Clip-paths stay off the `<button>` so the hit area
1039
+ stays a plain rectangle and anything painted outside the silhouette isn't
1040
+ swallowed. All coordinates are `calc(var(--vf-scale,1) * Npx)`
1041
+ system pixels, so edges land on whole device pixels and never antialias.
1042
+ - `:active` (pressed, not disabled): invert — the `::after` face flips to
1043
+ black, white text.
1044
+ - `:focus-visible`: **no ring** — the UA outline is off on both the inner
1045
+ button and the (`delegatesFocus`) host, replaced by `vfFocusUnderline`
1046
+ (§4) on the label: a 1px dashed rule 1px below the baseline, i.e. row 15
1047
+ of the 20px face, one blank row under the glyph ink. It inverts with the
1048
+ label when pressed; the controls that cannot carry the mark on their own
1049
+ face keep the dotted ring. `npm run verify:focus` and the focused button in
1050
+ `npm run shot:verify` assert the rendered pixels.
1051
+ - `disabled`: only the label dims to `var(--vf-disabled, #c0c0c0)`; the 1px
1052
+ black border stays black. (For `variant="default"`, the fat outer ring
1053
+ dims to `var(--vf-disabled)` while the inner black border stays — the ring
1054
+ and the title dim together, the button's own frame does not. System 7
1055
+ dimmed both with a 50% stipple; flat gray standing in for it is the kit's
1056
+ liberty, per §1.) The ring rule is keyed on **both** disabled routes —
1057
+ `[disabled]` and `:state(form-disabled)` — as separate rules: an ancestor
1058
+ `<fieldset disabled>` arrives through `formDisabledCallback` and sets no
1059
+ attribute, and keyed on the attribute alone it left a solid black ring
1060
+ around a greyed label.
1061
+ - `variant="default"`: the ring is a host `::before` at
1062
+ `inset: -4px` — `background: var(--vf-black)` clipped by an `evenodd`
1063
+ donut polygon (outer corner insets `[5,3,2,1,1]`; hole opens at row 3 with
1064
+ insets `[6,4,4]`, then 3px inside). The band is 3px thick with a 1px
1065
+ fully transparent gap to the button, per the reference's alpha-0 gap
1066
+ pixels (host needs `position: relative` and 4px breathing room via margin,
1067
+ tokenized `--vf-button-ring-margin` so `vf-button-group` can zero it).
1068
+ - **Group hooks:** the ring margin reads `--vf-button-ring-margin` (default
1069
+ `4px`) and the inner button's flex reads `--vf-button-flex` (default
1070
+ `0 1 auto`); `vf-button-group` sets these to `0` and `1 1 auto` so grouped
1071
+ faces align and stretch to a shared width. Standalone, both defaults are inert.
1072
+ - **Behavior:** form-associated. `type="submit"` submits by inserting a
1073
+ transient native proxy button carrying this button's `name`/`value` and any
1074
+ `form*` override, then clicking it — a form-associated custom element can't
1075
+ be a native submitter itself (`requestSubmit(vfButton)` throws a `TypeError`,
1076
+ "not a submit button"), and a bare `requestSubmit()` would submit with
1077
+ `event.submitter === null` and no name/value; `reset` → the same proxy path.
1078
+ Enter/Space work
1079
+ natively via the inner button, and the host overrides `click()` to forward
1080
+ to it (a click dispatched at the host propagates up, never down into the
1081
+ shadow tree; before the first render there is no inner button, so it falls
1082
+ back to dispatching at the host, since a native `click()` always fires) —
1083
+ which is also how the fields' Enter reaches this button as
1084
+ the form's default button (see `requestImplicitSubmit`, text-control.ts;
1085
+ its `vf-button` leg lowercases `type` for the enumerated rule above).
1086
+ - **When the action runs.** At the *end* of the click's propagation, not
1087
+ where the listener sits. HTML runs a button's activation behavior once the
1088
+ click has finished propagating, which is what lets `preventDefault()` on
1089
+ the button cancel the submission; this component's listener is on the
1090
+ inner `<button>`, first on that path rather than last, so acting there beat
1091
+ every listener a consumer could write and only a capture-phase cancel ever
1092
+ landed. It now defers by adding a listener to the window *during* dispatch
1093
+ (each node's listener list is read as that node is reached), so both
1094
+ phases cancel. `stopPropagation()` cancels nothing, per HTML — the event
1095
+ then never reaches the window and a task picks the action up instead; a
1096
+ task and never a microtask, which would interleave between the listeners
1097
+ of a trusted dispatch.
1098
+ - **Submitter identity.** `event.submitter` can never *be* the `vf-button` —
1099
+ the platform forbids it. It is the proxy, which is parented to the host
1100
+ and carries a `slot` name no shadow root offers: unslotted, so it is never
1101
+ rendered, measured or in the a11y tree, and off the flattened tree so its
1102
+ own click can't travel back up through the shadow `<button>` the click
1103
+ handler is bound to. Being a light-DOM descendant is what makes
1104
+ `event.submitter.closest('vf-button')` resolve to the component — the
1105
+ closest to identity the platform allows. Its own click is stopped at the
1106
+ proxy, so one press is one click at the form (`stopPropagation` leaves the
1107
+ activation behavior alone).
1108
+ - **Name:** the slotted label, with a host-level `aria-label`/`aria-labelledby`
1109
+ bridged to the inner button when present (see §4's name bridge); a
1110
+ `<label for>` deliberately doesn't name it. `description` and a host-level
1111
+ `aria-describedby` reach it through the same shadow-internal span the fields
1112
+ use.
1113
+ - **Slots:** default (label). **Parts:** `button`.
1114
+ - **Events:** none custom (native `click` suffices).
1115
+ - **Verified by:** `npm run verify:button` (the form contract, the name
1116
+ bridge, the enumerated `type`, both disabled routes and the pinned metrics);
1117
+ `verify:buttons` (the clip-path traces), `verify:focus` (the dashed rule).
1118
+
1119
+ #### `vf-button-group` (`VfButtonGroup`, vf-button-group.ts)
1120
+ - **Attributes/props:** `vertical` (stack in a column instead of a row),
1121
+ `natural` (let each button keep its own content width; off by default, so
1122
+ grouped buttons are uniform width — the classic System 7 dialog behavior).
1123
+ - **Visual:** `display: inline-grid`, shrink-wrapped to its buttons. Uniform
1124
+ (default): one auto column per button, all `grid-auto-columns: 1fr`, so under
1125
+ the shrink-wrapped grid they equalize to the widest button's intrinsic width;
1126
+ `align-items: center` puts every face on one baseline. Gap is
1127
+ `--vf-button-group-gap` (default 12px; see the §3 note on values under 8px).
1128
+ `vertical` switches to `grid-auto-flow: row` (a single column sized to the
1129
+ widest, each button stretched to it). `natural` falls back to `inline-flex`
1130
+ so the columns don't equalize.
1131
+ - **Face alignment:** a `variant="default"` button reserves its ring
1132
+ with a 4px `--vf-button-ring-margin` margin, so an ad-hoc flex row lines up the
1133
+ *ring*, not the button. The group sets that margin to `0` and reserves the
1134
+ ring space itself as 4px (`RING_INSET`) padding, then centers the cross axis —
1135
+ so button *faces* align and equalize, not margin boxes. Buttons fill their
1136
+ column via the inherited `--vf-button-flex`. Pure CSS; no measurement.
1137
+ - **Layout-neutral:** shrink-wraps to its buttons; the parent positions it — a
1138
+ `<vf-stack fill-width place="end">` around it is the kit's own bottom-right
1139
+ action row, and `justify-self: end` does it in page CSS. `vf-dialog` wraps
1140
+ its `buttons` slot in one.
1141
+ - **Slots:** default (vf-button elements). **Parts:** none. **Events:** none.
1142
+
1143
+ #### `vf-swatch` (`VfSwatch`, vf-swatch.ts)
1144
+ The color-swatch button: a well of solid color — a palette cell.
1145
+ - **Attributes/props:** `color?: string` (the fill — a CSS color, typically
1146
+ hex; unset shows the transparency checker, and a translucent value layers
1147
+ over that checker so partial opacity reads as partial), `width` / `height`:
1148
+ number (the border box, whole system px; default 24×18), `shadow`: boolean
1149
+ (default **false** — cast the kit's hard drop shadow), `label: string`
1150
+ (accessible name; defaults to `color`, or "transparent"), `disabled`.
1151
+ - **Visual:** inner `<button>` sized `width × height`: 1px black border, 1px
1152
+ white inset (the button's own padding + background) and a `fill` span
1153
+ carrying the checker as an exact fill with the color painted over it in a
1154
+ `tint` child (see *Tiled fills*); `--vf-swatch-checker` overrides the
1155
+ pattern.
1156
+ - `shadow`: adds the shared hard shadow (`vfHardShadowDecls` — the same
1157
+ `--vf-shadow-offset` token as windows and menus, painting outside the box
1158
+ like theirs). **Opt-in, because the swatch's usual home is a table of
1159
+ them** (a `vf-grid`, a picker row), where every cell shadowing its
1160
+ neighbour reads as noise rather than depth; the lone well standing in for
1161
+ a current color is the case that wants the raised reading. The depth in
1162
+ play is resolved once into a private `--_shadow-depth` (`0px` unset), so
1163
+ the focus rule below composes what is actually there.
1164
+ - `:active` (pressed, not disabled): the white inset inverts to black — the
1165
+ inset counterpart of vf-button's face inversion.
1166
+ - `:focus-visible`: **no ring** — the UA outline is off on both the inner
1167
+ button and the (`delegatesFocus`) host, replaced by `vfFocusUnderline`
1168
+ (§4) below the whole box: a `calc()` offset that clears the 1px border and
1169
+ `--_shadow-depth`, then leaves the blank row — so the rule lands one row
1170
+ under a flat swatch's border and one under a shadowed one's shadow. Not
1171
+ inside the box the way vf-button underlines its label — every pixel in
1172
+ there is the color the swatch exists to show, and a rule over the fill
1173
+ would read as part of it. Spans the border box (±1px off the padding box
1174
+ the pseudo-element sizes to). `npm run verify:focus` checks both depths.
1175
+ - `disabled`: interaction stops; nothing dims. The kit dims *labels* when
1176
+ disabled, and a swatch's only label is its fill, which must keep reading
1177
+ as its color.
1178
+ - **Behavior:** "basically a button": native `click` retargets to the host,
1179
+ Enter/Space activate via the inner button, `delegatesFocus`. `label` feeds
1180
+ the inner button's `aria-label`, so `vf-label for` reaches it like every
1181
+ control. Form-associated (`VfFormControl`) **for the disabled contract
1182
+ alone**: an ancestor `<fieldset disabled>` reaches the palette through
1183
+ `formDisabledCallback` (and `:state(form-disabled)`) like every other
1184
+ control — but it submits nothing (a palette cell picks, it doesn't submit;
1185
+ no value is ever set, so no `FormData` entry and no `formResetCallback`).
1186
+ Carries a `ScaleController` and a `GridSnapController`.
1187
+ - **Slots:** none. **Parts:** `button`, `fill`. **Events:** none custom.
1188
+
1189
+ #### `vf-checkbox` (`VfCheckbox`, vf-checkbox.ts)
1190
+ - **Attributes/props:** `checked`, `disabled`, `name`, `value` (default `'on'`).
1191
+ - **Visual:** 13×13 white box, `1px solid black`, no radius; checked = classic
1192
+ ✕: the pixel-exact corner-to-corner cross from the sprite sheet, rendered as
1193
+ the `CHECKBOX_X` inline-SVG fill path (`shape-rendering: crispEdges`,
1194
+ `fill: currentColor`) — no anti-aliased strokes. Label (slot) sits right with
1195
+ 6px gap, bold. Disabled: only the label dims to `var(--vf-disabled, #c0c0c0)`;
1196
+ the box border and ✕ glyph stay black. Pressed (`:active` on box): border
1197
+ thickens to 2px (classic press feedback).
1198
+ - **Behavior:** form-associated; toggles on click and Space; `role="checkbox"`,
1199
+ `aria-checked`; focusable (tabindex 0 on the host).
1200
+ - **When the toggle runs.** At the *end* of the click's propagation, like a
1201
+ native checkbox's activation behavior — so `preventDefault()` on the
1202
+ control or anything above it stops the state change, in either phase.
1203
+ `stopPropagation()` cancels nothing (HTML doesn't), and the disabled gate
1204
+ sits at the far end of the deferral, so a control disabled *during*
1205
+ propagation never acts. Space synthesises a cancelable click and inherits
1206
+ all of it. Shared with `vf-radio` through `VfToggleControl`, and with
1207
+ `vf-button` and the menus through `deferActivation` (src/events.ts).
1208
+ `npm run verify:toggle`, CANCELLATION group.
1209
+ `:focus-visible` marks
1210
+ the **box**, not the label and not either with a ring: `vfFocusUnderline`
1211
+ (§4) at `--vf-focus-underline-offset: -3px`, a dashed rule spanning the
1212
+ well's full 13px, one blank row under its border. The −3 and the ±1px width
1213
+ growth both count that 1px border, which an absolutely positioned pseudo
1214
+ sizes inside of. `npm run verify:focus`.
1215
+ - **Slots:** default (label). **Parts:** `box`, `label`.
1216
+ - **Events:** `vf-change` detail `{ checked: boolean }`, plus the native
1217
+ `input`/`change` pair per user toggle (§2).
1218
+
1219
+ #### `vf-radio` (`VfRadio`, vf-radio.ts)
1220
+ - **Attributes/props:** `checked`, `disabled`, `value: string`.
1221
+ - **Visual:** 13×13 pixel circle drawn as inline SVG — the hand-tuned 1-bit
1222
+ `RADIO_RING` outline over a white `RADIO_FACE` disc (not `border-radius`, which
1223
+ anti-aliases); checked = the centered `RADIO_DOT` pixel disc. Pressed
1224
+ (`:active`): the ring swaps to `RADIO_RING_PRESSED` (2px-thick). Label right,
1225
+ 6px gap. Disabled dims like checkbox (label only; ring + dot stay black).
1226
+ - **Behavior:** `role="radio"`, `aria-checked`. Click → asks parent group to
1227
+ select it (dispatch internal event or parent listens). NOT itself
1228
+ form-associated — the group is. Selection defers to the end of the click's
1229
+ propagation and is cancellable exactly as `vf-checkbox`'s is (same
1230
+ `VfToggleControl` skeleton). `:focus-visible` marks the **circle** with
1231
+ `vfFocusUnderline` (§4) at `--vf-focus-underline-offset: -2px` — one blank
1232
+ row below the same 13px well the checkbox uses, so the two rules share a row
1233
+ in a mixed list (the −1 difference is only that this well has no border).
1234
+ The 12px sprite sits half a system px proud of the well, so the gap to the
1235
+ circle itself reads as one row or two depending on how that rounds; the well
1236
+ is the anchor. The rule is also narrowed to **9** of the well's 13px (2px
1237
+ inset each side, 5 dashes): full width reads wider than the round shape above
1238
+ it, and 9 is the closest to two thirds that keeps whole-px insets and ink at
1239
+ both ends. Focus: inside a `vf-radio-group` the group
1240
+ owns the roving tabindex and is the single source of truth for `checked`;
1241
+ standalone, the radio self-manages its own tabindex (otherwise it would be
1242
+ keyboard-dead) and self-checks on activation. A consumer-authored `tabindex`
1243
+ always wins, and that ownership is latched on first connect so a reconnect
1244
+ can't mistake our own write for the consumer's.
1245
+ - **Slots:** default (label). **Parts:** `circle`, `label`.
1246
+ - **Events:** `vf-change` detail `{ value }` (fired by user interaction only).
1247
+
1248
+ #### `vf-radio-group` (`VfRadioGroup`, vf-radio-group.ts)
1249
+ - **Attributes/props:** `value: string`, `name`, `disabled`, `label: string`
1250
+ (accessible name → `internals.ariaLabel`).
1251
+ - **Visual:** `display: block`; slotted radios stack with 6px vertical gap
1252
+ (consumer can override with own layout). No chrome of its own.
1253
+ - **Behavior:** form-associated (form value = `value`). `role="radiogroup"`.
1254
+ Keeps children in sync: sets `checked` on the child whose `value` matches.
1255
+ Roving tabindex; ArrowUp/ArrowLeft & ArrowDown/ArrowRight move selection AND
1256
+ select (classic Mac behavior). Child click updates group `value`.
1257
+ - **Slots:** default (vf-radio elements, or arbitrary markup containing them).
1258
+ - **Events:** `vf-change` detail `{ value }`, plus the native `input`/`change`
1259
+ pair per user pick (§2) — fired from the group, the form-associated surface,
1260
+ not the radio.
1261
+
1262
+ ### Group C — text & value inputs
1263
+
1264
+ #### `vf-text-field` (`VfTextField`, vf-text-field.ts)
1265
+ - **Attributes/props:** `value`, `placeholder`, `disabled`, `readonly`,
1266
+ `type: string` (default `'text'`; pass through to input), `name`. The
1267
+ input-behavior attributes — `autocomplete`, `inputmode`, `enterkeyhint`,
1268
+ `maxlength`, `pattern`, `spellcheck`, `autocapitalize` — are **forwarded
1269
+ verbatim** from the host onto the inner input (observed attributes, not
1270
+ reactive properties: four of them are globals with IDL accessors already on
1271
+ `HTMLElement`, and a Lit `@property` would shadow the platform member — the
1272
+ `align`/`draggable` trap). The platform only honors them on the element that
1273
+ actually takes the input, which is in the shadow root.
1274
+ - **Visual:** inner `<input>`: white bg, `1px solid black`, NO radius,
1275
+ height `var(--vf-control-height, 22px)`, `padding: 0 6px`, font tokens but
1276
+ `font-weight: var(--vf-font-weight, 700)`. `user-select: text`. Focus: for a
1277
+ **keyboard** focus, the kit's 1px dashed rule (`vfFocusUnderline`, §4) one
1278
+ blank system px row under the well — no dotted outline and no thickened
1279
+ border. A click leaves it unmarked: the insertion point is already the
1280
+ answer to where focus went (see §4 on why this can't be `:focus-visible`).
1281
+ Disabled: the text dims
1282
+ to gray; the black border stays. Selected text inverts to solid
1283
+ black-on-white (`.vf-field::selection`, using
1284
+ `--vf-highlight`/`--vf-highlight-text`) — the 1-bit System 7 selection, shared
1285
+ by all three editable fields via the `vfField` skin.
1286
+ - **Structure:** the `<input>` sits in a `.vf-field-well` wrapper, which is what
1287
+ the focus rule hangs from and what carries `vf-snap`. A replaced element draws
1288
+ no pseudo-element of its own, and the host is never the thing grid snapping
1289
+ moves, so the wrapper is the only box that is both the well's exact shape and
1290
+ on the corrected grid. Same wrapper in all three fields, assembled by
1291
+ `VfTextControlBase.wellClass` so the focus gate can't drift between them.
1292
+ Two consequences when embedding: the rule paints 2 system px
1293
+ **below the host's own box** (`pointer-events: none`, so it never takes a
1294
+ click meant for what sits under it, but a tight `overflow: hidden` ancestor
1295
+ clips it), and a `width` set on `::part(input)` sizes the control without
1296
+ moving the wrapper the rule spans — a field's width belongs on the host or
1297
+ on `--vf-field-width`.
1298
+ - **Behavior:** form-associated; syncs `value` on input; `formResetCallback`
1299
+ restores default. A plain Enter runs the form's **implicit submission** the
1300
+ way HTML defines it — activating the form's default button (first submit
1301
+ button in tree order, `vf-button` included), so the submission carries a
1302
+ real `submitter` and that button's `name`/`value`, a disabled default
1303
+ button submits nothing, and only a form with no submit button falls back to
1304
+ a bare `requestSubmit()` (`requestImplicitSubmit`, text-control.ts).
1305
+ - **Parts:** `input`.
1306
+ - **Events:** `vf-input` detail `{ value }` on every keystroke; `vf-change`
1307
+ detail `{ value }` on commit (native change). Plus the native pair per §2:
1308
+ the inner input's own composed `input` crosses the boundary itself; `change`
1309
+ is re-dispatched from the host.
1310
+
1311
+ #### `vf-text-area` (`VfTextArea`, vf-text-area.ts)
1312
+ Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number`
1313
+ (default 4). No resize grip (`resize: none`) — System 7 fields don't resize.
1314
+ Wrapped entry text sits on the display face's native line
1315
+ (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) —
1316
+ the single-line well's 20px box is control geometry, the 22px field trace, and
1317
+ stays; a multi-line well is typesetting.
1318
+ Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail"
1319
+ behavior — see vf-scroll-area): arrows on an empty white channel until the text
1320
+ overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail`
1321
+ subtree (§4), a shadow sibling of the `<textarea>` synced to its native
1322
+ scrolling by `ScrollRailController`; the textarea carries the `vf-scroll`
1323
+ class (native bar hidden), and both controllers re-measure on each keystroke
1324
+ (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field,
1325
+ the 1px frame sits on the `.vf-field-well` wrapper rather than the field
1326
+ itself — the rail has to live inside the frame beside the text — with the
1327
+ borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding (plus
1328
+ the border-floor compensation, a `mod()` term restoring what engines floor
1329
+ off a fractional border-width), so the text and the outer box sit exactly
1330
+ where the bordered field puts them. The well is the same wrapper
1331
+ vf-text-field uses; the focus rule spans the full frame, scroll rail
1332
+ included (the `-3px` bordered-carrier offset, §4 vfFocusUnderline). Forwards
1333
+ the same input-behavior attributes as vf-text-field, minus `pattern` (only an
1334
+ `<input>` takes it).
1335
+ Parts: `textarea`. Events: `vf-input`, `vf-change`, plus the native pair (§2).
1336
+
1337
+ #### `vf-number-field` (`VfNumberField`, vf-number-field.ts)
1338
+ A numeric text field paired with the classic "little arrows" stepper.
1339
+ - **Attributes/props:** `value: string`, `min?: number`, `max?: number`,
1340
+ `step: number` (default 1; also sets the value's decimal precision),
1341
+ `placeholder`, `disabled`, `readonly`, `name`, `label`.
1342
+ - **Visual:** a form-associated `<input>` (white well, 1px black border, value
1343
+ right-aligned) with a 3px gap to the little-arrows stepper. Focus draws the
1344
+ dashed rule under the well like `vf-text-field` — the `.vf-field-well`
1345
+ wrapper is the flex item, so the rule stops at the well and never runs under
1346
+ the stepper, which is not where the insertion point is. The stepper is the `STEPPER` glyph (rounded
1347
+ 1-bit frame + hollow up/down arrows from `Little arrows.png`), rendered inline
1348
+ at its **native 15×25** so it stays pixel-crisp. The well and the sprite are
1349
+ authentically different heights (the reference sheets measure fields at 22 and
1350
+ the arrows at 25), so the well keeps `var(--vf-control-height, 22px)` — lining
1351
+ up with every sibling control — and the taller sprite sets the host's height.
1352
+ The odd 3px remainder is biased a whole pixel (1 above the well, 2 below)
1353
+ rather than centered, which would place it on a half pixel and fringe at every
1354
+ scale. Holding an arrow overlays its solid fill (`STEPPER_UP_FILL`
1355
+ / `STEPPER_DOWN_FILL`, synthesized to match the kit's hollow→filled press
1356
+ convention). Disabled: the value dims to gray; the box and stepper stay black.
1357
+ - **Behavior:** `role="spinbutton"` on the input with `aria-valuenow/min/max`.
1358
+ Clicking an arrow steps by `step`, clamped to `min`/`max`, rounded to `step`'s
1359
+ precision; press-and-hold autorepeats (300ms delay, then ~60ms). Keyboard:
1360
+ ArrowUp/ArrowDown step, Home/End jump to min/max. Typing is free-form; the
1361
+ value normalizes (clamp + round) on commit (native `change`). Form-associated
1362
+ (submits `value`; `formResetCallback` restores the default). `readonly` blocks
1363
+ stepping and editing.
1364
+ - **Parts:** `input`, `stepper`.
1365
+ - **Events:** `vf-input` detail `{ value, valueAsNumber }` on every keystroke;
1366
+ `vf-change` detail `{ value, valueAsNumber }` on commit or step. Plus the
1367
+ native pair (§2): a step dispatches both from the host (a native spinner's
1368
+ pair — a step has no inner native event at all), a typed commit re-dispatches
1369
+ `change`. Forwards the input-behavior attributes like vf-text-field (no
1370
+ `pattern`); `inputmode`/`autocomplete` default to `decimal`/`off` when the
1371
+ host doesn't say otherwise.
1372
+
1373
+ #### `vf-select` (`VfSelect`, vf-option.ts children) (vf-select.ts)
1374
+ The classic popup menu control ("Macintosh HD ▼").
1375
+ - **Attributes/props:** `value: string`, `disabled`, `name`.
1376
+ - **Children:** `<vf-option value="...">Label</vf-option>` elements (default
1377
+ slot). `vf-option` (`VfOption`, vf-option.ts): props `value`, `disabled`,
1378
+ `selected` (managed by parent); renders its slot; `role="option"`.
1379
+ - **Visual (closed control):** height `var(--vf-popup-height, 18px)`, white
1380
+ bg, `1px solid black`, NO radius, `box-shadow: 1px 1px 0 0 var(--vf-black, #000)`
1381
+ (the small hard shadow visible in the screenshot), `padding: 0 8px 0
1382
+ var(--vf-select-gutter, 16px)` — the left inset equals the option checkmark
1383
+ gutter so the selected label sits at the same x closed or open. *One
1384
+ documented 1px deviation:* the reference sheets put a closed pill's label ink
1385
+ 16px from its border box but an open menu's at 17 (three pill instances and
1386
+ four menu panels, all consistent, in the reference art). System 7
1387
+ reconciled that by drawing the open panel 1px left of the pill; the kit
1388
+ instead uses one shared gutter with no panel offset, which keeps the
1389
+ closed↔open alignment exact and puts the closed pill's ink at 17. Bold label
1390
+ left, the black `CARET_DOWN` ▼ pixel glyph (inline SVG) right with 8px gap. The
1391
+ ▼ stays black even when the control is disabled (only the label dims).
1392
+ **Width:** the control hugs the *widest* option — no intrinsic min-width — via
1393
+ an invisible, height-collapsed stack of every option's text sharing the label's
1394
+ grid cell; so the closed pill and the open panel are always exactly the same
1395
+ width and the value never shifts as the selection changes. Authors wanting a
1396
+ floor set `min-width` on the host (or grow it in their layout, e.g. `flex: 1`).
1397
+ **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at
1398
+ `--vf-focus-underline-offset: -4px`, a dashed rule under the *whole* pill
1399
+ (border + hard shadow + blank row), not inside the face the way vf-button
1400
+ underlines its label: the pill's one line already holds the label and the ▼.
1401
+ Spans the border box (±1px off the padding box the pseudo-element sizes to).
1402
+ **Closed only**, as `vf-menu`'s is: the open list already shows where focus
1403
+ is, and a panel short enough not to cover the rule — a one-option menu
1404
+ overlays the pill exactly — would leave a stray dashed line below it.
1405
+ Gated on a `.vf-focus-rule` class from the page's input modality, not
1406
+ `:focus-visible` — see §4 for why this control can't use the selector either.
1407
+ `npm run verify:focus`.
1408
+ - **Visual (open):** panel uses the `.vf-panel` recipe but overrides
1409
+ `--vf-shadow-offset: 1px` so its hard shadow matches the pill's (not the 2px
1410
+ menu shadow). Positioned `position: fixed` to the control's exact width and
1411
+ left (unsnapped, so the panel's left/right edges and shadow coincide with the
1412
+ pill's). Item rows are height 16px — the pill's *content* height, derived as
1413
+ `calc(var(--vf-popup-height, 18px) - 2px)` so a re-themed pill moves its rows
1414
+ with it — `padding: 0 20px 0 var(--vf-select-gutter, 16px)`;
1415
+ the panel opens with the selected row's cell laid directly over the closed
1416
+ pill (its top border on the pill's top border, extending downward), so the
1417
+ selected label's position and surrounding whitespace are identical closed and
1418
+ open. The currently-selected item shows a ✓ checkmark in the left
1419
+ `--vf-select-gutter` column; hovered/active item inverts (black bg, white
1420
+ text); disabled options gray.
1421
+ - **Visual (open, clipped):** a list taller than the screen is **clipped, never
1422
+ scrolled** — System 7 put no scrollbar on a menu. The panel is `overflow:
1423
+ hidden` and drawn once at a whole number of row slots; the rows ride a
1424
+ `.rows` strip rolled by `transform`, and the edge slot with items beyond it
1425
+ is covered by an opaque white **arrow slot** (`part="scroll-arrow"`,
1426
+ `aria-hidden`) carrying `CARET_UP` / `CARET_DOWN`. Traced from a real System 7
1427
+ popup clipped at the screen edge (Find File's criteria menu under Infinite
1428
+ Mac, 2×): the 11×6 triangle sits 13px in from the panel's content edge and 5px
1429
+ down its 16px row, and the arrow occupies a **full** row slot. The pointer
1430
+ resting on an arrow rolls the list one row per `MENU_SCROLL_INTERVAL_MS`
1431
+ (66ms, motion.ts — the interaction itself, so *not* reduced-motion gated);
1432
+ the arrow retires when its direction runs out. Both can show at once. Four
1433
+ invariants, all in `src/popup-overflow.ts`:
1434
+ - **The clamp is quantized to the pill lattice.** Rows are clipped a whole row
1435
+ at a time off `pillTop − selectedIndex × rowHeight`, so a clipped popup
1436
+ still opens with its selected row exactly over the closed pill — what an
1437
+ un-quantized pixel clamp loses. One deliberate exception: when the selected
1438
+ row would land *under* an arrow (the pill within one row of a screen edge
1439
+ with items beyond it), the scroll shifts by one and the overlay gives way by
1440
+ exactly one row. Rare, and purely geometric.
1441
+ - **The panel is as tall as the *list* asked for, not as tall as the rows it
1442
+ can currently show** — capped only by the screen band. A list that fits the
1443
+ band but not where the pill would put it keeps every slot and slides whole
1444
+ rows, while the item strip stays welded to the pill; the slots the strip no
1445
+ longer reaches are drawn as **empty white**. The blank is the exact travel
1446
+ the list rolls through: scrolling to that end lands the
1447
+ strip flush with the panel, precisely full, both arrows retired. This is
1448
+ what System 7 drew — 5 empty rows above `name` in Find File's criteria
1449
+ popup, 2 above `Athens` in Character Set's font menu. **The two directions
1450
+ are one rule:** a panel pushed up (the pill is low) reserves its blank at the
1451
+ top and rolls down into it; a panel pushed down (the pill is high with a late
1452
+ item selected) reserves it at the bottom and rolls up into it. Only the sign
1453
+ of the scroll integer distinguishes them — it is free to leave the
1454
+ `[0, rowCount − visibleSlots]` range that would fill every slot, and that
1455
+ departure *is* the blank. It follows that the blank is a one-way starting
1456
+ position: an arrow means "rows are really hidden this way", so whichever end
1457
+ the blank is on, the arrow pointing back at it is off.
1458
+ - **The panel box never moves or resizes while open:** scrolling rolls the
1459
+ items inside it.
1460
+ - **Three slots minimum** when overflowing, even if that intrudes on the
1461
+ insets below — with both arrows shown, fewer leaves nothing to pick.
1462
+ The screen-edge band comes from `--vf-popup-inset-top` /
1463
+ `--vf-popup-inset-bottom` (§3), whose 4px defaults reproduce the old viewport
1464
+ margins exactly, so a list that fits is untouched by any of this.
1465
+ `npm run verify:select-overflow`.
1466
+ - **Behavior:** form-associated. Opens on pointerdown (mouse/touch),
1467
+ Space/Enter/ArrowDown, or a synthesised click (assistive tech). Two pointer
1468
+ styles coexist, disambiguated by the gesture and resolved at the first
1469
+ release: **System 7 press-drag-release** (press the pill, drag onto an item,
1470
+ release to pick — releasing on the current item or off the list cancels) and
1471
+ **modern click-to-open** (a quick in-place click leaves the list open for a
1472
+ second click). The only timed distinction is an in-place release — a tap
1473
+ (<200ms) keeps the list open, a held press closes it; any press that travels
1474
+ to another item is a drag-pick regardless of duration. Highlight tracking
1475
+ during a press hit-tests by coordinates (so it works under touch's implicit
1476
+ pointer capture) and never moves DOM focus off the control. Panel is
1477
+ positioned `position: fixed` from `getBoundingClientRect()` so it escapes
1478
+ clipping containers; closes on outside pointerdown, Escape, blur, scroll.
1479
+ Keyboard while open: arrows move active item, Enter/Space select, Escape
1480
+ cancels, Home/End jump, and printable keys run the shared Finder
1481
+ first-letter type-ahead (`src/type-ahead.ts` — the same buffer `vf-list`
1482
+ and the menus use), moving the highlight to the match. On select: classic
1483
+ blink (invert toggles ~3 times in ~250ms) then close + `vf-change`.
1484
+ A press that *begins* on an arrow is neither a pick nor a dismissal (so a
1485
+ click on one leaves the list up); a press-drag *released* on one closes with
1486
+ no change, like any release on a non-item — and while a drag is in an arrow's
1487
+ zone the highlight is dropped, because the arrow row is not an item. That
1488
+ zone reaches past the panel edge in its own direction, the classic ergonomics
1489
+ of slamming the pointer to the screen edge.
1490
+ - **Parts:** `control`, `label`, `arrow`, `panel`, `scroll-arrow`.
1491
+ - **Events:** `vf-change` detail `{ value }`, plus the native `input`/`change`
1492
+ pair per committed pick (§2).
1493
+
1494
+ #### `vf-progress-bar` (`VfProgressBar`, vf-progress-bar.ts)
1495
+ - **Attributes/props:** `value: number` (0–100), `max: number` (default 100),
1496
+ `indeterminate: boolean`, `label: string` (accessible name → host
1497
+ `aria-label`; the only way to name an indeterminate bar, which has no
1498
+ `aria-valuenow`).
1499
+ - **Visual:** `display: block; height: 14px;` track
1500
+ `var(--vf-progress-track, #ffffff)` (white), `1px solid black`, no radius.
1501
+ Determinate fill: `var(--vf-progress-fill, #000000)` (solid black) from left,
1502
+ with a 1px black leading edge
1503
+ line. Indeterminate: full-width animated diagonal black/white barber stripes
1504
+ (45°, a 12px `\` cell whose bands are a *staircase of axis-aligned 1px
1505
+ rects*, not diagonal polygons and not a `repeating-linear-gradient` — a
1506
+ diagonal edge blurs to a gray fringe when scaled, axis-aligned rects stay
1507
+ pixel-exact). The art rides an exact-fill strip inside the fill (see *Tiled
1508
+ fills*), animated by `left` keyframes that advance exactly one whole 12px
1509
+ cell per cycle so the loop wraps seamlessly (no phase-jump seam), ~0.4s
1510
+ `steps(4, end)` infinite — stepped, not smooth; each stepped value
1511
+ is one quantized length, so no CSS length ever carries an accumulating
1512
+ phase. Override the tile via `--vf-progress-stripes`.
1513
+ - **Behavior:** `role="progressbar"` + `aria-valuenow/min/max` (omit valuenow
1514
+ when indeterminate); `label` → host `aria-label`.
1515
+ - **Parts:** `track`, `fill`.
1516
+
1517
+ #### `vf-slider` (`VfSlider`, vf-slider.ts)
1518
+ - **Attributes/props:** `value: number`, `min: number` (default 0), `max: number`
1519
+ (default 100), `step: number` (default 1), `disabled: boolean`, `name: string`,
1520
+ `label: string`.
1521
+ - **Visual:** not a historical System 7 control — a 1-bit reverse-adaptation of
1522
+ the later Mac OS slider. A 4px-tall rounded capsule rail (2px tapered caps)
1523
+ fills **solid black** from the left up to the shield-shaped drag handle (the
1524
+ 11×12 `SLIDER_THUMB` sprite, three grip strokes, pointed bottom) and runs
1525
+ **hollow** (1px top/bottom edge) after it — the classic filled/unfilled track.
1526
+ The rail is a whole-pixel `<svg>` regenerated on resize (so it stays crisp at
1527
+ any width); the thumb snaps to integer pixels. Disabled dims the whole control
1528
+ to `var(--vf-disabled, #c0c0c0)` (the fill *is* the value — there is no label
1529
+ to dim instead). No hover/active state on the handle (static sprite).
1530
+ - **Behavior:** form-associated; `role="slider"` with
1531
+ `aria-valuemin/max/now/valuetext` + `aria-orientation="horizontal"`. Click or
1532
+ drag the track to set the value (the thumb's travel is inset by half its width
1533
+ so its edges stay flush within the rail, never overhanging). Focusable
1534
+ (self-managed `tabindex`): Arrow keys step by `step`, PageUp/PageDown by
1535
+ `max(step, range/10)`, Home/End jump to min/max. `formResetCallback` restores
1536
+ the initial value.
1537
+ - **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at
1538
+ `--vf-focus-underline-offset: 3px`, a dashed rule under the **rail**, its
1539
+ full width, one blank system px row below it. Not around the handle: that
1540
+ marked the value rather than the control, and moved as the value did. The
1541
+ handle is `z-index: 1` and the rule is not, so it occludes the dashes it
1542
+ passes over exactly as it occludes the rail behind it.
1543
+ - Gated on a `.vf-focus-rule` class from the page's input modality, not
1544
+ `:focus-visible` (§4): a press on the track `preventDefault`s to suppress
1545
+ text selection, which cancels the native focus, so the control calls
1546
+ `focus()` itself. It calls the controller's `suppress()` from that same
1547
+ `pointerdown` (a press on an already-focused slider fires no `focusin`) and
1548
+ `reveal()` from a handled arrow key, so a slider grabbed with the mouse and
1549
+ then nudged with the keys starts showing its rule. `npm run verify:focus`.
1550
+ - **Events:** `vf-input` detail `{ value: number }` on every drag move / key
1551
+ change; `vf-change` detail `{ value: number }` on commit (pointer release or
1552
+ key change). Plus the native pair (§2), mapped 1:1 — `input` per user value
1553
+ move, `change` per commit, a native range input's cadence.
1554
+ - **Parts:** `track`, `rail`, `thumb`.
1555
+
1556
+ ### Group D — menus, lists, containers
1557
+
1558
+ #### `vf-menu-bar` (`VfMenuBar`, vf-menu-bar.ts)
1559
+ - **Attributes/props:** `label: string` — accessible name for the menubar,
1560
+ mirrored as host `aria-label` (guarded: a consumer's own
1561
+ `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected)
1562
+ — draws the System 7 screen-corner mask over the bar's top-left/top-right
1563
+ corners.
1564
+ - **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` —
1565
+ 19 white system px over the 1px black rule, the Menus.png bar strip exactly —
1566
+ white bg, `border-bottom: 1px solid var(--vf-black, #000)`, children laid out
1567
+ horizontally from left. Adjacent titles are pulled 5px into each other
1568
+ (`margin-inline-start: -5px` on every slotted menu, absorbed by 14px of bar
1569
+ start padding — 9px of bar before the first plate, as System 7 placed the
1570
+ Apple title, plus the first title's 5px share): Menus.png spaces title ink
1571
+ 14px apart while each title's plate runs 10px left / 9px right of its own
1572
+ ink, so neighboring plates *overlap* by 5px, as the originals did. With
1573
+ `rounded`, two
1574
+ 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs
1575
+ 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in
1576
+ `--vf-black` *over* the bar: on the classic screen the rounding was the
1577
+ ROM's black corner mask sitting on top of the menu bar, not a shape of the
1578
+ bar's own, so it is ink over any backdrop rather than a cutout.
1579
+ - **Behavior:** container/controller for slotted `vf-menu` children. Pressing a
1580
+ menu label → opens it (label inverts while open). While any menu is open,
1581
+ hovering another label switches to it (classic behavior). Escape / outside
1582
+ click / item selection closes. `role="menubar"`, behind a first-connect
1583
+ ownership latch so a consumer's own role survives upgrade; the shadow `.bar`
1584
+ is `role="presentation"` and each slotted `vf-menu` host `role="none"`, so
1585
+ the `menubar → menuitem` ownership chain has no generics in it. While a menu
1586
+ is open: ArrowLeft/Right move between menus, ArrowDown/Up walk the open
1587
+ menu's items, Home/End jump to its first/last enabled item, and printable
1588
+ keys run the shared Finder first-letter type-ahead over the items
1589
+ (`src/type-ahead.ts`; Space stays the focused item's activation key, and the
1590
+ prefix resets on menu switch or close). The bar also **owns the
1591
+ press-drag-release gesture** across its menus (`MenuPressController`,
1592
+ `src/menu-press.ts` — see `vf-menu`), since one press may travel over
1593
+ several of them: it binds the opening `pointerdown` and hands the controller
1594
+ its own open/close rules, so the gesture changes *when* a menu opens, never
1595
+ *how*.
1596
+ - **Slots:** default (vf-menu elements). **Parts:** `bar`.
1597
+
1598
+ #### `vf-menu` (`VfMenu`, vf-menu.ts)
1599
+ - **Attributes/props:** `label: string` (the menu title in the bar; may contain
1600
+ e.g. an apple glyph), `open: boolean` (reflect, managed by menu-bar or self).
1601
+ - **Visual:** label: bold, height of menubar, `padding-inline: 9px 8px` — the
1602
+ label box is the title's black plate and hit rect, and Menus.png puts the
1603
+ plate 10px left / 9px right of the title ink (Chicago carries a 1px bearing
1604
+ on each side inside the text box, so 9/8 in layout lands −10/+9 around
1605
+ ink); open → inverted (black bg / white text), inset one system px
1606
+ top and bottom via transparent `border-block` + `background-clip:
1607
+ padding-box` — the hilite is rows 1..18 of the 20px bar, leaving the bar's
1608
+ top row white and its bottom rule showing through, while the hit rect stays
1609
+ the full bar height. The title itself sits in a `.title` box inside that
1610
+ cell, so the focus rule can span the title and not the padding. In a bar the
1611
+ label is `role="menuitem"`; standalone it is `role="button"` — a collapsed
1612
+ standalone dropdown *is* the APG menu-button pattern, and `aria-haspopup` +
1613
+ `aria-expanded` are already right for it (the host itself is `role="none"`
1614
+ in a bar and role-less standalone, behind vf-menu-item's first-connect
1615
+ ownership latch). Panel: `.vf-panel`, `position: absolute` below the label
1616
+ (`top: 100%; left: 0;`), `padding: 0`, `min-width: 100%` — a menu is as wide
1617
+ as its widest row, the way the MDEF sized it (`Menus.png`'s File pulldown is
1618
+ 141px, its cm/inches popup 94), never narrower than its own bar title;
1619
+ `role="menu"`.
1620
+ - **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at
1621
+ `--vf-focus-underline-offset: -2px`, a dashed rule one blank system px row
1622
+ under the `.title` box. That box is `line-height: 1`, i.e. the face's own
1623
+ em, whose bottom edge is the descent line — so the one offset clears a
1624
+ descender *and* a slotted 16px `vf-img` (the Apple menu), where the
1625
+ button's baseline-anchored rule is crossed by both. In `currentColor`, so
1626
+ it inverts with the title on an open menu's black cell.
1627
+ - **Closed only** (`:host(:not([open]))`). A dropped menu inverts its whole
1628
+ cell, which already shows where focus is; the rule marks the state the
1629
+ inversion can't — focused but not yet open — and drawing both would put a
1630
+ second mark (in white, since it is `currentColor`) under the first. The
1631
+ class stays on through the open state,
1632
+ so the rule returns by itself when the menu closes and hands focus back.
1633
+ - Gated on a `.vf-focus-rule` class from the page's input modality, not
1634
+ `:focus-visible` (§4): `MenuPressController` `preventDefault`s the opening
1635
+ `pointerdown` and calls `focusLabel()` itself. The menu also calls the
1636
+ controller's `suppress()` from its own host `pointerdown`, so a press on an
1637
+ already-focused title — which moves no focus, and so fires no `focusin` —
1638
+ still drops the mark, as does mousing into the dropped panel.
1639
+ `npm run verify:focus`.
1640
+ - **Behavior:** delegates open-state coordination to parent `vf-menu-bar` when
1641
+ present (only one open at a time; standalone, `#requestToggle` doesn't emit
1642
+ the coordination event at all — see the events convention in §2). Keyboard
1643
+ on the label: Enter, Space and ArrowDown all open **and move focus to the
1644
+ first enabled item** (APG, menubar and menu-button patterns alike — opening
1645
+ without entering would park the keyboard on the title with the panel
1646
+ dropped). While open standalone the menu runs its own item keyboard:
1647
+ ArrowDown/Up wrap, Home/End jump, printable keys run the shared first-letter
1648
+ type-ahead (`src/type-ahead.ts`), Escape closes and refocuses the label.
1649
+ Sets `--vf-separator-color: var(--vf-disabled, #c0c0c0)` on its panel so
1650
+ slotted `vf-separator`s render dimmed, each spending one full
1651
+ `--vf-menu-row-height` — the MDEF's divider-as-item — with the rule 8px in
1652
+ (12px under the ink band above, 10 over the one below, per InfiniteMac's
1653
+ System 7.5 Edit menu and the 32px cross-divider ink pitch in `Menus.png`).
1654
+ **Pointer:** the two styles `vf-select` supports, on the same terms and the
1655
+ same `PRESS_HOLD_MS` threshold (`src/motion.ts`) — the menus get theirs from
1656
+ `MenuPressController` (`src/menu-press.ts`), which a standalone menu hosts
1657
+ itself and a `vf-menu-bar` hosts for all of its menus. Opening is on
1658
+ **pointerdown**, and the press is then tracked to its release anywhere:
1659
+ - *System 7 press-drag-release* — press the title, slide onto a command,
1660
+ release over it to run it. Sliding sideways onto another title switches
1661
+ menus mid-press. Releasing over a disabled row, a separator, the title, or
1662
+ off the menu closes with nothing chosen (the classic "release outside").
1663
+ - *Modern click-to-open* — a quick in-place tap (under `PRESS_HOLD_MS`, 200ms)
1664
+ on a title leaves the menu dropped for a second, independent click; a *held*
1665
+ in-place press closes it, and a press on an already-dropped title closes it.
1666
+ Time is consulted only for an in-place release: any press that travels is a
1667
+ drag-pick however long it took.
1668
+ Hit-testing is by **coordinates**, not event target, so tracking survives
1669
+ touch's implicit pointer capture (every move is delivered to the pressed
1670
+ title); the row under the pointer carries `vf-menu-item[active]` rather than
1671
+ relying on `:hover`, which capture defeats. The trailing `click` the browser
1672
+ synthesises after a press is swallowed by the label and by the row (the same
1673
+ guard `vf-select` uses), so a keyboard/assistive-tech click — which arrives
1674
+ with no preceding pointerdown — is the only click that still toggles or
1675
+ activates.
1676
+ - **Slots:** default (vf-menu-item / vf-separator), `label` (replaces the
1677
+ `label` text in the bar — e.g. the Apple menu's `vf-img` apple; the `label`
1678
+ attribute stays set as the accessible name, mirrored to the bar item's
1679
+ `aria-label`). **Parts:** `label`, `panel`.
1680
+
1681
+ #### `vf-menu-item` (`VfMenuItem`, vf-menu-item.ts)
1682
+ - **Attributes/props:** `disabled`, `checked` (shows ✓ in left gutter),
1683
+ `checkable` (declares a toggle up front — see Behavior),
1684
+ `shortcut: string` (e.g. `"⌘H"`, drawn in the left-aligned shortcut
1685
+ column), `value?: string` (defaults
1686
+ to text content), `active` (reflect; the transient press-drag highlight,
1687
+ managed by the menu — mirrors `vf-option[active]`, not an authoring API).
1688
+ - **Visual:** height `var(--vf-menu-row-height, 16px)` — `Menus.png` puts every
1689
+ menu row on a 16px pitch (3px above + the 9px glyph + 4px below), so a
1690
+ pulldown row matches a popup row exactly; the line box is locked to the same
1691
+ expression so an inherited line-height can never overflow the panel.
1692
+ `padding: 0 12px 0 var(--vf-select-gutter, 16px)`
1693
+ (left gutter for ✓, shared with `vf-select`/`vf-option` — `Menus.png` puts a
1694
+ pulldown's label ink at the same inset as a popup's; a shortcut row trades
1695
+ the 12 for 1px, the slot being the clearance there). The shortcut is
1696
+ left-aligned in a right-anchored `--vf-menu-shortcut-column` (23px) slot —
1697
+ every ⌘ at the same x, 23px from the right border, the widest letters
1698
+ running to within ~3px of it (`Menus.png`'s File pulldown) — with an 8px
1699
+ label↔shortcut min gap. `color: var(--vf-disabled)` when
1700
+ disabled. Hover, `[active]`, keyboard focus (not disabled): full-width
1701
+ inversion — each with its own `.blink-off` override at matching specificity,
1702
+ so a drag-picked row keeps its flag through the blink and the release reads as
1703
+ the highlight flashing off. A disabled row dims its
1704
+ ✓ along with the label — a **documented deviation** from the §1 "dim the label,
1705
+ chrome glyphs stay black" rule, because authentic System 7 greyed the whole
1706
+ disabled row.
1707
+ - **Behavior:** `role="menuitem"` — or `role="menuitemcheckbox"` with
1708
+ `aria-checked` when the item is *checkable*: either `checkable` is set, or the
1709
+ item has ever been `checked`. Set `checkable` on a toggle that starts **off**,
1710
+ which would otherwise announce as a plain command until its first flip (a
1711
+ boolean `checked` attribute can't express "checkable but off"). The role is
1712
+ re-derived on every connect, so re-parenting a checkable item keeps it; an
1713
+ author-supplied `role` is left alone. On activation — a click, Enter/Space, or
1714
+ the public `activate()` the menu's press gesture calls for the row a drag was
1715
+ released over (which the row's own `click` never sees, since a press that
1716
+ started on the title dispatches its click above both): classic **blink**
1717
+ (invert toggles 3 times over ~250ms via timer; skipped under
1718
+ `prefers-reduced-motion`, selecting at once), then dispatch `vf-menu-select`
1719
+ detail `{ value, item }` and signal ancestors to close the menu. Disabling an
1720
+ item mid-blink cancels it and drops the pending `vf-menu-select`.
1721
+ - **Slots:** default (label). **Parts:** `item`, `check`, `label`, `shortcut`.
1722
+ - **Events:** `vf-menu-select` — menu-specific by design. A plain `vf-select`
1723
+ would collide with the `<vf-select>` popup on any delegated ancestor listener,
1724
+ since both bubble and compose while `<vf-select>` itself commits with
1725
+ `vf-change`. Parallels the existing `vf-menu-*` coordination names.
1726
+
1727
+ #### `vf-list` (`VfList`, vf-list-item children) (vf-list.ts)
1728
+ Classic list box.
1729
+ - **Attributes/props:** `multiple: boolean`, `value: string` /
1730
+ `values: string[]` (multiple), `disabled`, `label: string` (accessible name →
1731
+ host `aria-label`).
1732
+ - **Children:** `<vf-list-item value="...">` (`VfListItem`, vf-list-item.ts):
1733
+ props `value`, `selected` (reflect), `disabled`; height 20px,
1734
+ `padding: 0 6px`; selected = inverted row (full width). Slots: default (the
1735
+ row text, in a `text` part that ellipsizes), `icon` (a leading graphic —
1736
+ usually a 16×16 `vf-img` small icon; flex-centered in the row with the
1737
+ reference art's 4px gap to the text, no text contributed so type-ahead
1738
+ still reads the words; keep row-height − icon-height even so the centering
1739
+ offset is a whole pixel).
1740
+ - **Visual (list):** white bg inside a real 1px black frame on the snapped
1741
+ wrapper, a `[rows | rail]` grid: the scrolling row viewport (class
1742
+ `vf-scroll`, native bar hidden) beside the drawn `vfScrollRail` subtree
1743
+ (§4), synced by `ScrollRailController`. The rows sit exactly 1 system px
1744
+ inside the frame lines (a `mod()` border-floor compensation pads back what
1745
+ engines floor off the fractional border-width — the rows are light-DOM
1746
+ components, so their origins are the page's grid contract); default
1747
+ `max-height: 200px` overridable via `--vf-list-max-height` (the frame adds
1748
+ its 2px on top). Reserves a permanent vertical scroll rail (the
1749
+ "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white
1750
+ channel until the rows overflow.
1751
+ - **Behavior:** `role="listbox"` (+`aria-multiselectable`, + `aria-label` from
1752
+ `label`), items `role="option"`. A disabled list pushes `aria-disabled` down
1753
+ onto every row (as `vf-radio-group` does), so AT is never shown enabled-looking
1754
+ options inside a disabled listbox; the flag is tracked separately from each
1755
+ row's own `disabled`, so re-enabling the list leaves individually disabled rows
1756
+ disabled. Click selects (Shift/Cmd extend when `multiple`). Roving tabindex;
1757
+ Arrow keys move the selection, Space toggles in multiple mode. The full
1758
+ multiple-mode model: Shift+Arrow extends from the anchor, Ctrl+Arrow moves
1759
+ the cursor without touching the selection, plain Home/End and type-ahead
1760
+ jumps are cursor-only moves too (a jump that rewrote a hand-built selection
1761
+ would destroy it — Space is how the reached row joins it; in single mode
1762
+ they select, since there the selection *is* the cursor), Shift(+Ctrl)+
1763
+ Home/End extend through to the ends, Shift+Space selects the contiguous run
1764
+ from the anchor to the cursor, and Ctrl/Cmd+A selects every enabled row.
1765
+ Printable keys drive classic Finder **first-letter type-ahead**
1766
+ (`src/type-ahead.ts`, the shared buffer `vf-select` and the menus also run):
1767
+ keystrokes accumulate into a prefix matched against each row's text, jumping
1768
+ to the next match, wrapping and skipping disabled rows. The prefix resets
1769
+ after 1s of silence; repeating a single character cycles the rows starting
1770
+ with it. Modified keys are left to the consumer, and Space stays the
1771
+ multiple-mode toggle, so neither joins the prefix. A **disabled** list keeps
1772
+ its rows rendered and readable (System 7 dims a list, it doesn't hide it) —
1773
+ when they overflow the box, the viewport itself becomes the Tab stop so the
1774
+ dimmed rows stay reachable by keyboard scroll.
1775
+ - **Parts:** `list`. **Events:** `vf-change` detail `{ value, values }`.
1776
+
1777
+ #### `vf-scroll-area` (`VfScrollArea`, vf-scroll-area.ts)
1778
+ A container whose scrollbars look like System 7.
1779
+ - **Attributes/props:** `axis: 'vertical' | 'horizontal' | 'both'` (default
1780
+ `'vertical'`, reflected) — which scroll rails to reserve as permanent
1781
+ placeholders (see "always-a-rail" below); `label: string` — accessible name
1782
+ for the viewport (`aria-label` on the viewport, since an `aria-label` on the
1783
+ host cannot reach into the shadow DOM). The viewport is a **Tab stop only
1784
+ while its content actually overflows** — the same state
1785
+ `ScrollStateController` measures for the rails; a fitting scroll area used
1786
+ to be a focusable stop with `role: generic` and no name, a dead Tab press.
1787
+ Whenever it is a stop it carries a role: `role="region"` when `label` names
1788
+ it (a named landmark), `role="group"` when not (an unnamed region is inert,
1789
+ so that role is reserved for the labelled case).
1790
+ - **Visual:** `display: block`; the snapped wrapper carries a real 1px black
1791
+ frame and a grid reserving each rail as its own edge column/row —
1792
+ `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the
1793
+ white inner viewport padded `8px` plus the border-floor compensation (a
1794
+ `mod()` term restoring what engines floor off the fractional border-width,
1795
+ so slotted content and the (0,0) of placed children sit exactly 9 system px
1796
+ from the frame box at every scale). Consumer sets width/height on host.
1797
+ The rails are the drawn `vfScrollRail` subtree (§4 — the classic 16px cell
1798
+ counting the frame line: divider, 14px channel, 15px arrow cells, the fixed
1799
+ 16px thumb, the `tileRaster` dot-dither trough), rendered as later siblings
1800
+ of the viewport and synced to its native scrolling by
1801
+ `ScrollRailController`; the viewport carries the `vf-scroll` class, which
1802
+ hides the native bar without touching the native scrolling. When both
1803
+ rails are reserved, the corner cell joins them, supplying the interior
1804
+ dividers the adjacent arrow cells leave to it. Every engine renders the
1805
+ same rail; the old `::-webkit-scrollbar` skin, its `.vf-scroll-frame`
1806
+ overlay contortion (WebKit quantized native scrollbar rects to whole CSS
1807
+ px) and the Firefox `scrollbar-color` fallback are all retired.
1808
+ - **Always-a-rail behavior:** each *reserved* axis (per `axis`) renders its
1809
+ rail element as a permanent placeholder — arrow buttons on an empty white
1810
+ channel (dither off, no thumb; System 7 drew the arrows on any bar in an
1811
+ active window, and a scroller outside a window always counts as active) —
1812
+ until the content overflows that axis, when the dither and thumb fill in
1813
+ and the arrows go live (an idle axis's arrows are drawn but inert — the
1814
+ press guard in `ScrollRailController` skips them). `ScrollStateController`
1815
+ (`src/scroll-state.ts`) measures both axes and writes `data-overflow-x` /
1816
+ `data-overflow-y` (`"true"` / `"false"`) on the scroll element; the recipe
1817
+ keys the dither and thumb off those attributes. The unreserved axis still scrolls
1818
+ natively (wheel, keyboard) but draws no rail. Shared by vf-list and
1819
+ vf-text-area; a future `@container scroll-state(scrollable)` query could
1820
+ replace the JS for slotted-content components.
1821
+ - **Inactive-window blanking:** the HIG's non-frontmost window must not
1822
+ display interactive scroll UX, so the controller also finds the nearest
1823
+ `vf-window` up the composed tree (light-DOM ancestor for a slotted
1824
+ scroller, shadow ancestor for `vf-window[scrollbars]`'s own edge rails),
1825
+ watches its reflected `active` attribute, and toggles a presence-only
1826
+ `data-window-inactive` on the scroll element. While present, the recipe
1827
+ empties dither/thumb/arrows on BOTH axes regardless of overflow — the
1828
+ bare channel, arrows included (unlike the idle rail, which keeps them),
1829
+ exactly as System 7 blanked a deactivated window's
1830
+ bars (its List Manager/TextEdit deactivated in-window scrollbars too).
1831
+ No `vf-window` ancestor → the attribute never appears: dialogs have no
1832
+ inactive state and a bare scroll component always draws live. Like the
1833
+ overflow half, this signal could one day go declarative — a custom
1834
+ property cascaded under `vf-window:not([active])` gating the recipe via an
1835
+ `@container style()` query; the rails being ordinary DOM, that migration
1836
+ is a plain selector swap (see the FUTURE note in scroll-state.ts).
1837
+ - **Document-window (TeachText) composition:** to put the rails on a window's
1838
+ edge rather than inset in its body, slot the scroll area into a
1839
+ `vf-window[flush]` sized `calc(100% + 2px·scale)` with `margin: -1px·scale`
1840
+ — one system pixel under the window frame on every side. Its own frame
1841
+ border then repaints the window's border lines exactly (no doubled frame),
1842
+ and a resizable window's grow box lands exactly over the rail's corner
1843
+ cell, giving the classic System 7 document window. `vf-window[scrollbars]`
1844
+ renders exactly this composition from its own shadow tree, so the
1845
+ one-liner and the slotted form are geometrically identical; slot it
1846
+ yourself when the well should sit *inset* in the body instead (the
1847
+ installer's read-me well).
1848
+ - **Slots:** default. **Parts:** `viewport`.
1849
+
1850
+ #### `vf-fieldset` (`VfFieldset`, vf-fieldset.ts)
1851
+ The "Install Location" group box.
1852
+ - **Attributes/props:** `legend: string`.
1853
+ - **Visual:** `border: 1px solid var(--vf-black, #000)`, no radius,
1854
+ `padding: 14px 12px 10px`, `margin-top: 8px` (room for legend). Legend: bold,
1855
+ positioned overlapping the top border (absolute, `top: -0.7em; left: 8px;`),
1856
+ `padding: 0 5px`, `background: var(--vf-surface, var(--vf-white, #fff))` so
1857
+ it punches out the border to match its surface.
1858
+ - **Slots:** default, plus named slot `legend` (overrides attr).
1859
+ - **Parts:** `fieldset`, `legend`.
1860
+
1861
+ #### `vf-grid` (`VfGrid`, vf-grid.ts)
1862
+ A lattice of equal cells with 1px rules between them: the Figure 5-6 tool
1863
+ palette's 3×3 of desk accessories, a color picker's swatch table, a pattern or
1864
+ icon chooser. All the same drawing, and all of it used to be hand-rolled page
1865
+ CSS — a `display: grid` with a 1px gap over a black background, showing through
1866
+ as the rules (which only draws them solid, and puts the kit's artwork in the
1867
+ consumer's stylesheet).
1868
+ - **Attributes/props:** `columns: number` (cells across, default 1),
1869
+ `rows: number` (cells down; unset, the slotted cells decide — set, that many
1870
+ are reserved, so an unfilled cell still gets its rules),
1871
+ `cell-width` / `cell-height`: number (whole system px, default 16),
1872
+ `rules: 'solid' | 'dashed' | 'none'` (default `'solid'`, reflected) — the pen
1873
+ every rule is drawn with, `frameless: boolean` (reflected) — drop the
1874
+ perimeter, which is otherwise drawn, `collapse: boolean` (reflected) — land a
1875
+ cell's own border on the rule rather than beside it.
1876
+ - **Visual:** host `display: block`; the shadow `grid` box is `width: max-content`
1877
+ (a block-level grid stretched by a wider parent would tile its rules on across
1878
+ the empty space) over `var(--vf-surface, var(--vf-white, #fff))`, with fixed
1879
+ `cell-width × cell-height` tracks and a 1px gap for the rules to paint into
1880
+ (`rules="none"` closes the gap and the cells butt together). The box carries
1881
+ 1px of padding for the outer lines to land on, which `frameless` drops with
1882
+ them. Cells are the *slotted* elements
1883
+ (`slot { display: contents }`), so they keep their own semantics and styling.
1884
+ The grid is `place-items: center`: what a palette holds is smaller than its
1885
+ well (a 16px icon in a 26px cell), and grid's own `stretch` default behaves as
1886
+ *start* for anything with a size, which parks every icon in a corner. A cell
1887
+ that means to fill its well — a tool button that inverts when selected — asks
1888
+ with `place-self: stretch`; under `collapse` that is already the default.
1889
+ - **The rules are a masked lattice, not gaps:** one overlay paints
1890
+ `var(--vf-black, #000)` through two tiled 1-bit SVG masks — a pixel column at
1891
+ the horizontal pitch, a pixel row at the vertical — so the pen switches from
1892
+ solid to dashed with no change in layout, and the color stays a token (the
1893
+ SVG only says *where*; rects rather than gradient stops, like the windoid
1894
+ dither). The lattice starts at the box's own edge, so the perimeter is simply
1895
+ its first and last lines; `frameless` pulls it back one pixel, putting the
1896
+ first line on the first gap and the last one outside the box.
1897
+ - **The dashed pen inks where `x + y` is even** — the diagonal phase of a 50%
1898
+ dither, and the only phasing under which the two layers agree at a crossing:
1899
+ either both draw that pixel or neither does. Phase the lines independently and
1900
+ the crossings where one layer inks and the other doesn't put a stray pixel
1901
+ beside a dash, rendering the 1px rule as 2 and 3 px clumps at half its
1902
+ intersections. Carrying it takes a mask tile two pitches long (its second line
1903
+ starting `pitch % dash` steps out of phase).
1904
+ - **Painted under the cells** (`z-index: -1` in the grid's own stacking context,
1905
+ above its surface), so an item spanning two cells covers the boundary it
1906
+ swallowed and a cell's own ink — a pressed face, a focus rule, a hard shadow —
1907
+ is never crossed by a line. It is also what makes `collapse` read right: a
1908
+ filled cell's solid border hides the rule beneath it while the empty cells
1909
+ around it keep showing the pen.
1910
+ - **`collapse`** is `border-collapse: collapse` in system px, for cells that
1911
+ draw their own 1px border (a bordered tile, a `vf-swatch`): without it that
1912
+ border sets *beside* the lattice line and every boundary reads 2px. It pulls
1913
+ each cell back 1px on all four sides (`::slotted(*) { margin: -1px }`), so the
1914
+ two become one line and neighbors share it. Size the cells at *item − 2px*
1915
+ (`cell-width="14"` for a 16px item); these cells also keep grid's `stretch`
1916
+ rather than the centering above — they exist to meet the lines — so an
1917
+ auto-width one is resized to match, and a consumer's own margin still wins (a
1918
+ light-DOM declaration beats a `::slotted` one). The frame gives the outermost borders a line to land
1919
+ on and ends the box exactly at the last item's outer edge — `n × (cell + 1) + 1`
1920
+ is exactly `n` items of `cell + 2` overlapping by one. On a `frameless` grid
1921
+ those outer borders paint a pixel outside its box; with `rules="none"` there is
1922
+ nothing to collapse onto and it goes inert.
1923
+ - **Semantics are the consumer's:** the grid takes no role, no keyboard behavior
1924
+ and no selection — `role="group"`/`aria-label` on the host for a tool palette,
1925
+ `role="radiogroup"` for a picker, nothing at all for a plain tiling.
1926
+ - **Slots:** default (the cells, in order). **Parts:** `grid`, `rules`.
1927
+ **Events:** none.
1928
+
1929
+ #### `vf-stack` (`VfStack`, vf-stack.ts)
1930
+ The kit's layout primitive: a flexbox whose `gap`, `pad`, `width` and `height`
1931
+ are declared in whole system px and converted internally, so a window's insides
1932
+ are laid out in the art's own unit with no `calc(var(--vf-scale, 1) * Npx)` in
1933
+ the consumer's stylesheet.
1934
+ - **Why a component and not a documented snippet:** scaling is default-on and
1935
+ *per component* — `ScaleController` sets `--vf-scale` on the component's own
1936
+ host, never on the document — so `var(--vf-scale, 1)` in page CSS resolves
1937
+ only where the rule's element happens to sit inside a `vf-*` ancestor and
1938
+ inherit it. Inside a window body it does (which is the only reason the demo's
1939
+ own hand-written rules ever worked); for a plain `<div>` holding two buttons
1940
+ on an ordinary page it does not, and the fallback `1` silently renders an 8px
1941
+ gap around 3×-sized controls. A page that hasn't called `applyScale()` cannot
1942
+ express "8 system px" at all. A component always can, because it *is* the
1943
+ scope — which is also why the stack carries a `ScaleController` of its own.
1944
+ - **Attributes/props:** `direction: 'column' | 'row'` (default `'column'`,
1945
+ reflected), `gap: number` (whole system px, default `0`), `pad: string | number`
1946
+ (whole system px, one to four values in CSS shorthand order),
1947
+ `place: 'start' | 'center' | 'end'` (reflected; unset resolves per direction),
1948
+ `width` / `height`: number (whole system px, optional). Whole system px is the
1949
+ only expressible value — a fractional entry is truncated — so the gap half of
1950
+ the layout contract (docs/SIZING.md rule 2) holds by construction, and a declared
1951
+ `width` covers the size half of rule 3. On a **child**: `fill-width` and
1952
+ `fill-height`, bare attributes like `nosnap`.
1953
+ - **Visual:** none. The stack paints nothing — no border, background or shadow —
1954
+ and takes no role, no keyboard behavior and no selection; what it holds
1955
+ decides what it is, as with `vf-grid`. `:host` is a plain block shell
1956
+ (`width: fit-content`); the flex container is one shadow box coinciding with
1957
+ the host box (`.vf-snap`, `height: 100%` so a declared or filled host height
1958
+ reaches the flex layout) — the `vf-container` arrangement, with the
1959
+ placed-child anchor and the grid-snap target on the same element. The box
1960
+ carries no padding until `pad` writes some, so its padding box IS the host
1961
+ box and the placed-child anchor ignores `pad` exactly as before
1962
+ (`verify:position` pins this). `gap`/`pad` are written to that box's inline
1963
+ style as `calc(var(--vf-scale, 1) * Npx)` via `sysLength`/`sysLengths` (the
1964
+ var resolves against the host's own `--vf-scale` by inheritance);
1965
+ `width`/`height` stay on the host via `VfSized` — each stays live against
1966
+ the display rather than freezing at write time. **Carries a
1967
+ `GridSnapController`** — a reversal of the original no-controller decision,
1968
+ which accounted only for kit children: a stack is a positioned ancestor and
1969
+ a layout box for *anything*, and consumer content inside it (a div, an
1970
+ `<img>`, a run of text) has no controller of its own. Under
1971
+ `applyGridSnap()` the correction moves the stack's whole coordinate system;
1972
+ kit children then find nothing left to correct (the sweep runs
1973
+ outermost-first). It does not fix what it never could: a text-governed child
1974
+ width mid-row still shifts later siblings fractionally (their own
1975
+ controllers cover that), and centering still can't land on a whole pixel.
1976
+ - **The content governs the box.** A column is as wide as its widest child and a
1977
+ row as tall as its tallest; children neither grow nor shrink
1978
+ (`::slotted(*) { flex: 0 0 auto }`). System 7 controls keep their natural
1979
+ sizes — a push button is as wide as its label, a popup menu hugs its widest
1980
+ option, a
1981
+ swatch is a fixed well — and a window is a fixed box whose overflow is clipped
1982
+ at the frame, not a layout that squeezes its controls. The stack distributes;
1983
+ it never resizes. `width: fit-content` is the same rule stated in the box
1984
+ model: a layout box that claimed its parent's whole width would be handing out
1985
+ a size nobody declared. It shrink-wraps while staying **block-level**, which is
1986
+ deliberate — `inline-flex` gives the same width but puts the box on a line box,
1987
+ and a line box can never be shorter than its parent's strut, so a stack shorter
1988
+ than the surrounding `line-height` silently gains the difference as leading
1989
+ (the showcase's swatch panel, an 18px row in a 20px line box, grew by exactly
1990
+ that). `width`/`height` override the content when an author says so, in system
1991
+ px, and beat `fit-content` from the host's inline style.
1992
+ - **`place` defaults per direction** — `start` down a column (a field starts at
1993
+ the panel edge), `center` across a row (a caption sits beside its control).
1994
+ The two directions call for opposite defaults. Both are
1995
+ stated as the direction's own rather than as an `auto` value, so an
1996
+ unrecognized `place` — a stale `stretch` from before this API — lands on the
1997
+ sane one instead of on flexbox's `normal`, which stretches. It is the only
1998
+ placement the stack owns: there is no `justify`, and a right-aligned action row
1999
+ is a filled column whose one child sits at the end of it. **It is `place` and
2000
+ not `align` because `align` is a legacy HTML presentation attribute** — see
2001
+ the transparency bullet below.
2002
+ - **`fill-width` / `fill-height` name the outcome, not an axis**, so each
2003
+ compiles to the main axis (`flex: 1 1 0` plus the `min-*: 0` that lets it
2004
+ shrink below its content) or the cross axis (`align-self: stretch`) depending
2005
+ on which way the stack runs — four static rules, no allowlist. What follows is
2006
+ one rule about geometry rather than vocabulary: **the cross axis always has a
2007
+ size, the main axis only has slack if one was declared.** `fill-width` always
2008
+ works in a column and needs a declared `width` in a row; `fill-height` is the
2009
+ other way round; a fill with nothing to take is inert rather than an error; and
2010
+ two children filling along the main axis come out equal, the zeroed flex basis
2011
+ being what lets them divide the slack instead of keeping their natural sizes.
2012
+ A child that declares its own size shouldn't also ask to fill it — in a column
2013
+ the declared size wins, in a row the fill does. The host reads both attributes
2014
+ about **itself** too (`width`/`height: 100%`), for the parents that aren't
2015
+ stacks — a window body, a fieldset, a scroll well, a grid cell — which is where
2016
+ a panel's width enters the tree. Three components have no width of their own
2017
+ and need a fill to take one: `vf-separator`, `vf-progress-bar` and `vf-slider`
2018
+ are drawn as a rule or a track that *is* the width. A light-DOM declaration
2019
+ beats a `::slotted` one, so `align-self: stretch` in page CSS remains the
2020
+ escape hatch for a cross-axis fill a direction doesn't offer.
2021
+ - **Typographically transparent.** `vfBase`
2022
+ dresses a host as chrome (body face, a 1.25 ratio line box, black,
2023
+ unselectable); the stack returns `font`, `-webkit-font-smoothing`, `color`,
2024
+ `user-select` and `text-align` to `inherit`, because wrapping content in a
2025
+ layout box must not change how that content reads. `text-align` is on that
2026
+ list because **`align` is a legacy HTML presentation attribute**: Blink maps
2027
+ the `align` content attribute on any HTML element to `text-align` — "left" /
2028
+ "right" / "center" by name, anything else verbatim — so `align="end"` on an
2029
+ action row right-aligned every run of copy inside it. Renaming the attribute
2030
+ to `place` is the fix; the reset stays because it costs nothing and keeps
2031
+ markup written against the old spelling harmless. The hint loses to
2032
+ a `:host` rule, and the page's own `text-align` still inherits through
2033
+ (`verify:stack` asserts all three). Of every attribute name the kit uses,
2034
+ `align` was the only one that carried a hint — `width`, `height`, `color`,
2035
+ `size`, `direction`, `label` and `value` are all inert on a custom element. Inside a window it goes on inheriting the
2036
+ window's face and the SPEC §1 chrome selection rule; on an ordinary page it
2037
+ leaves the page's typography — and its whole-pixel line boxes — alone. A ratio
2038
+ line-height landing on slotted prose is the exact rule-2 fault the kit warns
2039
+ pages about.
2040
+ - **The gap is the real CSS property, not a token.** Written as a custom
2041
+ property it would inherit, and a nested stack would silently pick up its
2042
+ parent's spacing (`--vf-button-group-gap` inherits on purpose; this must not).
2043
+ - **What it deliberately doesn't do:** equalize its children's widths. A row of
2044
+ buttons still belongs in a `vf-button-group`, which sizes them to the widest
2045
+ and aligns their *faces* rather than the `variant="default"` ring boxes a
2046
+ plain flex row lines up. It is also not a grid: `grid-template-columns:
2047
+ 1fr auto 1fr` stays page CSS.
2048
+ - **Two traps it does not fix.** Centering cannot land on a whole pixel by
2049
+ itself — a 16px caption centered against the 25-system-px `vf-number-field`
2050
+ sits at 4.5 system px, and no container can round that without reading each
2051
+ child's height (`applyGridSnap()` keeps the caption's own ink crisp anyway;
2052
+ `place="start"` is the deterministic escape; `hit-list.md` files the 25px
2053
+ height as the underlying fault). And a flex container does not collapse
2054
+ margins, so `vf-fieldset`'s legend room — an 8px `margin-top` on its inner box
2055
+ that escapes the host in block flow — is genuinely reserved inside a stack
2056
+ instead of being donated by whatever precedes it.
2057
+ - **Slots:** default (the children; `fill-width` / `fill-height` on any of them).
2058
+ **Parts:** none.
2059
+ **Events:** none.
2060
+
2061
+ #### `vf-container` (`VfContainer`, vf-container.ts)
2062
+ A plain sized box: `width`/`height` in whole system px around a bare slot —
2063
+ no paint, no layout opinion. Explicit placement (src/position.ts) leaves one
2064
+ line of CSS the kit can't write for a consumer: children placed with
2065
+ `top`/`left` need a positioned ancestor, so a region of the consumer's *own*
2066
+ needs `position: relative` in a stylesheet. This component is that region as
2067
+ an element — a DITL's enclosing rectangle with nothing drawn in it.
2068
+ - **Attributes/props:** `width` / `height`: number (whole system px, via
2069
+ `VfSized`); the `top` / `left` pair via `VfPositioned` like nearly every
2070
+ component. On a **child**: `fill-width` and `fill-height`, bare attributes
2071
+ as in `vf-stack`. That is the whole API.
2072
+ - **Not a `vf-stack`.** The stack is a flexbox with opinions — an axis, a
2073
+ cross-axis default, fills compiled into flex. The container has none:
2074
+ in-flow children get normal flow, placed children get a coordinate system.
2075
+ Reach for it when the stack's opinions are the thing in the way — a field of
2076
+ placed icons, a fixed stage for placed art, a composition that brings its
2077
+ own layout with it.
2078
+ - **Visual:** none — no border, background, role, keyboard behavior or
2079
+ selection; what it holds decides what it is. `:host` is a block with
2080
+ `width: fit-content`, so an undeclared axis shrink-wraps rather than
2081
+ claiming the parent's width (the stack's rule — a layout box must not hand
2082
+ out a size nobody declared; a declared dimension lands on the host's inline
2083
+ style and beats it). The slot sits in one shadow box coinciding with the
2084
+ host box (`.vf-snap`, `display: flow-root`, `height: 100%`): flow-root so a
2085
+ slotted margin cannot collapse through the top edge and push the coordinate
2086
+ origin off the host's corner, 100% so percentage fills resolve against a
2087
+ declared height. That box owns `position: relative` — the anchor for
2088
+ `top`/`left` children — the component's purpose. Content that
2089
+ outgrows the declared box overflows it rather than growing it.
2090
+ - **Carries a `GridSnapController`.** A container's box is itself the
2091
+ consumer's coordinate system, including for non-`vf` content that cannot
2092
+ correct itself. The shadow box owns the positioning anchor and the `vf-snap`
2093
+ class *together*, so under `applyGridSnap()` the correction moves the whole
2094
+ coordinate system and everything placed against it rides along, kit or not.
2095
+ A declared size and whole-px `top`/`left` are on the grid by construction;
2096
+ the controller covers the origin the page contributes. (This is the
2097
+ arrangement `vf-stack` has since adopted too — one shadow box owning
2098
+ anchor and correction together.)
2099
+ - **Typographically transparent**, exactly as `vf-stack` and for the same
2100
+ reason: `font`, `-webkit-font-smoothing`, `color`, `user-select` and
2101
+ `text-align` return to `inherit`, so wrapping content in a sized box changes
2102
+ nothing about how that content reads.
2103
+ - **`fill-width` / `fill-height`** are read about the host (`width`/`height:
2104
+ 100%`, for a parent that can give the box a size) and compiled for slotted
2105
+ children as the percentage form — normal flow has no flex axes to translate
2106
+ onto. `width: 100%` always binds against the box; `height: 100%` binds only
2107
+ against a declared `height` (percentage-against-auto computes to auto), so a
2108
+ fill with nothing to take is inert, not an error. A light-DOM declaration
2109
+ beats the `::slotted` rule, as everywhere.
2110
+ - **Carries a `ScaleController`** for the stack's reason: a lone container on
2111
+ a plain page must resolve its declared size against the true scale, not the
2112
+ `var(--vf-scale, 1)` fallback its slotted children each escape on their own.
2113
+ - **Slots:** default (the content; `fill-width` / `fill-height` on any of it).
2114
+ **Parts:** none.
2115
+ **Events:** none.
2116
+
2117
+ ### Group E — static text
2118
+
2119
+ The two components for text that is *not* part of a control: captions and copy.
2120
+ Both compose `vfStaticText` for their `face` / `size` / `dim` switches, both
2121
+ carry a `ScaleController` and a `GridSnapController`, and both exist for the
2122
+ same reason — a line box stated in whole system px, so text stops being the
2123
+ thing that knocks a page off the device-pixel grid (§3, "Line boxes are lengths
2124
+ too"). `npm run verify:text` asserts the line boxes, the faces and the `for`
2125
+ wiring, and A/Bs a column of paragraphs against plain `<p>`s under a ratio
2126
+ leading.
2127
+
2128
+ #### `vf-label` (`VfLabel`, vf-label.ts)
2129
+ The static caption: "Name:" beside a field, "Mode" over a radio group, a readout.
2130
+ - **Attributes/props:** `for: string` (id of the labelled control, resolved in
2131
+ the label's own tree scope), `face: 'display' | 'body'`, `dim: boolean`,
2132
+ `width`/`height: number` (whole system px, via `VfSized` — src/size.ts).
2133
+ - **`width` is the caption's half of contract rule 3.** Left to its text a
2134
+ caption measures whatever its glyphs measure (the showcase's Apple menu title
2135
+ came to 32.641 system px) and anything sized from it inherits the fraction; a
2136
+ declared width is whole, and so is the row built on it. It replaces the
2137
+ `min-width: calc(var(--vf-scale, 1) * 80px)` a page used to need, which only
2138
+ landed whole while it happened to bind. Written to the host's inline style via
2139
+ `sysLength`, so it stays live against the display. A caption wider than its
2140
+ width overflows rather than reflowing the row.
2141
+ - **Visual:** `display: inline-block` (so a page can give a caption column a
2142
+ shared width — contract rule 3), the **display face** by default (dialog
2143
+ captions are chrome), `line-height: var(--vf-label-line-height,
2144
+ var(--vf-line-height-display, 16px))` — Chicago 12's native line, read from
2145
+ the face token; `face="body"` reads `--vf-line-height` (12px, Geneva 9's),
2146
+ so fine print wraps at native pitch too — `cursor: default`, not selectable
2147
+ (chrome, per §1). `dim` greys the text to `--vf-disabled` — System 7 dims
2148
+ the label, not the control.
2149
+ - **Behavior (`for`):** clicking the caption **focuses** the target (a focus
2150
+ shortcut, not an activation — the kit's toggles carry their own labels), and
2151
+ the caption text becomes the target's **accessible name** by whichever route
2152
+ reaches it: a `vf-*` control's `label` property (the only thing that reaches
2153
+ the focusable element inside its shadow root), or `aria-labelledby` for
2154
+ anything in the label's own tree scope. Never overwrites a name the target
2155
+ already has — a consumer-set attribute, **or a name computed from the
2156
+ target's own content** (a `vf-checkbox` with slotted text, a `vf-button`, a
2157
+ native `<button>`): the aria route declines those rather than stamping over
2158
+ visible text. Puts back what it found when the label is removed, the id
2159
+ changes or the caption text does. A target that hasn't upgraded yet is waited for
2160
+ (`customElements.whenDefined`), since a pre-upgrade element has no `label`
2161
+ property to fill in. A disabled target (`disabled`, or `isDisabled` for an
2162
+ ancestor `<fieldset disabled>`) is not focused.
2163
+ - **Slots:** default (the caption). **Parts:** `label`.
2164
+
2165
+ #### `vf-paragraph` (`VfParagraph`, vf-paragraph.ts)
2166
+ A paragraph of copy on the kit's body face and grid.
2167
+ - **Attributes/props:** `face: 'display' | 'body'`, `dim: boolean`,
2168
+ `width`/`height: number` (whole system px, via `VfSized` — the measure the
2169
+ copy wraps to, and a box the copy overflows rather than grows; set them on
2170
+ a placed paragraph, which otherwise shrink-wraps to its longest line).
2171
+ - **Visual:** `display: block`, the **body face** by default,
2172
+ `line-height: var(--vf-paragraph-line-height, var(--vf-line-height, 12px))`
2173
+ — Geneva 9's native
2174
+ strike line, the 16px em centering into it the way the icon plate's does;
2175
+ `face="display"` defaults the box to 16px, Chicago 12's own line (ascent 12 +
2176
+ descent 3 + leading 1), so wrapped chrome copy leaves the native 7 blank rows
2177
+ between a baseline and the caps below it. Selectable (prose, so it re-enables
2178
+ the text selection `vfBase` suppresses). Renders a real `<p>` in the shadow
2179
+ root, so the copy keeps paragraph semantics for AT. **No margin** (§2):
2180
+ paragraph spacing is the page's, in whole pixels like everything else.
2181
+ - **Slots:** default (the copy). **Parts:** `paragraph`.
2182
+
2183
+ ### Group F — images
2184
+
2185
+ #### `vf-img` (`VfImg`, vf-img.ts)
2186
+ A raster image — the reference art's pixel icons — on the kit's grid, treating
2187
+ one image pixel as one system pixel.
2188
+ - **Attributes/props:** `width`, `height`: number (system px, whole; default
2189
+ the slotted image's natural size). Stated up front they reserve the box
2190
+ before the file loads; a whole multiple magnifies on the same grid.
2191
+ - **Visual:** `display: inline-block`; the shadow `frame` box is sized
2192
+ `width × height` system px (× `--vf-scale` in `calc()`, so a source pixel
2193
+ covers exactly `scale × dpr` device px — contract rule 1) and the slotted
2194
+ `<img>` fills it with `image-rendering: pixelated`, which on a
2195
+ whole-device-pixel box is bit-exact nearest-neighbor magnification. The box
2196
+ is 0×0 until a size is known — never a flash of the image at some other
2197
+ scale. `-webkit-user-drag: none` (chrome, not draggable content).
2198
+ - **Behavior:** the graphic stays a native `<img>` in the consumer's light DOM
2199
+ (native loading, `alt` semantics — `alt=""` when decorative; the kit ships no
2200
+ raster files). The component watches the slotted image's `load`/`error` to
2201
+ pick up its natural size and any later `src` swap. A **failed** load
2202
+ (settled, no natural size) releases an undeclared box instead of clamping it
2203
+ to 0×0 — a native `<img alt="…">` that fails renders its alt text, and
2204
+ stretching the image into a 0×0 frame erased it; declared `width`/`height`
2205
+ still hold the box, exactly as a sized native `<img>` reserves its box
2206
+ around the alt text. Carries a `ScaleController` and a `GridSnapController`
2207
+ like every painted host.
2208
+ - **Slots:** default (a single `<img>`). **Parts:** `frame`.
2209
+
2210
+ #### `vf-icon` (`VfIcon`, vf-icon.ts)
2211
+ The Finder icon — pixel art in a reserved cell with its name on a plate below,
2212
+ as one selectable, movable, renameable unit. `vf-img` puts a picture on the
2213
+ grid; this makes it the thing the Finder manipulates.
2214
+ - **Attributes/props:** `label`: string (the name; empty draws no plate — that
2215
+ is the "no label" setting, rather than a second attribute that could disagree
2216
+ with it). `size`: `large` (default, 32×32) | `small` (16×16) — picks the slot
2217
+ that paints *and* the cell it paints in. `selectable`, `selected`, `movable`,
2218
+ `editable`: boolean. `open`: boolean — the icon's window is on screen, and
2219
+ the art paints as the derived open ghost (below). `width`: number (system px,
2220
+ **even**; the cell/grid pitch, which a longer name overflows rather than
2221
+ being bound by). `maxlength`: number (31).
2222
+ - **Visual:** `display: inline-block`. A column: the art cell (`--_cell` system
2223
+ px square, `overflow: hidden`, art centered), `--vf-icon-gap` (2px), then the
2224
+ name plate — body face on a `--vf-icon-label-height` (12px) line box, the
2225
+ Finder's own plate height: the face's 16px em centers in it, 3px above the
2226
+ ascenders and descenders on the bottom edge, exactly as the Finder drew it —
2227
+ 1px
2228
+ horizontal padding, **opaque `--vf-white`** in its own right rather than
2229
+ `--vf-surface`: on the desktop dither the name reads because it sits on a
2230
+ plate, and `--vf-surface` is unset out there (`Example screen (1-bit).png`,
2231
+ the "Macintosh HD" icon). Selected: the plate takes the `--vf-highlight` pair
2232
+ and the art takes `filter: invert(1)` — a System 7 icon is ink and opaque
2233
+ white on a transparent surround, i.e. precisely an image plus its mask, so
2234
+ inverting flips the two and leaves the surround alone. That is the complete
2235
+ classic selected appearance for 1-bit art; color art inverts into a
2236
+ photographic negative rather than the darkening System 7 gave it.
2237
+ - **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art
2238
+ redraws as the Finder's open-window ghost — outline held in solid black,
2239
+ interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at
2240
+ (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the
2241
+ slotted art itself, by compositing alone: a silhouette via `source-in`; a
2242
+ 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette
2243
+ shifted one pixel each way (off-canvas composites as transparent, so the
2244
+ raster's own edge erodes too and an interior hole rings itself); the lattice
2245
+ via `createPattern` anchored at the art's top-left, where 32 and 16 being
2246
+ whole multiples of the tile keeps a field of icons in one phase; then the
2247
+ fill composed over the un-eroded silhouette, so the ring the erosion removed
2248
+ reads as the outline. Never `getImageData` — a canvas that drew a
2249
+ cross-origin image is tainted, which forbids *reading* pixels but not
2250
+ drawing or displaying them, so the ghost works for CORS-less sources too —
2251
+ and never `ctx.filter` (Safari's shaky corner; compositing operators are
2252
+ universal). Keeping the interior *opaque white* is what lets a selected open
2253
+ icon invert under the same `filter: invert(1)` with no second treatment. The
2254
+ slot stays in the tree while the ghost paints, hidden via a class gated on a
2255
+ successful derivation — art the pipeline cannot draw (nothing slotted, a
2256
+ failed load, an inline `<svg>`) keeps rendering as itself. The ghost
2257
+ re-derives on slotchange, the art's `load`/`error`, and `open`/`size`
2258
+ changes, keyed by `currentSrc` so a refresh that changed nothing is a string
2259
+ compare; the source's `alt` is carried onto the ghost (`role="img"` +
2260
+ `aria-label`) so a graphic that names its icon keeps doing so while its slot
2261
+ is hidden. The ghost displays on `vf-img`'s terms: natural raster size in
2262
+ system px, `image-rendering: pixelated`, one image pixel per system pixel.
2263
+ - **Behavior:** clicking selects, Shift/⌘ toggles, and a press outside every
2264
+ icon clears — a `DocumentListenersController` capture-phase listener attached
2265
+ only while selectable *and* selected, which is what makes single-selection
2266
+ work with no container owning the set. Double-click fires `vf-open`, and its
2267
+ keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts,
2268
+ added because a double-click is a pointer-only gesture (§1). Return is
2269
+ deliberately not one: the Finder's Return renamed, never opened, so it
2270
+ starts the edit on an editable icon and does nothing on a non-editable
2271
+ one. A double-click opens the icon **wherever it lands, the name included** —
2272
+ the handler is on the frame, and the name is as much the icon as the art is.
2273
+ `editable`: a press on the plate of an
2274
+ ALREADY-selected icon opens a rename field overlaying it (the press that does
2275
+ the selecting never does); the whole name starts selected, Return commits,
2276
+ Escape reverts, and the hidden plate keeps rendering the draft so the box
2277
+ widens as you type. **The two pointer gestures on a name are the same press**,
2278
+ and only the second one tells them apart, so the rename *waits*: the press
2279
+ arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and
2280
+ a press landing inside that window — wherever in the icon it falls — calls it
2281
+ off, leaving the double-click to open with no rename flashing up behind it.
2282
+ The window is long deliberately: no API reports what the browser counts as a
2283
+ double-click (it follows a user-movable platform setting), and reading a lone
2284
+ click as a pair costs only a wait, while reading a pair as a lone click
2285
+ renames when the user meant to open. A press that travels into a drag, a press
2286
+ outside, and any key call it off too — so a name drags like the art does, and
2287
+ Return opens the field at once, having no second half to wait for.
2288
+ `movable` — **not `draggable`**, which is a global HTML
2289
+ attribute and an `HTMLElement` accessor (the same trap as `align`, §5
2290
+ Group G) — drags via `DragController` on the `vf-window` delegate
2291
+ shape, plus arrow-key nudging (1 system px, 8 with Shift) because a
2292
+ pointer-only gesture is the kind of gap the kit closes (§1). Focus is the
2293
+ dashed rule below the plate, gated on `FocusRuleController` rather than
2294
+ `:focus-visible`: the host focuses itself so the press-drag can own the
2295
+ pointer.
2296
+ - **An icon alone is a picture; an icon in a field is an option.** `option` is
2297
+ invalid without a `listbox` that owns it — written unconditionally the
2298
+ browser drops the role *and* `aria-selected`, which is how a `selectable`
2299
+ icon reached AT as a bare generic in every configuration the kit shipped. So
2300
+ the role follows the container: `role="option"` + `aria-selected` when a
2301
+ `[role="listbox"]` ancestor claims it, otherwise `role="img"` named from
2302
+ `label` (else the art's `alt`), since `img` is not a name-from-content role.
2303
+ Not `button` — that promises Enter/Space activate, while here Return renames
2304
+ and the open route is ⌘O / ⌘↓. Re-derived on every connect, so re-parenting
2305
+ between the two contexts re-grades the icon. **The recipe for a field of
2306
+ icons is a container carrying `role="listbox"`** (`aria-label` +
2307
+ `aria-multiselectable`); `vf-desktop` cannot be it, since it also holds
2308
+ windows and a menu bar and a non-`option` child of a listbox is invalid the
2309
+ same way. Divergence from APG, recorded: its listbox options share one
2310
+ roving tab stop, the kit's stay one stop each.
2311
+ - **`selectable` is what makes an icon focusable** — `movable` and `editable`
2312
+ require it, as the Finder did (you cannot move or rename what you have not
2313
+ selected; the rename already opens only on an ALREADY-selected icon). A
2314
+ `movable`-only icon is draggable but not a tab stop, rather than a tab stop
2315
+ that announces nothing.
2316
+ - **A name is never abbreviated, and never folded:** no ellipsis, no clipping,
2317
+ no wrapping — `white-space: nowrap`, one line always. System 7 solved the
2318
+ long-name problem at the other end — HFS capped a filename at 31 characters —
2319
+ so the Finder always drew the name in full. Wider than its cell, it overflows
2320
+ it, centered. `width` is therefore the cell (the grid pitch), not a bound on
2321
+ the name.
2322
+ - **Every centered offset is whole, by parity.** The frame centers two things
2323
+ over one axis, and a centered child sits at `(box − child) / 2` — whole
2324
+ exactly when box and child share a parity. Half a system pixel is what
2325
+ fringes 1-bit art: glyph stems smear across two device columns and go gray
2326
+ while the plate behind them stays sharp, since backgrounds are pixel-snapped
2327
+ by the compositor and glyphs are not (hence a crisp plate under a gray
2328
+ name). So the component makes the parities agree rather than correcting
2329
+ afterwards. The cell is 32 or 16; `#measurePlate()` measures the text with a
2330
+ `Range` over the plate's own contents — the plate already carries the width
2331
+ this last computed, so reading the element back would only return it — and
2332
+ sizes the plate (an `inline-block`) to that plus its 1px padding, rounded UP
2333
+ to a whole **even** number of system px, so it never clips the run it was
2334
+ measured from. The measurement is in system px and therefore scale-invariant,
2335
+ which is why nothing re-runs on a density change; it re-runs on `label`,
2336
+ `_draft`, `_editing`, and once on `document.fonts.ready`, since a name
2337
+ measured against the fallback face is a different width. The text is NOT
2338
+ centered inside its own plate (`text-align: left` against the block's
2339
+ `center`) — that would re-introduce the half pixel one level down; it starts
2340
+ at the 1px padding edge, so its x is the plate's plus one whole pixel, and
2341
+ the rounding slack lands after it as a hairline of extra plate. The rename
2342
+ field puts its run exactly where the plate's is, so the name does not shift
2343
+ when editing starts; its 1px border and 1px white well are a wrapper around
2344
+ the input rather than the input's own border and padding, because the
2345
+ selection behind a highlighted name paints at the face's full 16px em and is
2346
+ clipped only at the input's own edge — split, the input stands the plate's
2347
+ 12px line box (clipping the selection to it) and the well stays white, for
2348
+ an edit box 16 system px tall in all, not the em plus trim. Staying on one line is part of the same argument:
2349
+ one run, one measured width, one parity. Nothing is snapped and nothing leans
2350
+ on the rasterizer — the kit's normal antialiasing stays on, and `verify:icon`
2351
+ asserts both the geometry and zero gray pixels across a field of names at dpr
2352
+ 1/2/3, declared width and auto, with a control that forces an odd plate and
2353
+ requires the fringe to return. The one thing the component cannot round is a
2354
+ `width` the consumer chose, so that must be even.
2355
+ - **The 31-char cap** is `maxlength` on the rename field, and exceeding it
2356
+ fires `vf-name-too-long` rather than dropping characters silently. Detected
2357
+ in `beforeinput`, not `input`: with `maxlength` set, a blocked keystroke
2358
+ fires `beforeinput` and then *no* `input` at all, while an overshooting paste
2359
+ reaches `input` already trimmed — only the "about to change" event still
2360
+ knows what was attempted. `input` carries a trimming backstop for any route
2361
+ that skips it. The cap bounds typing only; a `label` set from consumer code
2362
+ is displayed as given and fires nothing (the name is the consumer's data).
2363
+ - **An empty name is refused, not applied.** A file has to be called something,
2364
+ and System 7 would not commit a nameless one: a rename committed empty — or
2365
+ as nothing but spaces, which would read as blank either way — drops the edit
2366
+ and restores the previous name, firing `vf-name-rejected` and deliberately no
2367
+ `vf-change`. An empty `label` remains a valid *starting* state (a freshly
2368
+ made icon has no name yet): it draws no plate but stays selectable, focusable
2369
+ and renameable. The caption element is therefore rendered whenever there is a
2370
+ name **or** an edit is open — without that an unnamed icon has nowhere to put
2371
+ the field and could never be named at all. The plate hugs its text in BOTH
2372
+ states, which is what keeps a commit from moving the name: it is the same box
2373
+ either side, so the glyphs do not shift. The single exception is an edit with
2374
+ no text in it at all, which reserves the cell width so the field is usable
2375
+ rather than a 2px sliver, and goes back to hugging on the first character.
2376
+ That floor is the cell, which is even, so the parity rule holds.
2377
+ - **Slots:** `large` (32×32 art, normally a `vf-img`), `small` (16×16).
2378
+ **Parts:** `frame`, `icon`, `label`, `plate`, `input`.
2379
+ - **Events:** `vf-select` `{ selected }`, `vf-change` `{ label, previous }`,
2380
+ `vf-open` `{}`, `vf-name-too-long` `{ attempted, accepted, limit }`,
2381
+ `vf-name-rejected` `{ attempted, kept, reason }`.
2382
+
2383
+ ## 6. Exports
2384
+
2385
+ `src/index.ts` (already written — do not change without reason) exports every
2386
+ component class. Importing the package registers all elements.
2387
+
2388
+ ## 7. Demo page (built last)
2389
+
2390
+ `index.html` + `demo/demo.ts` + `demo/demo.css`, served by `vite` from repo
2391
+ root. It is both showcase and fidelity test — it recreates the reference
2392
+ screenshots:
2393
+
2394
+ 1. Full-viewport `vf-desktop` with a `vf-menu-bar` on top: Apple menu (its bar
2395
+ title a slotted 16-px `vf-img` apple icon; about item), File (New Window ⌘N, Open… ⌘O, sep, Close ⌘W, Page Setup…,
2396
+ disabled Print, sep, Quit ⌘Q), Edit (Undo ⌘Z, sep, Cut/Copy/Paste), View
2397
+ (an exclusive check across "by Icon", "by Small Icon" and "by Name"; the two
2398
+ icon views really do switch every `vf-icon` between the family's two
2399
+ members), Special (Restart, Shut Down, sep, "Show All Windows" — reopens
2400
+ closed demo windows).
2401
+ 2. **"DragThing 2.9 Installer" window** — faithful to the screenshot: white
2402
+ content well (bordered) with welcome copy + bullet list, "Disk space
2403
+ available: 58,616K / Approximate disk space needed: 4,584K" caption row
2404
+ (the body face — System 7's fine print is Geneva 9 at its own size),
2405
+ `vf-fieldset legend="Install Location"` containing the folder
2406
+ text and a `vf-select` ("Macintosh HD"), and stacked `Quit` +
2407
+ `Install` (variant=default) buttons on the right. `movable zoomable`.
2408
+ 3. **"Format" dialog window** — faithful to the screenshot: Mode
2409
+ `vf-radio-group` (Hierarchical ⌘H … Don't Reorganize ⌘R, with "Source Format
2410
+ Profile" disabled; shortcut text right of labels), disabled "Selection Only"
2411
+ checkbox, Options checkboxes (3, all checked), Cancel ⌘. + Format
2412
+ (variant=default) buttons bottom-right. Rendered as a movable `vf-window`
2413
+ with no close box (closable=false), so its own Cancel/Format buttons are
2414
+ how it dismisses.
2415
+ 4. **"Controls" kitchen-sink window** — text field, password field, textarea,
2416
+ determinate progress animating 0→100 on a timer, indeterminate progress,
2417
+ button variants (normal/default/disabled), a `vf-swatch` palette row (the
2418
+ six-color wells plus the no-color checker), separator, multi-select
2419
+ `vf-list` (each row's `icon` slot carrying its DA's 16×16 small icon as a
2420
+ `vf-img`), `vf-scroll-area` with enough
2421
+ text to scroll, disabled control examples.
2422
+ 5. An alert: menu item Special → "Erase Disk…" opens a composed alert box —
2423
+ `vf-dialog frame="plain"` with the demo's own 32×32 alert icon
2424
+ (`demo/icons/alert.png`) in a row `vf-stack` via `vf-img` — "Completely
2425
+ erase the disk named 'Macintosh HD'?" with Cancel / Erase buttons (Erase =
2426
+ default variant, closes the dialog). The kit ships no alert component; the
2427
+ showcase demonstrates the recipe.
2428
+ 6. All windows `movable`; desktop stacking/active management demonstrably
2429
+ works. Every window starts put away and opens from its own desktop
2430
+ launcher icon — a `vf-icon` per demonstration window and dialog, clustered
2431
+ around the top-left so the set stays on-canvas at any viewport size;
2432
+ `vf-open` centers the window on the raster as it currently stands and
2433
+ raises it (no window carries an authored position). Closing a window hides
2434
+ it (listen for `vf-close`, set `hidden`); the close-box-less modal
2435
+ lookalikes dismiss via their own OK/Cancel buttons instead; Special →
2436
+ Show All Windows un-hides everything, cascaded around the center.
2437
+ 7. **"Page Setup" modal dialog box** — File → Page Setup… opens a
2438
+ `vf-dialog frame="plain"` (dBoxProc double frame, heading drawn in
2439
+ content): Paper radio group in a fieldset, Reduce or Enlarge
2440
+ `vf-number-field`, Cancel / OK (default) buttons.
2441
+ 8. **"Desk Accessories" utility palette** — `vf-window variant="utility"
2442
+ movable flush`: a `vf-grid` of 3×3 26px cells holding 16×16 `vf-img` DA
2443
+ icons (one selected, inverted), frameless so the window's own border is the
2444
+ palette's, floating above the document windows on the desktop's utility
2445
+ tier and untouched by their active-state churn — the Group A archetype
2446
+ table's fifth recipe, live.
2447
+ 9. The **"DragThing Read Me"** window (item 2's copy points at it) carries the
2448
+ document-window archetype at full anatomy: `movable resizable
2449
+ scrollbars="both"`, the rails in the frame and the grow box in the corner
2450
+ cell.
2451
+ 10. **Finder icons on the desktop** — "Macintosh HD" and "Trash" as `vf-icon`,
2452
+ each slotting its art at both resource sizes, `selectable movable
2453
+ editable`. They sit under the windows the way desktop icons do, taking
2454
+ the column to the right of the launcher cluster — with `left`/`top` in
2455
+ system px, never a `right`/`bottom` anchor, which lands somewhere
2456
+ different at each density since the desktop is the viewport. View →
2457
+ "by Small Icon" drives them (launchers included).
2458
+
2459
+ Demo may use small amounts of layout CSS (positioning windows on the desktop)
2460
+ but NO aesthetic CSS — looks must come from the components. That includes the
2461
+ static text: every caption is a `vf-label` and every run of copy a
2462
+ `vf-paragraph`, so `demo.css` sets no face or size at all (it used to hand-roll
2463
+ the display face for the field labels, which is what Group E replaced).
2464
+
2465
+ ## 8. Definition of done
2466
+
2467
+ - `npm run typecheck` and `npm run build` pass.
2468
+ - Every component in §5 implemented per spec, exported, registered.
2469
+ - Demo showcases every component.
2470
+ - Side-by-side with the reference screenshots, an unfamiliar reviewer should
2471
+ say "yes, that's System 7."