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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/custom-elements.json +19807 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/chrome.d.ts +73 -0
- package/dist/chrome.js +94 -0
- package/dist/components/vf-button-group.d.ts +50 -0
- package/dist/components/vf-button-group.js +68 -0
- package/dist/components/vf-button.d.ts +169 -0
- package/dist/components/vf-button.js +234 -0
- package/dist/components/vf-checkbox.d.ts +60 -0
- package/dist/components/vf-checkbox.js +135 -0
- package/dist/components/vf-container.d.ts +95 -0
- package/dist/components/vf-container.js +86 -0
- package/dist/components/vf-desktop.d.ts +243 -0
- package/dist/components/vf-desktop.js +310 -0
- package/dist/components/vf-dialog.d.ts +105 -0
- package/dist/components/vf-dialog.js +280 -0
- package/dist/components/vf-fieldset.d.ts +34 -0
- package/dist/components/vf-fieldset.js +76 -0
- package/dist/components/vf-grid.d.ts +124 -0
- package/dist/components/vf-grid.js +133 -0
- package/dist/components/vf-icon.d.ts +356 -0
- package/dist/components/vf-icon.js +507 -0
- package/dist/components/vf-img.d.ts +63 -0
- package/dist/components/vf-img.js +65 -0
- package/dist/components/vf-label.d.ts +117 -0
- package/dist/components/vf-label.js +151 -0
- package/dist/components/vf-list-item.d.ts +48 -0
- package/dist/components/vf-list-item.js +98 -0
- package/dist/components/vf-list.d.ts +77 -0
- package/dist/components/vf-list.js +256 -0
- package/dist/components/vf-menu-bar.d.ts +64 -0
- package/dist/components/vf-menu-bar.js +236 -0
- package/dist/components/vf-menu-item.d.ts +91 -0
- package/dist/components/vf-menu-item.js +252 -0
- package/dist/components/vf-menu.d.ts +95 -0
- package/dist/components/vf-menu.js +346 -0
- package/dist/components/vf-number-field.d.ts +60 -0
- package/dist/components/vf-number-field.js +248 -0
- package/dist/components/vf-option.d.ts +49 -0
- package/dist/components/vf-option.js +119 -0
- package/dist/components/vf-paragraph.d.ts +73 -0
- package/dist/components/vf-paragraph.js +66 -0
- package/dist/components/vf-progress-bar.d.ts +69 -0
- package/dist/components/vf-progress-bar.js +272 -0
- package/dist/components/vf-radio-group.d.ts +109 -0
- package/dist/components/vf-radio-group.js +105 -0
- package/dist/components/vf-radio.d.ts +59 -0
- package/dist/components/vf-radio.js +135 -0
- package/dist/components/vf-scroll-area.d.ts +75 -0
- package/dist/components/vf-scroll-area.js +124 -0
- package/dist/components/vf-select.d.ts +334 -0
- package/dist/components/vf-select.js +658 -0
- package/dist/components/vf-separator.d.ts +38 -0
- package/dist/components/vf-separator.js +55 -0
- package/dist/components/vf-slider.d.ts +80 -0
- package/dist/components/vf-slider.js +255 -0
- package/dist/components/vf-stack.d.ts +188 -0
- package/dist/components/vf-stack.js +168 -0
- package/dist/components/vf-swatch.d.ts +98 -0
- package/dist/components/vf-swatch.js +201 -0
- package/dist/components/vf-text-area.d.ts +77 -0
- package/dist/components/vf-text-area.js +121 -0
- package/dist/components/vf-text-field.d.ts +53 -0
- package/dist/components/vf-text-field.js +79 -0
- package/dist/components/vf-window.d.ts +157 -0
- package/dist/components/vf-window.js +377 -0
- package/dist/cursor-art.d.ts +37 -0
- package/dist/cursor-art.js +41 -0
- package/dist/cursor.d.ts +49 -0
- package/dist/cursor.js +171 -0
- package/dist/define.d.ts +45 -0
- package/dist/define.js +15 -0
- package/dist/document-listeners.d.ts +62 -0
- package/dist/document-listeners.js +29 -0
- package/dist/drag.d.ts +49 -0
- package/dist/drag.js +30 -0
- package/dist/events.d.ts +79 -0
- package/dist/events.js +25 -0
- package/dist/focus-modality.d.ts +94 -0
- package/dist/focus-modality.js +56 -0
- package/dist/form-control.d.ts +247 -0
- package/dist/form-control.js +127 -0
- package/dist/glyphs.d.ts +117 -0
- package/dist/glyphs.js +100 -0
- package/dist/grid-snap.d.ts +73 -0
- package/dist/grid-snap.js +135 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +71 -0
- package/dist/menu-press.d.ts +60 -0
- package/dist/menu-press.js +97 -0
- package/dist/modal-dialog.d.ts +148 -0
- package/dist/modal-dialog.js +155 -0
- package/dist/motion.d.ts +72 -0
- package/dist/motion.js +22 -0
- package/dist/number.d.ts +24 -0
- package/dist/number.js +12 -0
- package/dist/open-art.d.ts +40 -0
- package/dist/open-art.js +35 -0
- package/dist/pixel-frame.d.ts +102 -0
- package/dist/pixel-frame.js +77 -0
- package/dist/popup-overflow.d.ts +157 -0
- package/dist/popup-overflow.js +45 -0
- package/dist/position.d.ts +187 -0
- package/dist/position.js +74 -0
- package/dist/scale.d.ts +259 -0
- package/dist/scale.js +133 -0
- package/dist/scroll-rail.d.ts +112 -0
- package/dist/scroll-rail.js +194 -0
- package/dist/scroll-state.d.ts +113 -0
- package/dist/scroll-state.js +55 -0
- package/dist/size.d.ts +47 -0
- package/dist/size.js +30 -0
- package/dist/styles/base.d.ts +38 -0
- package/dist/styles/body-font.d.ts +7 -0
- package/dist/styles/body-font.js +9 -0
- package/dist/styles/display-font.d.ts +7 -0
- package/dist/styles/display-font.js +9 -0
- package/dist/styles/recipes/body-face.d.ts +14 -0
- package/dist/styles/recipes/body-face.js +6 -0
- package/dist/styles/recipes/display-face.d.ts +23 -0
- package/dist/styles/recipes/display-face.js +10 -0
- package/dist/styles/recipes/field.d.ts +20 -0
- package/dist/styles/recipes/field.js +68 -0
- package/dist/styles/recipes/focus.d.ts +76 -0
- package/dist/styles/recipes/focus.js +39 -0
- package/dist/styles/recipes/host.d.ts +5 -0
- package/dist/styles/recipes/host.js +62 -0
- package/dist/styles/recipes/pattern.d.ts +80 -0
- package/dist/styles/recipes/pattern.js +82 -0
- package/dist/styles/recipes/scroll-rail.d.ts +69 -0
- package/dist/styles/recipes/scroll-rail.js +222 -0
- package/dist/styles/recipes/shadow.d.ts +13 -0
- package/dist/styles/recipes/shadow.js +5 -0
- package/dist/styles/recipes/static-text.d.ts +24 -0
- package/dist/styles/recipes/static-text.js +17 -0
- package/dist/styles/recipes/surface.d.ts +28 -0
- package/dist/styles/recipes/surface.js +27 -0
- package/dist/styles/recipes/tile.d.ts +122 -0
- package/dist/styles/recipes/tile.js +40 -0
- package/dist/styles/recipes/title-bar.d.ts +49 -0
- package/dist/styles/recipes/title-bar.js +134 -0
- package/dist/styles/recipes/toggle.d.ts +10 -0
- package/dist/styles/recipes/toggle.js +20 -0
- package/dist/styles/register-embedded-font.d.ts +64 -0
- package/dist/styles/register-embedded-font.js +30 -0
- package/dist/text-control.d.ts +131 -0
- package/dist/text-control.js +83 -0
- package/dist/tile-grid.d.ts +111 -0
- package/dist/tile-grid.js +53 -0
- package/dist/toggle-control.d.ts +60 -0
- package/dist/toggle-control.js +41 -0
- package/dist/track-width.d.ts +42 -0
- package/dist/track-width.js +30 -0
- package/dist/type-ahead.d.ts +35 -0
- package/dist/type-ahead.js +20 -0
- package/dist/zoom.d.ts +204 -0
- package/dist/zoom.js +105 -0
- package/docs/SPEC.md +2471 -0
- package/editor/vscode.html-custom-data.json +1320 -0
- package/editor/web-types.json +3237 -0
- 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."
|