vintage-frames 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/custom-elements.json +184 -20
- package/dist/components/vf-checkbox.d.ts +6 -5
- package/dist/components/vf-checkbox.js +41 -37
- package/dist/components/vf-container.d.ts +14 -14
- package/dist/components/vf-container.js +58 -28
- package/dist/components/vf-desktop.d.ts +114 -12
- package/dist/components/vf-desktop.js +119 -58
- package/dist/components/vf-fieldset.js +30 -22
- package/dist/components/vf-icon-field.d.ts +1 -1
- package/dist/components/vf-icon.js +111 -104
- package/dist/components/vf-list.js +30 -29
- package/dist/components/vf-menu-bar.d.ts +5 -0
- package/dist/components/vf-menu-bar.js +67 -43
- package/dist/components/vf-menu.js +24 -22
- package/dist/components/vf-number-field.js +32 -30
- package/dist/components/vf-progress-bar.js +28 -29
- package/dist/components/vf-radio.js +3 -4
- package/dist/components/vf-scroll-area.js +27 -26
- package/dist/components/vf-select.js +108 -90
- package/dist/components/vf-separator.d.ts +5 -5
- package/dist/components/vf-separator.js +36 -10
- package/dist/components/vf-swatch.js +47 -45
- package/dist/components/vf-text-area.js +37 -35
- package/dist/components/vf-text-field.js +20 -18
- package/dist/components/vf-window.d.ts +13 -0
- package/dist/components/vf-window.js +101 -72
- package/dist/index.d.ts +12 -1
- package/dist/index.js +61 -59
- package/dist/pattern-fill.d.ts +11 -8
- package/dist/pattern-fill.js +2 -4
- package/dist/position.d.ts +25 -7
- package/dist/position.js +6 -3
- package/dist/shell/alert.d.ts +32 -0
- package/dist/shell/alert.js +44 -0
- package/dist/shell/catalog.d.ts +168 -0
- package/dist/shell/catalog.js +278 -0
- package/dist/shell/clock.d.ts +16 -0
- package/dist/shell/clock.js +36 -0
- package/dist/shell/filing.d.ts +39 -0
- package/dist/shell/filing.js +68 -0
- package/dist/shell/finder.d.ts +116 -0
- package/dist/shell/finder.js +564 -0
- package/dist/shell/geometry.d.ts +196 -0
- package/dist/shell/geometry.js +241 -0
- package/dist/shell/index.d.ts +29 -0
- package/dist/shell/index.js +11 -0
- package/dist/shell/shell.d.ts +150 -0
- package/dist/shell/shell.js +213 -0
- package/dist/shell/state.d.ts +88 -0
- package/dist/shell/state.js +103 -0
- package/dist/shell/storage.d.ts +14 -0
- package/dist/shell/storage.js +41 -0
- package/dist/shell/windows.d.ts +215 -0
- package/dist/shell/windows.js +343 -0
- package/dist/styles/base.d.ts +3 -1
- package/dist/styles/recipes/field.d.ts +10 -6
- package/dist/styles/recipes/field.js +8 -7
- package/dist/styles/recipes/focus.d.ts +7 -3
- package/dist/styles/recipes/rule.d.ts +11 -8
- package/dist/styles/recipes/rule.js +31 -13
- package/dist/styles/recipes/scroll-rail.d.ts +7 -6
- package/dist/styles/recipes/scroll-rail.js +18 -18
- package/dist/styles/recipes/shadow.d.ts +10 -6
- package/dist/styles/recipes/shadow.js +4 -2
- package/dist/styles/recipes/stroke.d.ts +85 -0
- package/dist/styles/recipes/stroke.js +53 -0
- package/dist/styles/recipes/surface.d.ts +8 -4
- package/dist/styles/recipes/surface.js +23 -17
- package/dist/styles/recipes/title-bar.d.ts +7 -0
- package/dist/styles/recipes/title-bar.js +25 -22
- package/dist/styles/recipes/toggle.js +3 -3
- package/dist/window-chrome.d.ts +43 -0
- package/dist/window-chrome.js +13 -0
- package/dist/zoom.d.ts +9 -9
- package/docs/SPEC.md +34 -32
- package/editor/vscode.html-custom-data.json +12 -7
- package/editor/web-types.json +50 -14
- package/package.json +10 -2
package/docs/SPEC.md
CHANGED
|
@@ -48,7 +48,7 @@ Modern requirements that we deliberately keep (accessibility over purity):
|
|
|
48
48
|
- Components must render nothing surprising outside their box: no margins on `:host` by default.
|
|
49
49
|
- **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's or a dialog's *content region* (the frame's inner edge below any title bar, with no inset of its own — flow content starts there too), a stack's box, a fieldset's border interior, a scroll area's scrolled plane. The style writing rides a ReactiveController (`hostUpdated`), not an `updated()` override — component subclasses routinely skip `super.updated()` — and re-applies only when the property values changed, so an unrelated update never re-asserts a coordinate. **`origin`** (reflected string: one of nine keywords, vertical then horizontal — `top left` the default, `top center`, `top right`, `center left`, `center`, `center right`, `bottom left`, `bottom center`, `bottom right`) names which point of the host's own box the pair places. With a non-default origin the controller measures the host's border box in whole system px (w × h) and writes `left` as `calc(var(--vf-scale, 1) * (L − ⌈w·fx⌉)px)` with `fx` 0, ½ or 1, `top` likewise — the ceiling sends an odd size's leftover half toward the start, the tie rule `vf-stack`'s centering and the title patch use — and keeps the offset current with a ResizeObserver (a relabel, a late font; a zoom step changes CSS px, not system px, so the same offset comes back and nothing is rewritten), each rewrite announced as `vf-placement-change`. Never a transform: a percentage of an odd width is half a pixel, and a transform would make the host the containing block for a `position: fixed` descendant (`vf-select`'s list). `top left` is the plain path — no observer, no measurement, the same inline style. The pair is the origin point throughout: `PlacementController.seed()` returns it (the corner read from flow plus the offset, for a host with no stated pair), `moveTo()` takes the offset off before the clamp and puts it back after, so a gesture writes the point's pair and the clamp holds the box; `placementAt(clientX, clientY, child?)` on the three shadow-anchored containers folds `child`'s offset in; `fixed` folds it into its sticky thresholds. `vf-dialog` has no `origin` — its pair is the viewport's, and unset centers it. An unknown value places as `top left` and warns once per element; on its own the attribute places nothing. **`fixed`** (bare attribute, reflected) holds the placement against the *visible* region of the nearest scrolling ancestor instead of its scrolled plane: `position: sticky` in place of `absolute`, the same live offsets, the flag alone at (0,0). Sticky boxes stay in flow, so the controller erases the footprint — the host is blockified (the outer display change `absolute` makes on its own: `inline-block` → `block`, `inline-flex` → `flex`, …), `max-width: fit-content`, `z-index: 1` (over the plane's placed children), and a negative right/bottom margin equal to its own border box, kept exact by a ResizeObserver — a 0×0 margin box that flow content lays out as if it weren't there. Two engine facts stated as rules: sticky only ever pushes a box *down* from where the flow put it, so a fixed child precedes the flow content in its parent; and a scroll container that never scrolls (a plain window body is `overflow: hidden`) holds it exactly where placement would. Removing the flag returns the host to ordinary placement with the recipe unwound. `npm run verify:position`.
|
|
50
50
|
- `vf-dialog` takes the pair in **viewport** coordinates: `showModal()` puts the box in the top layer, whose containing block is the viewport rather than the nearest positioned ancestor. Unset means centered — recomputed on open and on every box/viewport/scale change, so a modal whose content upgrades after opening is never stranded; `position` is left to the UA and only the four inset/margin declarations are written.
|
|
51
|
-
- **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }
|
|
51
|
+
- **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }`, nulls on a return to flow), a public event: a page hears every move through it — a title-bar drag's steps and its release (an `outline-drag` writes once, on the release), an arrow nudge, a drop, a walk's landing, an `origin` re-measure and its own property writes — and so does a `vf-scroll-area`, which re-measures under a child dropped, nudged or set past its viewport with no code on the page. It is dispatched from the host's update with the new pair already written, so a handler that writes the pair again takes effect on the next update. `npm run verify:position` (EVENT), `npm run verify:window` (PLACEMENT EVENT). `npm run verify:icon-drag` (RE-MEASURE).
|
|
52
52
|
- **The movable contract.** A host that moves under a gesture states its own rectangle, and its positioning parent is a box with a size. Both halves are the consumer's — a component can supply neither for itself — and both used to fail quietly, so each is a one-time `console.warn` (`warnMovableContract`, latched per element like `vf-window`'s size warning). `npm run verify:position` group CONTRACT.
|
|
53
53
|
- `vf-window[movable]` states `top`/`left` **and** `width`/`height`; a WIND resource carried the whole rect. `vf-icon[movable]` states `top`/`left` only — it is not `VfSized`, its height is its content's, and `width` is the cell pitch. `vf-dialog` is exempt: `showModal()` anchors it to the viewport, and unset means centered.
|
|
54
54
|
- What the first half actually requires is being **out of flow** when the gesture starts; `top`/`left` are the kit's way and the only one that scales, but a stylesheet's own `position: absolute` satisfies it and `seed()` reads that case from the computed offsets (a faux desktop places its icons this way). A host still in flow has to be taken out of it by its first move, which reflows everything after it and collapses an auto-height parent to whatever is left. **The clamp therefore runs against the box measured once by `seed()`, not a fresh one per move**: re-reading it would hold the rest of the gesture inside a box the host never sat in, walking it to the parent's origin while the user drags the other way.
|
|
@@ -146,13 +146,14 @@ The span construction (`vfTileSize`, `tileImage`, `tileSpan`) still sizes what i
|
|
|
146
146
|
- `vfStripes` — a `.vf-stripes` class for the racing-stripe layer. Position it absolutely inside the title bar, inset `3px 1px` (top/bottom 3px, left/right 1px) so exactly six 1px stripes show at the 18px bar height, their top and bottom edges aligned with the close box's and one system px of clear white between the stripes and the frame border — the same buffer the widgets' patch ring keeps. The paint is split by engine, each side the measured best (the full three-engine matrix is in `scripts/verify-tile.mjs`): Blink — and any engine failing both gate properties — draws a 12-unit SVG (the band's 11 rows plus an empty pad row, viewBox stretched onto a 12px-tall box, crispEdges). Twelve, not eleven, because Blink pixel-snaps painted boxes to whole CSS px and an 11-system-px box has no legal CSS height at scale 3/2; 12 divides by 2 and 3, so the box never rounds at any integer display's scale, and the SVG measured whole-rhythm, close-box-registered and zero-gray at dpr 1/1.5/2/3 — retiring the `repeating-linear-gradient`, which it strictly dominates. Gecko and WebKit (gated by `@supports (-moz-appearance: none) or (-webkit-backdrop-filter: blur(1px))`, each property parsing in its one engine only) render six placed solid rows (`chromeTitleBar` supplies the spans): both engines device-snap solid quads exactly, and each misrendered the gradient — Gecko's GPU pipeline softens a hard stop at default zoom, WebKit landed the sixth stripe a device row thin at dpr 3 and the zoom-minted scales.
|
|
147
147
|
- `vfDots` — the windoid bar's counterpart to `vfStripes`: a `.vf-dots` layer inset `2px` top/bottom and **flush left/right** (the close-up reference runs the dots into the side borders; the `Windows/` sheet's 2px side inset is the artist's, not the bar's), a 2×2 motif carrying one black pixel at its origin (`--vf-dots-pattern` to retheme). The layer's background is the kit tile `vf-window` writes (see *Tiled fills*); a `--vf-dots-pattern` token renders as the placed tile grid in a window with a declared width, and as a CSS repeat on its 30-px span in one without.
|
|
148
148
|
- `vfTileSize` / `tileImage` — the span a CSS-REPEATING fill takes, and its art. Every metric in the kit is `calc(var(--vf-scale, 1) * Npx)`, and paint snaps each box to the device grid on its own — but a tiled fill is one snapped box holding N *unsnapped* repeats, each placed at `k × tileSize`, so a tile size the layout grid cannot hold drifts a fraction of a device pixel further with every repeat. Load-bearing for consumer tokens' documented tiles and the forced-colors masks; the kit's own art repeats a 120-system-px tile instead (`vfRepeatTileSize`, `repeatTile`). See *Tiled fills* below.
|
|
149
|
-
- `
|
|
150
|
-
- `
|
|
151
|
-
- `
|
|
152
|
-
- `
|
|
153
|
-
- `
|
|
149
|
+
- `vfStrokeDecls` / `vfStrokeInset` / `vfStrokeShadow` — every 1-bit line the kit draws (frames, wells, rules, rails, widgets) is a **stroke**, not a CSS `border`: Safari rounds a border width to device px before it applies page zoom, so a `calc(var(--vf-scale, 1) * 1px)` border draws thinner than the art around it at most zoom levels and moves everything inside the box off the device grid. `vfStrokeDecls({ edges, width, padding, shadows, ink })` puts the line's space in padding on each stroked edge (added to the element's own `padding`) and paints it with an inset `box-shadow`, `shadows` after it in the same list; neither length is rounded before zoom, so the line lands on whole device px at every zoom. The declarations own the element's padding on the stroked edges and its `box-shadow`; the color goes through a private property the declarations set, so a `var()` passed as `ink` recolors it from a state rule. What moves with the space: a child placed against the box measures from its outer edge, so its inset on a stroked edge adds the stroke — `vfStrokeInset(base)`; an `overflow: hidden` box clips at its outer edge, so a box whose content could paint over its stroke clips on an inner element (vf-select's panel, vf-window's header and status slots). Under `forced-colors: active`, which paints no box-shadow, each stroke is a border again and the padding the element's own — the box a border draws, in the engines that have forced colors (Safari has none). `vfStrokeShadow(width)` restates only the painted width (vf-checkbox's press). Exported for consumer boxes, for the same reason the kit uses it.
|
|
150
|
+
- `vfHardShadow` / `vfHardShadowDecls` — the hard 1-bit drop shadow: `var(--vf-shadow-offset, 2px) var(--vf-shadow-offset, 2px) 0 0 var(--vf-black, #000)`. No blur, no spread — System 7's only depth cue. `vfHardShadow` is the value, which a stroked surface passes to `vfStrokeDecls` as `shadows`; `vfHardShadowDecls` is the whole declaration, for a box with no stroke. Every raised surface in the kit uses this one value (`vfPanel`, `vfChromeFrame`).
|
|
151
|
+
- `vfPanel` — a `.vf-panel` class for menus/popups: white bg, a 1px black stroke with `vfHardShadow` after it.
|
|
152
|
+
- `vfChromeFrame` — a `.vf-frame` class for the desktop window (`vf-window`): the same white face, 1px black stroke and hard offset shadow as `vfPanel`, no bevels. Skin only — the component supplies its own layout (a full-size flex column). `vf-dialog` does not use it: both of its chromes are `vfModalFrame`.
|
|
153
|
+
- `vfRule` — the 1px rule on one edge of a box, one class per edge: `.vf-rule-top`, `.vf-rule-right`, `.vf-rule-bottom`, `.vf-rule-left`, each a stroke on that edge (`vfStrokeDecls`) scaled by `--vf-scale`. It sits inside the box's `border-box` size, and in-flow content begins inside the rule, as a rectangle's interior begins inside FrameRect's line; children placed against the box and percentage fills measure from its outer edge, so a component that promises them the interior gives them an inner box (vf-container's slot). The classes compose on one element — each edge's shadow goes through its own private property and one rule paints all four — so they own the element's `box-shadow` and its padding on their edges. `vf-menu-bar`'s floor and `vf-window`'s status-strip ceiling are these classes, and `vf-container rule="…"` takes the edges by name. The attribute grammar ships beside it (`RULE_EDGES`, `parseRule`, `ruleClasses`): edge names separated by whitespace in any order, repeats dropped, returned in shorthand order; a blank or unset value is no edges; any other token refuses the whole value, as `parsePattern` refuses an unrecognized pattern. `npm run verify:rule` asserts a container's rule against the menu bar's own at four densities.
|
|
154
|
+
- `vfModalFrame` — the dBoxProc modal-dialog frame, both `vf-dialog` chromes: `.vf-modal-frame` (white face, 1px black stroke, **no shadow**) around `.vf-modal-frame-inner` (`margin: 2px`, a 2px stroke). **The movable modal is this frame with the striped bar set into it** (traced from a 2× System 7 capture): a `.vf-title-bar` as the frame's first child sits directly under the outer rule, `margin: 0 2px` so its stripes — flush to the bar's own edge here, `inset: 3px 0` — keep the same 2px of white from the outer rule the band keeps; the inner box that follows it drops its top margin and thins its top stroke to 1px, so the bar's own 1px rule plus that stroke make the 2px band the bar's floor, continuing down the sides and along the bottom. System 7's alert box drew the mirror trace — 2px outer, 2px gap, 1px inner rule, *with* the hard shadow (`Windows/modal dialog.png` vs the alert reference) — a different chrome the kit does not ship: an alert box is a consumer composition over this frame, not a component.
|
|
154
155
|
- `vfWindowWidgets` — the title-bar window widgets (`.box` base, `.close` left / `.zoom` right at 11×11 with the 1px white patch ring, the pressed go-away sunburst, the nested zoom square), shared by `vf-window` and a `closable` `vf-dialog` so the two components' widgets match by construction. The templates that pair with it (`closeBox`/`zoomBox`/`widgetLabel`) live in `src/chrome.ts` with `chromeTitleBar`. Geometry is the standard bar's; `vf-window`'s utility variant overrides sizes under its own selector.
|
|
155
|
-
- `vfTitleBar` — the striped title bar shared by `vf-window` and `vf-dialog`: a `.vf-title-bar` row (`height: var(--vf-titlebar-height, 18px)`, 1px bottom rule, `overflow: hidden`) and the `.vf-title` patch inside it (display face, white bg, `padding: 0 6px`, `z-index: 1` over the stripes, `nowrap` + ellipsis). Put a `.vf-stripes` layer in as the bar's first child.
|
|
156
|
+
- `vfTitleBar` — the striped title bar shared by `vf-window` and `vf-dialog`: a `.vf-title-bar` row (`height: var(--vf-titlebar-height, 18px)`, 1px bottom rule — a stroke, so the recipe adds it to the texture layers' bottom insets — `overflow: hidden`) and the `.vf-title` patch inside it (display face, white bg, `padding: 0 6px`, `z-index: 1` over the stripes, `nowrap` + ellipsis). Put a `.vf-stripes` layer in as the bar's first child.
|
|
156
157
|
- Title geometry is whole system px, traced from the InfiniteMac reference: the face's 16px line box on interior rows 1..16 (`align-items: flex-start` + 1px top margin, `line-height: var(--vf-line-height-display, 16px)`) lands the 9px cap band on rows 4..12 — 4px of white above and below — and 6px padding plus the letters' 1px side bearing leaves 7px of white between ink and stripes. Neither axis is flex-centered onto a fraction: vertically the row is stated, horizontally the flex-centered patch is held on the placement lattice by `TitleCenterController` (src/chrome.ts), which cancels the parity half-pixel through the controller-owned `--vf-title-dx`.
|
|
157
158
|
- The title's clearance for anything else in the bar is `--vf-title-inset` (default 16px); `vf-window` sets 60px so an ellipsized title can't run under its close/zoom widgets.
|
|
158
159
|
- `touch-action` is deliberately NOT in the recipe: `vf-dialog`'s bar is always a drag handle, `vf-window`'s only when `[movable]`, and suppressing touch scrolling on a bar that can't be dragged would be a behavior change.
|
|
@@ -163,7 +164,7 @@ The span construction (`vfTileSize`, `tileImage`, `tileSpan`) still sizes what i
|
|
|
163
164
|
- `vfFocusUnderline` is the alternative for a control that can carry the mark on its own face: an `::after` on that element, 1 system px tall, spanning its box, dashed 1px on / 1px off via a `repeating-linear-gradient` in `currentColor` — so it inverts to white with the label on a pressed face. The component suppresses the UA outline in the same rule set, and the underlined element needs `position: relative`. Two placements:
|
|
164
165
|
- **Inside the control**, under the ink it marks: `vf-button` underlines its label, `vf-checkbox` its box, `vf-radio` its circle (narrowed to 9 of its 13px, since that shape is round — §5), the three editable fields their well (via `vfField`'s `.vf-field-well`) and `vf-menu` its bar title.
|
|
165
166
|
- **Below the control**, under its whole box, where there is no room inside: a `vf-select`'s single line is already the label and the ▼, every pixel inside a `vf-swatch` is the color it exists to show, and a `vf-slider`'s only interior is the handle — which moves, so marking it marked the *value* rather than the control. `vf-select` always casts the hard shadow and `vf-swatch` does under `shadow`, which the rule has to clear — ink lying outside every box the pseudo-element could size itself to; the slider's runs the rail's full width and the handle occludes it in passing (z-index 1), exactly as it occludes the rail behind it.
|
|
166
|
-
- `--vf-focus-underline-offset` places it, measured (like any `bottom`) from the element's **padding** box up to the rule's bottom edge, so positive insets it and negative drops it below. The contract is one blank system px row between the rule and the ink above it, so a negative offset counts every row of ink in between: `4px` (the default) for a text box, whose ink stops at the baseline 6px above its bottom (2px half-leading over the 16px em + the 4px descent); `-2px` for a well whose ink ends at its own bottom edge (the radio's circle, a field's wrapper) and for `vf-menu`'s title box; `-3px` for
|
|
167
|
+
- `--vf-focus-underline-offset` places it, measured (like any `bottom`) from the element's **padding** box up to the rule's bottom edge, so positive insets it and negative drops it below. The contract is one blank system px row between the rule and the ink above it, so a negative offset counts every row of ink in between: `4px` (the default) for a text box, whose ink stops at the baseline 6px above its bottom (2px half-leading over the 16px em + the 4px descent); `-2px` for a well whose ink ends at its own bottom edge (the radio's circle, a field's wrapper, the checkbox's box and the text area's well — a stroke is inside the padding box the pseudo-element sizes to) and for `vf-menu`'s title box; `-3px` for `vf-select`, adding its 1px hard shadow; `3px` for `vf-slider`, counting back up from the track's bottom to a row under the rail; and for `vf-swatch` a `calc()` that composes the depth actually in play — `0px` flat, a rethemeable `--vf-shadow-offset` under `shadow` — rather than hard-coding one. The stroked carriers state their `bottom`/`left`/`right` with `vfStrokeInset`, which adds the stroke back under forced colors, where it is a border and the padding box sits inside it. `npm run verify:focus`.
|
|
167
168
|
- `vf-menu` is the one that anchors to a **box** rather than to the baseline the button uses: its title box is shrunk to the face's own em (`line-height: 1`), whose bottom edge is the descent line, so one offset serves both a descender and the Apple menu's slotted 16px `vf-img`. The button's rule, one row under the baseline, is crossed by both.
|
|
168
169
|
- The two controls that **drop open** — `vf-menu` and `vf-select` — draw the rule only while closed. An open menu or list already shows where focus is (a whole inverted cell, a dropped panel), so the rule is reserved for the closed state. Both keep the underlying keyboard-focus state through the open state, so the rule returns by itself on close.
|
|
169
170
|
- The mark is **keyboard-only** everywhere, but four controls can't say so with `:focus-visible`, for two opposite reasons:
|
|
@@ -195,22 +196,23 @@ Every recipe also declares its size — `width`, and `height` where the window i
|
|
|
195
196
|
|
|
196
197
|
#### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
|
|
197
198
|
Full-bleed classic desktop container.
|
|
198
|
-
- **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it.
|
|
199
|
-
- **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/ `height` to the largest whole-system-px raster whose host box — bezel included — fits the bound, per the current effective scale, and returns `{ width, height }`. The page's half of the sizing contract: call it on `resize` and `onScaleChange` (zoom and density moves change what a system px costs in CSS px), as a full-screen faux desktop does. `clearActive()` → deactivate the whole document tier (see *Deactivation* under Behavior). `activeWindow: HTMLElement | null` (read-only) → the active document-tier window, or `null` while the tier is deactivated or
|
|
199
|
+
- **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it. `window-top: number` (`windowTop`, system px from the screen's top, unset by default) — the least `top` a window may take, for a page that keeps a strip of its own under the menu bar, over the windows: a window dragged on the desktop stops there and `windowArea` starts there; icons still stop at the menu bar; below the bar's bottom it changes nothing.
|
|
200
|
+
- **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/ `height` to the largest whole-system-px raster whose host box — bezel included — fits the bound, per the current effective scale, and returns `{ width, height }`. The page's half of the sizing contract: call it on `resize` and `onScaleChange` (zoom and density moves change what a system px costs in CSS px), as a full-screen faux desktop does. `clearActive()` → deactivate the whole document tier (see *Deactivation* under Behavior). `activeWindow: HTMLElement | null` (read-only) → the active document-tier window, or `null` while the tier is deactivated or has no shown window; utility windows and hidden windows are never the holder. `stackingOrder: HTMLElement[]` (read-only) → the slotted windows in stacking order, bottom-most first — the document tier, then the floating tier, each by its last raise — hidden windows included where they stand; a fresh array per read. The light-DOM order catches up with it only after a gesture, and a raise by keyboard focus moves no node, so a page that saves window depths or picks the window behind another reads this rather than the DOM. `workArea` (read-only) → `{ left, top, width, height }` in system px from the screen's corner, bezel excluded: the screen less a slotted, shown `vf-menu-bar` along its top edge (measured at the call; the whole screen without one). `windowArea` (read-only) → the work area deepened to `window-top` where that is lower — the area windows are dragged in. `placementAt(clientX, clientY, child?)` → a viewport point as a placement on the screen, `{ left, top }` in whole system px on the placement lattice, measured from the raster's corner with the bezel excluded — the pair a child dropped onto the desktop is written with; with `child`, the pair to write to that element, its `origin` offset folded in (`placementIn`, src/position.ts; the anchor sits in shadow, which is why it is a method).
|
|
200
201
|
- **Visual:** `display: block; position: relative;` — the paint lives on an inner screen surface (part `desktop`, `overflow: hidden` — the whole-system-px raster, inset by `bezel` when one is set). Screen surface = the `pattern` (the classic 50% dither, `gray-50`, by default), painted as the screen's own background by the pattern fill (*Patterns* under *Tiled fills*): black ink on the fill's opaque `--vf-white` paper, a repeating tile — the authentic black-on-white dither. A consumer `--vf-desktop-pattern` renders instead as a placed tile grid on the token's 30-system-px tile with no paper, so `var(--vf-desktop, #808080)` beneath shows only under a custom tile with transparent cells (or `none`). Forced colors: flat Canvas.
|
|
201
202
|
- **Slots:** default (menu bar, windows, anything).
|
|
202
203
|
- **Behavior:** manages stacking of slotted `vf-window` children: `pointerdown` *or `focusin`* on a window brings it to front (incrementing z-index counter) and sets its `active` attribute, clearing `active` on the others — the `focusin` half is the keyboard route to activation: Tab landing anywhere in a background window (its undrawn-but-focusable widgets included) raises it. Listens via delegated pointerdown/focusin listeners + `slotchange`. Windows slotted before `vf-window` is defined are re-normalized once `customElements.whenDefined('vf-window')` settles, since the upgrade reflects each window's `active = true` default back out and upgrading a slotted node doesn't re-fire `slotchange`.
|
|
203
|
-
- **DOM order follows z-order** (bottom-most first), so sequential focus order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync runs in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); focus
|
|
204
|
-
- **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant`
|
|
204
|
+
- **DOM order follows z-order** (bottom-most first), so sequential focus order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync runs in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); the move is `Element.moveBefore()` where the browser has it, which keeps the focus inside the moved window — a rename field, a caret — and otherwise a re-insert, after which the focus is restored without re-raising that window (a `vf-icon` rename whose field blurred in the move and has its focus back in the same task commits nothing). Non-window children (a menu bar, page content) keep their positions. `npm run verify:window-a11y`.
|
|
205
|
+
- **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant` property of an upgraded window and the attribute of one not yet upgraded, so both land right — and so does a palette made by script and appended at once, which is slotted before its attribute reflects. `npm run verify:desktop-activate` (SCRIPTED).
|
|
205
206
|
- **Menu tier:** a slotted `vf-menu-bar` — or a free-standing `vf-menu` placed on the desktop — is restated at `z-index: 2_000_000` by the desktop's own `::slotted(vf-menu-bar), ::slotted(vf-menu)` rule (an outer-tree `::slotted` declaration beats the bar's inner `:host { z-index: 1000 }`), a band above the floating tier, so a dropped menu paints over palettes and document windows alike — the Menu Manager drew menus over every window. Only the drag outline, a window's opening rects and the screen-corner mask are in front. The full stack inside the screen's isolated context: consumer tile grid (−1) < document windows (counter) < utility windows (counter + 1_000_000) < menu tier (2_000_000) < drag outline and window rects (2_000_001) < corner mask (max). A popup *inside* a window (`vf-select`'s fixed panel) is a different case: it stacks within its window's context, so a palette can still cover it — KNOWN-BUGS.md. `npm run verify:archetypes` (MENU TIER).
|
|
206
207
|
- **The drag surface:** the Finder drew a dragged icon's outline on the *screen*, clipped at its edge and phase-locked to its raster, so the screen renders the layer that outline draws on — `.drag-surface`, a full-bleed child of `.screen` after the slot, `pointer-events: none` (never a hit for the page's `elementsFromPoint`), inside the raster's clip. It is deliberately no stacking context (no z-index of its own): a stacking context is an isolated group, and the outline's `mix-blend-mode` would then composite against the empty layer instead of the windows and dither beneath; the tier goes on its children (`.drag-surface > * { z-index: 2_000_001 }`), which places the outline in `.screen`'s own context, above the menu bar and under the corner mask. An icon reaches it through the `vf-drag-surface-request` handshake (§5 vf-icon): the desktop listens on its host, and the nearest desktop fills a still-null `detail.surface`. A window asks the same way and draws there with the same pen: its zoom rects when it opens from or closes to a rect (§5 vf-window, `show` and `hide`), its outline under `outline-drag`. Not the top layer: stable Safari cannot blend an element there against the page (`XOR_UNAVAILABLE`, src/cursor.ts), and a second kit entry in the top layer would need the re-promotion handshake KNOWN-BUGS.md #1 describes. `npm run verify:icon-drag` (OUTLINE).
|
|
207
|
-
- **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
|
|
208
|
+
- **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). **A hidden window never holds the active state.** A page that closes windows by hiding them (`vf-window.hide()`, or the `hidden` attribute set directly — the desktop watches the attribute on every slotted window) gets no activation from the hidden ones: a window slotted hidden waits to be shown, `bringToFront()` on one gives it its place in the stack and activates nothing, and the holder hiding hands the state to the topmost shown document window, or to none. A window shown into a tier where none is active takes the state, unless the tier was deliberately deactivated. Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
|
|
209
|
+
- **The work area:** a slotted `vf-menu-bar` along the screen's top takes its band off the screen (`workArea`), and the gestures on the screen stay below it: a window dragged up stops with its title bar under the bar — or at `window-top`, for windows alone (`windowArea`) — where it can still be grabbed, and an icon dropped, nudged, moved (`moveTo`, `dragTo`) or carried in a group on the desktop lands below the bar. A gesture measures the box it is clamped into once, at its start (`PlacementController.seed()`), by dispatching `vf-placement-bounds-request` (bubbles, non-composed — the drag surface's handshake); the desktop answers for a host placed on its screen (`offsetParent` the desktop: a slotted window, an icon in an unplaced field) with the screen's size, bezel excluded, and the area's top as the floor, rounded up to the host's placement lattice so a snapped landing never rounds above it (21 on a lattice of 3). A host placed in a box of its own — a window body, a placed field — is clamped in that box, from 0. `npm run verify:window` (WORK AREA), `npm run verify:icon-drag` (WORK AREA).
|
|
208
210
|
- **Events:** `vf-activate` (detail `{ window: HTMLElement | null }` — the new holder, `null` on deactivation).
|
|
209
211
|
- **Parts:** `desktop`.
|
|
210
212
|
|
|
211
213
|
#### `vf-window` (`VfWindow`, vf-window.ts)
|
|
212
214
|
The desktop-window shell: the classic document window (see DragThing screenshot), parameterized down to the windoid (see the Group A recipe table).
|
|
213
|
-
- **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default true), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule).
|
|
215
|
+
- **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default true), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule). Read-only: `sizeLimits` → `{ minWidth, minHeight, maxWidth, maxHeight }`, the sizeRect as it applies in whole system px — the declared mins or the 80×54 floor, the declared maxes or `Infinity` — for a page that sizes the window itself to stay in the grow box's range. The chrome around the content is `windowChrome()` (§2, src/window-chrome.ts): the insets in system px between the outer box and the body, or under `scrollbars` the scrolled plane's viewport, for a set of declared options — `{ variant, scrollbars, headerHeight, status }` — at the default tokens. `npm run verify:window` (CHROME).
|
|
214
216
|
- **Visual:** `vfChromeFrame` + `vfTitleBar` (§4), plus a full-size flex-column layout on the frame — title bar, header, body, status strip. `display: block`. Sets `--vf-surface: var(--vf-white, #fff)` on itself.
|
|
215
217
|
- Title bar: from `vfTitleBar` — height `var(--vf-titlebar-height, 18px)`, white bg, bottom `1px solid black`, contains `.vf-stripes` layer (only when `active`). `touch-action: none` only when `[movable]`.
|
|
216
218
|
- Title: centered, bold, on a white patch (`padding: 0 6px`; cap band on interior rows 4..12, 7px of white between ink and stripes — see §4 `vfTitleBar` for the traced geometry and the lattice hold) above the stripes, with `--vf-title-inset: 60px` of clearance so it ellipsizes before reaching the widgets. Inactive: no stripes, widgets undrawn (transparent ink — they keep their tab stops; see A11y below), the grow box's nested squares hidden, and every managed scroll rail inside the window blanked (see "always-a-rail" §5 vf-scroll-area) — but the title text stays black (System 7 never grayed the window title).
|
|
@@ -218,8 +220,8 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
|
|
|
218
220
|
- Zoom box: RIGHT side, same box, plus a small box nested in its top-left corner (sharing the widget's own top/left border; only the right and bottom edges are drawn). `:active` (pressed) → shows the identical sunburst as the close box; the nested box gives way to it.
|
|
219
221
|
- Body: no inset of its own — content starts at the content region's corner, the frame's inner edge below the title bar, where a placed child's (0,0) is; an inset is the content's (a `vf-stack pad`) — and `overflow: hidden` — the window being a fixed box, content taller than it is clipped at the frame the way the classic content region was, rather than painting out over the desktop; `scrollbars` is how the user reaches the rest. Two deliberate exemptions: `[scrollbars]` restores `overflow: visible`, because the edge-rail composition below pulls the scroll area one system px *outside* the body on every side and clipping would shave exactly that overhang off (the scroll area does its own clipping); and a control's drop-open panel is not clipped — `vf-select`'s list is `position: fixed` off the control's rect precisely to escape clipping ancestors (§5 vf-select), and it still escapes, because nothing between it and the viewport establishes a containing block for fixed descendants (the grid-snap correction is a `position: relative` left/top offset, never a transform — see §7). A `vf-menu` panel is anchored `position: absolute` and *would* clip, but a menu bar belongs to the desktop, not inside a window body.
|
|
220
222
|
- Grow box (if `resizable`): 15×15 at bottom-right corner, white bg, 1px black top/left borders, containing two overlapping small square outlines. Inactive: the cell and its borders stay, the nested squares go — System 7 drew a deactivated window's size box hollow, with its blanked scroll rails.
|
|
221
|
-
- Header (`header` slot): a band between the title bar and the body, the full width of the window — the Finder window's header line — a white interior over a 1px black rule (`vfRule`'s `.vf-rule-bottom`), part `header` (a `<div>`, never a `<header>` element, for the banner-landmark reason the title bar is a `<div>`). Like the body it carries no inset and is a positioning anchor: (0,0) is the header's own corner, flow content starts there, an inset is the content's (a `vf-stack pad`). As tall as its content unless `header-height` states it (rule included, the way every kit bar counts its rule). Clipped like the body; a drop-open panel still escapes. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails sit in the body below it, the area's top overhang landing its frame line exactly on the header's rule, so the header spans the vertical rail's column and the rail's top arrow begins under it.
|
|
222
|
-
- Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule (`vfRule`'s `.vf-rule-top`) over a white interior, `--vf-status-bar-height` (15px) in all, the grow box's own height, so a `resizable` window's grow box sits flush in the strip's right end (its top/left borders take over the rule there; the strip reserves 21px of right padding under `resizable` so text clears the cell). Slotted content rides the body face's native 12px line (`--vf-line-height`), whole-pixel centered in the 14px interior, `padding-inline: 6px`, nowrap + clipped. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails' bottom overhang lands its frame line exactly on the strip's rule, so the two never double up — though the grow box then sits in the strip rather than the rail corner cell.
|
|
223
|
+
- Header (`header` slot): a band between the title bar and the body, the full width of the window — the Finder window's header line — a white interior over a 1px black rule (`vfRule`'s `.vf-rule-bottom`), part `header` (a `<div>`, never a `<header>` element, for the banner-landmark reason the title bar is a `<div>`). Like the body it carries no inset and is a positioning anchor: (0,0) is the header's own corner, flow content starts there, an inset is the content's (a `vf-stack pad`). As tall as its content unless `header-height` states it (rule included, the way every kit bar counts its rule). Clipped like the body, above the rule — the rule is a stroke, so the slot is the box content lays out, places and clips in; a drop-open panel still escapes. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails sit in the body below it, the area's top overhang landing its frame line exactly on the header's rule, so the header spans the vertical rail's column and the rail's top arrow begins under it.
|
|
224
|
+
- Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule (`vfRule`'s `.vf-rule-top`) over a white interior, `--vf-status-bar-height` (15px) in all, the grow box's own height, so a `resizable` window's grow box sits flush in the strip's right end (its top/left borders take over the rule there; the strip reserves 21px of right padding under `resizable` so text clears the cell). Slotted content rides the body face's native 12px line (`--vf-line-height`), whole-pixel centered in the 14px interior, `padding-inline: 6px`, nowrap + clipped — vertically by the slot, below the rule. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails' bottom overhang lands its frame line exactly on the strip's rule, so the two never double up — though the grow box then sits in the strip rather than the rail corner cell.
|
|
223
225
|
- Edge scroll rails (if `scrollbars`): the body slot renders inside a shadow `vf-scroll-area` (its `axis` = the attribute's value, `label` = the heading, `viewport` part re-exported) carrying the TeachText composition internally — `calc(100% + 2px·scale)` with `margin: -1px·scale`, one system px under the frame on every side, so the area's own frame border repaints the window's border lines and a `resizable` window's grow box (z-index 1) lands exactly over the rail-corner cell. the area's viewport carries no inset, so content and the (0,0) of placed children sit at the content region's corner; a `resizable` window sets the area's `corner`, so a single-axis rail stops at the corner cell too — unless the status strip is populated, when the grow box sits in the strip and the rail runs edge to edge onto the strip's rule. Same caveats as the slotted composition (see vf-scroll-area §5).
|
|
224
226
|
- Utility variant (`variant="utility"`): the slim windoid bar traced from `Windows/utility-window.png` — `--vf-titlebar-height-utility` (12px = 11px interior + 1px rule), the `vfDots` dither instead of stripes (flush to the side borders — see §4 vfDots), 7×7 widgets (`top: 2px`; close `left: 7px`, zoom `right: 8px` — the art is asymmetric by that pixel) with a 2px patch ring where the striped bar's is 1px (`--vf-widget-ring`, internal geometry: the windoid sheet clears two px of dither beside its widgets), and the nested zoom square shrunk so its edges land at sprite col/row 3. No title patch: the display face's 16px line box can't sit in an 11px interior, so `.vf-title` is `display: none` under the variant (a retheming consumer can re-show it via `::part(title)`) and the heading names the widgets. A pressed windoid widget inverts whole — black interior under a white (invisible) borderline — rather than flashing the big bar's 9×9 sunburst, which can't land on a 5×5 interior.
|
|
225
227
|
- **A11y:** the close/zoom `aria-label`s are qualified by the title when there is one (`Close ${heading}` / `Zoom ${heading}`, falling back to bare `Close` / `Zoom`) — several windows are open at once by design, so a bare repeated "Close" gives an AT user no way to tell which window a widget belongs to. The frame is `role="group"`, named by the title patch via `aria-labelledby` when there is a heading (the utility variant's hidden patch still names it — AccName resolves hidden labelledby targets) — `group` rather than `region` deliberately, so a desktop of windows doesn't pollute landmark navigation; the title bar is a `<div>`, never a `<header>`, which would map to an unnamed `banner` landmark even inside the shadow root. An inactive window's widgets stay in the tree and the tab order but paint no ink (transparent border/background/patch ring — the bare System 7 bar): a background window whose body holds nothing focusable is still reachable, activated by `vf-desktop`'s `focusin` raise the moment Tab lands on a widget, and never drops focus to `<body>` when it deactivates. `npm run verify:window-a11y`.
|
|
@@ -245,7 +247,7 @@ The modal-dialog shell: movable modal by default (the dBoxProc double frame with
|
|
|
245
247
|
System 7's fixed modal alert (double frame: 2px outer, 2px gap, 1px inner rule, *with* the hard shadow) is deliberately not shipped as a component. What separates an alert from a modal dialog is a *picture* — the 32×32 icon — and pictures are the consumer's assets, never the library's (see the glyph-sprites note at the top of this spec). An alert box is composed from the shells above: `vf-dialog frame="plain"` with `label` stated (there is no title bar to name it), a row `vf-stack` slotting the consumer's own 32×32 art through `vf-img`, display-face copy (System 7 alerts used chrome type), and a `vf-button-group`, each placed with `top`/`left`. The reference page carries the live recipe, composed from `demo/icons/alert.png`.
|
|
246
248
|
|
|
247
249
|
#### `vf-separator` (`VfSeparator`, vf-separator.ts)
|
|
248
|
-
- 1px black rule. `vertical: boolean` attr → 1px wide, auto height. Horizontal default: `display: block; height: 1px
|
|
250
|
+
- 1px black rule. `vertical: boolean` attr → 1px wide, auto height. Horizontal default: `display: block; height: 1px`, the line the inner element's own background, `var(--vf-separator-color, var(--vf-black, #000))`. `--vf-separator-style` is `solid` (default) or `dotted` — one system px on, one off, from the rule's start, a `repeating-linear-gradient` selected by a style query on the token. Inside menus it renders as the classic dimmed **dotted** rule spanning the full panel width (see Menus.png): the menu panel sets the two props to `--vf-disabled` / `dotted`, and spends a full `--vf-menu-row-height` on the divider: 8px margin above the rule, 7 below. Under forced colors the line is a border in the token's style, as every stroke is. `role="separator"`.
|
|
249
251
|
|
|
250
252
|
### Group B — buttons & toggles
|
|
251
253
|
|
|
@@ -279,23 +281,23 @@ The color-swatch button: a well of solid color — a palette cell.
|
|
|
279
281
|
- **Visual:** inner `<button>` sized `width × height`: 1px black border, 1px white inset (the button's own padding + background) and a `fill` span carrying the checker as an exact fill with the color painted over it in a `tint` child (see *Tiled fills*); `--vf-swatch-checker` overrides the pattern.
|
|
280
282
|
- `shadow`: adds the shared hard shadow (`vfHardShadowDecls` — the same `--vf-shadow-offset` token as windows and menus, painting outside the box like theirs). **Opt-in, because the swatch's usual home is a table of them** (a `vf-grid`, a picker row), where every cell shadowing its neighbour reads as noise rather than depth; the lone well standing in for a current color is the case that wants the raised reading. The depth in play is resolved once into a private `--_shadow-depth` (`0px` unset), so the focus rule below composes what is actually there.
|
|
281
283
|
- `:active` (pressed, not disabled): the white inset inverts to black — the inset counterpart of vf-button's face inversion.
|
|
282
|
-
- `:focus-visible`: **no ring** — the UA outline is off on both the inner button and the (`delegatesFocus`) host, replaced by `vfFocusUnderline` (§4) below the whole box: a `calc()` offset that clears
|
|
284
|
+
- `:focus-visible`: **no ring** — the UA outline is off on both the inner button and the (`delegatesFocus`) host, replaced by `vfFocusUnderline` (§4) below the whole box: a `calc()` offset that clears `--_shadow-depth`, then leaves the blank row — so the rule lands one row under a flat swatch's frame and one under a shadowed one's shadow. Not inside the box the way vf-button underlines its label — every pixel in there is the color the swatch exists to show, and a rule over the fill would read as part of it. Spans the border box, which is the padding box the pseudo-element sizes to (the frame is a stroke). `npm run verify:focus` checks both depths.
|
|
283
285
|
- `disabled`: interaction stops; nothing dims. The kit dims *labels* when disabled, and a swatch's only label is its fill, which must keep reading as its color.
|
|
284
286
|
- **Behavior:** "basically a button": native `click` retargets to the host, Enter/Space activate via the inner button, `delegatesFocus`. `label` feeds the inner button's `aria-label`, so `vf-label for` reaches it like every control. Form-associated (`VfFormControl`) **for the disabled contract alone**: an ancestor `<fieldset disabled>` reaches the palette through `formDisabledCallback` (and `:state(form-disabled)`) like every other control — but it submits nothing (a palette cell picks, it doesn't submit; no value is ever set, so no `FormData` entry and no `formResetCallback`). Carries a `ScaleController` and a `GridSnapController`.
|
|
285
287
|
- **Slots:** none. **Parts:** `button`, `fill`. **Events:** none custom.
|
|
286
288
|
|
|
287
289
|
#### `vf-checkbox` (`VfCheckbox`, vf-checkbox.ts)
|
|
288
290
|
- **Attributes/props:** `checked`, `disabled`, `name`, `value` (default `'on'`).
|
|
289
|
-
- **Visual:**
|
|
291
|
+
- **Visual:** 12×12 white box, `1px solid black`, no radius — the sprite sheet's own size; checked = classic ✕: the pixel-exact cross from the sprite sheet filling the 10×10 interior corner to corner, rendered as the `CHECKBOX_X` inline-SVG fill path (`shape-rendering: crispEdges`, `fill: currentColor`) — no anti-aliased strokes. The 12×12 glyph covers the box exactly, its transparent 1px margin over the frame. The box is centered in the 20px row, which puts it on row 4 exactly; it ends on row 15, as the radio's well does. Label (slot) sits right with 6px gap, bold. Disabled: only the label dims to `var(--vf-disabled, #c0c0c0)`; the box border and ✕ glyph stay black. Pressed (`:active` on box): border thickens to 2px (classic press feedback).
|
|
290
292
|
- **Behavior:** form-associated; toggles on click and Space; `role="checkbox"`, `aria-checked`; focusable (tabindex 0 on the host).
|
|
291
|
-
- **When the toggle runs.** At the *end* of the click's propagation, like a native checkbox's activation behavior — so `preventDefault()` on the control or anything above it stops the state change, in either phase. `stopPropagation()` cancels nothing (HTML doesn't), and the disabled gate sits at the far end of the deferral, so a control disabled *during* propagation never acts. Space synthesises a cancelable click and inherits all of it. Shared with `vf-radio` through `VfToggleControl`, and with `vf-button` and the menus through `deferActivation` (src/events.ts). `npm run verify:toggle`, CANCELLATION group. `:focus-visible` marks the **box**, not the label and not either with a ring: `vfFocusUnderline` (§4) at
|
|
293
|
+
- **When the toggle runs.** At the *end* of the click's propagation, like a native checkbox's activation behavior — so `preventDefault()` on the control or anything above it stops the state change, in either phase. `stopPropagation()` cancels nothing (HTML doesn't), and the disabled gate sits at the far end of the deferral, so a control disabled *during* propagation never acts. Space synthesises a cancelable click and inherits all of it. Shared with `vf-radio` through `VfToggleControl`, and with `vf-button` and the menus through `deferActivation` (src/events.ts). `npm run verify:toggle`, CANCELLATION group. `:focus-visible` marks the **box**, not the label and not either with a ring: `vfFocusUnderline` (§4) at `-2px`, a dashed rule spanning the box's full 12px, one blank row under its border — the box's 1px line is a stroke, inside the padding box the pseudo-element sizes to. `npm run verify:focus`.
|
|
292
294
|
- **Slots:** default (label). **Parts:** `box`, `label`.
|
|
293
295
|
- **Events:** `vf-change` detail `{ checked: boolean }`, plus the native `input`/`change` pair per user toggle (§2).
|
|
294
296
|
|
|
295
297
|
#### `vf-radio` (`VfRadio`, vf-radio.ts)
|
|
296
298
|
- **Attributes/props:** `checked`, `disabled`, `value: string`.
|
|
297
|
-
- **Visual:** 13×13 pixel circle drawn as inline SVG — the hand-tuned 1-bit `RADIO_RING` outline over a white `RADIO_FACE` disc (not `border-radius`, which anti-aliases); checked = the centered `RADIO_DOT` pixel disc. Pressed (`:active`): the ring swaps to `RADIO_RING_PRESSED` (2px-thick). The circle
|
|
298
|
-
- **Behavior:** `role="radio"`, `aria-checked`. Click → asks parent group to select it (dispatch internal event or parent listens). NOT itself form-associated — the group is. Selection defers to the end of the click's propagation and is cancellable exactly as `vf-checkbox`'s is (same `VfToggleControl` skeleton). `:focus-visible` marks the **circle** with `vfFocusUnderline` (§4) at `--vf-focus-underline-offset: -2px` — one blank row below the
|
|
299
|
+
- **Visual:** 13×13 pixel circle drawn as inline SVG — the hand-tuned 1-bit `RADIO_RING` outline over a white `RADIO_FACE` disc (not `border-radius`, which anti-aliases); checked = the centered `RADIO_DOT` pixel disc. Pressed (`:active`): the ring swaps to `RADIO_RING_PRESSED` (2px-thick). The circle's well PAINTS 3 system px below the row top: centering the 13px well in the 20px row stays the layout (host height and exported baseline unchanged), but its 3.5 tie is half a system px off the pixel grid, so the paint steps back half a pixel through the same relative `top` the grid-snap correction rides — ties resolve toward the start, QuickDraw `div 2`. It ends on row 15, as the checkbox's box does. Label right, 6px gap. Disabled dims like checkbox (label only; ring + dot stay black).
|
|
300
|
+
- **Behavior:** `role="radio"`, `aria-checked`. Click → asks parent group to select it (dispatch internal event or parent listens). NOT itself form-associated — the group is. Selection defers to the end of the click's propagation and is cancellable exactly as `vf-checkbox`'s is (same `VfToggleControl` skeleton). `:focus-visible` marks the **circle** with `vfFocusUnderline` (§4) at `--vf-focus-underline-offset: -2px` — one blank row below the 13px well, which ends on the row the checkbox's box does, so the two rules share a row in a mixed list. The 12px sprite sits half a system px proud of the well, so the gap to the circle itself reads as one row or two depending on how that rounds; the well is the anchor. The rule is also narrowed to **9** of the well's 13px (2px inset each side, 5 dashes): full width reads wider than the round shape above it, and 9 is the closest to two thirds that keeps whole-px insets and ink at both ends. Focus: inside a `vf-radio-group` the group owns the roving tabindex and is the single source of truth for `checked`; standalone, the radio self-manages its own tabindex (otherwise it would be keyboard-dead) and self-checks on activation. A consumer-authored `tabindex` always wins, and that ownership is latched on first connect so a reconnect can't mistake our own write for the consumer's.
|
|
299
301
|
- **Slots:** default (label). **Parts:** `circle`, `label`.
|
|
300
302
|
- **Events:** `vf-change` detail `{ value }` (fired by user interaction only).
|
|
301
303
|
|
|
@@ -317,7 +319,7 @@ The color-swatch button: a well of solid color — a palette cell.
|
|
|
317
319
|
- **Events:** `vf-input` detail `{ value }` on every keystroke; `vf-change` detail `{ value }` on commit (native change). Plus the native pair per §2: the inner input's own composed `input` crosses the boundary itself; `change` is re-dispatched from the host.
|
|
318
320
|
|
|
319
321
|
#### `vf-text-area` (`VfTextArea`, vf-text-area.ts)
|
|
320
|
-
Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding, so the text and the outer box sit exactly where the bordered field puts them. The well is the same wrapper vf-text-field uses; the focus rule spans the full frame, scroll rail included (the `-
|
|
322
|
+
Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding, so the text and the outer box sit exactly where the bordered field puts them. The well is the same wrapper vf-text-field uses; the focus rule spans the full frame, scroll rail included (the frame is a stroke inside the padding box, so the `-2px` well offset, §4 vfFocusUnderline). Forwards the same input-behavior attributes as vf-text-field, minus `pattern` (only an `<input>` takes it). Parts: `textarea`. Events: `vf-input`, `vf-change`, plus the native pair (§2).
|
|
321
323
|
|
|
322
324
|
#### `vf-number-field` (`VfNumberField`, vf-number-field.ts)
|
|
323
325
|
A numeric text field paired with the classic "little arrows" stepper.
|
|
@@ -331,9 +333,9 @@ A numeric text field paired with the classic "little arrows" stepper.
|
|
|
331
333
|
The classic popup menu control ("Macintosh HD ▼").
|
|
332
334
|
- **Attributes/props:** `value: string`, `disabled`, `name`, `size: 'regular' | 'small'` (reflected; any other value renders as `regular`), `no-shadow` (`noShadow: boolean`, reflected).
|
|
333
335
|
- **Children:** `<vf-option value="...">Label</vf-option>` elements (default slot). `vf-option` (`VfOption`, vf-option.ts): props `value`, `disabled`, `selected` and `size` (both managed by parent — the select stamps its `size` on every option, including ones added later); renders its slot; `role="option"`.
|
|
334
|
-
- **Visual (closed control):** height `var(--vf-popup-height, 18px)`, white bg, `1px solid black`, NO radius, `box-shadow: 1px 1px 0 0 var(--vf-black, #000)` (the small hard shadow visible in the screenshot; `no-shadow` removes it from the pill, and the open panel keeps its own), `padding: 0 8px 0 var(--vf-select-gutter, 16px)` — the left inset equals the option checkmark gutter so the selected label sits at the same x closed or open. *One documented 1px deviation:* the reference sheets put a closed pill's label ink 16px from its border box but an open menu's at 17 (three pill instances and four menu panels, all consistent, in the reference art). System 7 reconciled that by drawing the open panel 1px left of the pill; the kit instead uses one shared gutter with no panel offset, which keeps the closed↔open alignment exact and puts the closed pill's ink at 17. Bold label left, the black `CARET_DOWN` ▼ pixel glyph (inline SVG) right with 8px gap. The ▼ stays black even when the control is disabled (only the label dims). **Width:** the control hugs the *widest* option — no intrinsic min-width — via an invisible, height-collapsed stack of every option's text sharing the label's grid cell; so the closed pill and the open panel are always exactly the same width and the value never shifts as the selection changes. Authors wanting a floor set `min-width` on the host (or grow it in their layout, e.g. `flex: 1`). **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at
|
|
336
|
+
- **Visual (closed control):** height `var(--vf-popup-height, 18px)`, white bg, `1px solid black`, NO radius, `box-shadow: 1px 1px 0 0 var(--vf-black, #000)` (the small hard shadow visible in the screenshot; `no-shadow` removes it from the pill, and the open panel keeps its own), `padding: 0 8px 0 var(--vf-select-gutter, 16px)` — the left inset equals the option checkmark gutter so the selected label sits at the same x closed or open. *One documented 1px deviation:* the reference sheets put a closed pill's label ink 16px from its border box but an open menu's at 17 (three pill instances and four menu panels, all consistent, in the reference art). System 7 reconciled that by drawing the open panel 1px left of the pill; the kit instead uses one shared gutter with no panel offset, which keeps the closed↔open alignment exact and puts the closed pill's ink at 17. Bold label left, the black `CARET_DOWN` ▼ pixel glyph (inline SVG) right with 8px gap. The ▼ stays black even when the control is disabled (only the label dims). **Width:** the control hugs the *widest* option — no intrinsic min-width — via an invisible, height-collapsed stack of every option's text sharing the label's grid cell; so the closed pill and the open panel are always exactly the same width and the value never shifts as the selection changes. Authors wanting a floor set `min-width` on the host (or grow it in their layout, e.g. `flex: 1`). **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at `calc(-2px - depth)`, where depth is the pill's shadow (1px, 0 under `no-shadow`): a dashed rule under the *whole* pill (hard shadow + blank row below the border box), not inside the face the way vf-button underlines its label: the pill's one line already holds the label and the ▼. Spans the border box, which is the padding box the pseudo-element sizes to (the pill's line is a stroke). **Closed only**, as `vf-menu`'s is: the open list already shows where focus is, and a panel short enough not to cover the rule — a one-option menu overlays the pill exactly — would leave a stray dashed line below it. Gated on a `.vf-focus-rule` class from the page's input modality, not `:focus-visible` — see §4 for why this control can't use the selector either. `npm run verify:focus`.
|
|
335
337
|
- **Visual (open):** panel uses the `.vf-panel` recipe but overrides `--vf-shadow-offset: 1px` so its hard shadow matches the pill's (not the 2px menu shadow). Positioned `position: fixed` to the control's exact width and left (unsnapped, so the panel's left/right edges and shadow coincide with the pill's). Item rows are height 16px — the pill's *content* height, derived as `calc(var(--vf-popup-height, 18px) - 2px)` so a re-themed pill moves its rows with it — `padding: 0 20px 0 var(--vf-select-gutter, 16px)`; the panel opens with the selected row's cell laid directly over the closed pill (its top border on the pill's top border, extending downward), so the selected label's position and surrounding whitespace are identical closed and open. The currently-selected item shows a ✓ checkmark in the left `--vf-select-gutter` column; hovered/active item inverts (black bg, white text); disabled options gray.
|
|
336
|
-
- **Visual (open, clipped):** a list taller than the screen is **clipped, never scrolled** — System 7 put no scrollbar on a menu. The panel is `overflow: hidden`
|
|
338
|
+
- **Visual (open, clipped):** a list taller than the screen is **clipped, never scrolled** — System 7 put no scrollbar on a menu. The panel is drawn once at a whole number of row slots, and a `.clip` element inside its stroke is `overflow: hidden` (the panel itself would clip at its outer edge, over its frame); the rows ride a `.rows` strip rolled by `transform` inside the clip, and the edge slot with items beyond it is covered by an opaque white **arrow slot** (`part="scroll-arrow"`, `aria-hidden`) carrying `CARET_UP` / `CARET_DOWN`. Traced from a real System 7 popup clipped at the screen edge (Find File's criteria menu under Infinite Mac, 2×): the 11×6 triangle sits 13px in from the panel's content edge and 5px down its 16px row, and the arrow occupies a **full** row slot. The pointer resting on an arrow rolls the list one row per `MENU_SCROLL_INTERVAL_MS` (66ms, motion.ts — the interaction itself, so *not* reduced-motion gated); the arrow retires when its direction runs out. Both can show at once. Four invariants, all in `src/popup-overflow.ts`:
|
|
337
339
|
- **The clamp is quantized to the pill lattice.** Rows are clipped a whole row at a time off `pillTop − selectedIndex × rowHeight`, so a clipped popup still opens with its selected row exactly over the closed pill — what an un-quantized pixel clamp loses. One deliberate exception: when the selected row would land *under* an arrow (the pill within one row of a screen edge with items beyond it), the scroll shifts by one and the overlay gives way by exactly one row. Rare, and purely geometric.
|
|
338
340
|
- **The panel is as tall as the *list* asked for, not as tall as the rows it can currently show** — capped only by the screen band. A list that fits the band but not where the pill would put it keeps every slot and slides whole rows, while the item strip stays welded to the pill; the slots the strip no longer reaches are drawn as **empty white**. The blank is the exact travel the list rolls through: scrolling to that end lands the strip flush with the panel, precisely full, both arrows retired. This is what System 7 drew — 5 empty rows above `name` in Find File's criteria popup, 2 above `Athens` in Character Set's font menu. **The two directions are one rule:** a panel pushed up (the pill is low) reserves its blank at the top and rolls down into it; a panel pushed down (the pill is high with a late item selected) reserves it at the bottom and rolls up into it. Only the sign of the scroll integer distinguishes them — it is free to leave the `[0, rowCount − visibleSlots]` range that would fill every slot, and that departure *is* the blank. It follows that the blank is a one-way starting position: an arrow means "rows are really hidden this way", so whichever end the blank is on, the arrow pointing back at it is off.
|
|
339
341
|
- **The panel box never moves or resizes while open:** scrolling rolls the items inside it.
|
|
@@ -364,11 +366,11 @@ The classic popup menu control ("Macintosh HD ▼").
|
|
|
364
366
|
- **Attributes/props:** `label: string` — accessible name for the menubar, mirrored as host `aria-label` (guarded: a consumer's own `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected) — draws the System 7 screen-corner mask over the bar's top-left/top-right corners. `shortcuts: boolean` (reflected) — makes every slotted menu's item `shortcut`s live key equivalents, one declaration for the whole bar the way MenuKey() answered for every menu at once (see vf-menu-item for the claim contract). Off by default: key equivalents are page-global, and only *the* menu bar should own them.
|
|
365
367
|
- **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` — 19 white system px over the 1px black rule, the Menus.png bar strip exactly — white bg, the rule `vfRule`'s `.vf-rule-bottom` on the bar (§4), children laid out horizontally from left. Adjacent titles are pulled 5px into each other (`margin-inline-start: -5px` on every slotted menu, absorbed by 14px of bar start padding — 9px of bar before the first plate, as System 7 placed the Apple title, plus the first title's 5px share): Menus.png spaces title ink 14px apart while each title's plate runs 10px left / 9px right of its own ink, so neighboring plates *overlap* by 5px, as the originals did. With `rounded`, two 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in `--vf-black` *over* the bar: on the classic screen the rounding was the ROM's black corner mask sitting on top of the menu bar, not a shape of the bar's own, so it is ink over any backdrop rather than a cutout.
|
|
366
368
|
- **Behavior:** container/controller for slotted `vf-menu` children. Pressing a menu label → opens it (label inverts while open). While any menu is open, hovering another label switches to it (classic behavior). Escape / outside click / item selection closes. `role="menubar"`, behind a first-connect ownership latch so a consumer's own role survives upgrade; the shadow `.bar` is `role="presentation"` and each slotted `vf-menu` host `role="none"`, so the `menubar → menuitem` ownership chain has no generics in it. While a menu is open: ArrowLeft/Right move between menus, ArrowDown/Up walk the open menu's items, Home/End jump to its first/last enabled item, and printable keys run the shared Finder first-letter type-ahead over the items (`src/type-ahead.ts`; Space stays the focused item's activation key, and the prefix resets on menu switch or close). The bar also **owns the press-drag-release gesture** across its menus (`MenuPressController`, `src/menu-press.ts` — see `vf-menu`), since one press may travel over several of them: it binds the opening `pointerdown` and hands the controller its own open/close rules, so the gesture changes *when* a menu opens, never *how*.
|
|
367
|
-
- **Slots:** default (vf-menu elements). **Parts:** `bar`.
|
|
369
|
+
- **Slots:** default (vf-menu elements); `end` — the bar's right end, where a clock goes: a flex row of its own pushed to the end with an auto start margin and held 9px in, mirroring the first title's inset, setting `--vf-label-line-height` to the bar's height so a `vf-label` there rides the titles' baseline. Empty, it is `display: none` and takes no part in the row, so a page that pushes its own item to the end keeps the whole of the slack. `npm run verify:archetypes` (END SLOT). **Parts:** `bar`.
|
|
368
370
|
|
|
369
371
|
#### `vf-menu` (`VfMenu`, vf-menu.ts)
|
|
370
372
|
- **Attributes/props:** `label: string` (the menu title in the bar; may contain e.g. an apple glyph), `open: boolean` (reflect, managed by menu-bar or self), `shortcuts: boolean` (reflect — the per-menu key-equivalent grant, for a standalone menu; in a bar, declare it once on the bar instead. See vf-menu-item).
|
|
371
|
-
- **Visual:** label: bold, height of menubar, `padding-inline: 9px 8px` — the label box is the title's black plate and hit rect, and Menus.png puts the plate 10px left / 9px right of the title ink (Chicago carries a 1px bearing on each side inside the text box, so 9/8 in layout lands −10/+9 around ink); open → inverted (black
|
|
373
|
+
- **Visual:** label: bold, height of menubar, `padding-inline: 9px 8px` — the label box is the title's black plate and hit rect, and Menus.png puts the plate 10px left / 9px right of the title ink (Chicago carries a 1px bearing on each side inside the text box, so 9/8 in layout lands −10/+9 around ink); open → inverted (black plate / white text), the plate a `::before` layer inset one system px top and bottom — the hilite is rows 1..18 of the 20px bar, leaving the bar's top row white and its bottom rule showing through, while the hit rect stays the full bar height. The title itself sits in a `.title` box inside that cell, so the focus rule can span the title and not the padding. In a bar the label is `role="menuitem"`; standalone it is `role="button"` — a collapsed standalone dropdown *is* the APG menu-button pattern, and `aria-haspopup` + `aria-expanded` are already right for it (the host itself is `role="none"` in a bar and role-less standalone, behind vf-menu-item's first-connect ownership latch). Panel: `.vf-panel`, `position: absolute` below the label (`top: 100%; left: 0;`), no inset beyond its 1px stroke, `min-width: 100%` — a menu is as wide as its widest row, the way the MDEF sized it (`Menus.png`'s File pulldown is 141px, its cm/inches popup 94), never narrower than its own bar title; `role="menu"`.
|
|
372
374
|
- **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at `--vf-focus-underline-offset: -2px`, a dashed rule one blank system px row under the `.title` box. That box is `line-height: 1`, i.e. the face's own em, whose bottom edge is the descent line — so the one offset clears a descender *and* a slotted 16px `vf-img` (the Apple menu), where the button's baseline-anchored rule is crossed by both. In `currentColor`, so it inverts with the title on an open menu's black cell.
|
|
373
375
|
- **Closed only** (`:host(:not([open]))`). A dropped menu inverts its whole cell, which already shows where focus is; the rule marks the state the inversion can't — focused but not yet open — and drawing both would put a second mark (in white, since it is `currentColor`) under the first. The class stays on through the open state, so the rule returns by itself when the menu closes and hands focus back.
|
|
374
376
|
- Gated on a `.vf-focus-rule` class from the page's input modality, not `:focus-visible` (§4): `MenuPressController` `preventDefault`s the opening `pointerdown` and calls `focusLabel()` itself. The menu also calls the controller's `suppress()` from its own host `pointerdown`, so a press on an already-focused title — which moves no focus, and so fires no `focusin` — still drops the mark, as does mousing into the dropped panel. `npm run verify:focus`.
|
|
@@ -407,7 +409,7 @@ A container whose scrollbars look like System 7.
|
|
|
407
409
|
#### `vf-fieldset` (`VfFieldset`, vf-fieldset.ts)
|
|
408
410
|
The "Install Location" group box.
|
|
409
411
|
- **Attributes/props:** `legend: string`.
|
|
410
|
-
- **Visual:**
|
|
412
|
+
- **Visual:** a 1px black stroke (`vfStrokeDecls`), no radius, `padding: 14px 12px 10px` inside it, `margin-top: 8px` (room for legend). The padding is the slot's: the slot is the box inside the stroke, so children placed with `top`/`left` measure from the rule's inner edge. Legend: bold, positioned overlapping the top rule (absolute, `top: -11px; left: 8px;` from the rule's inner edge — whole system px, so a slotted `vf-label` legend lands on the device-pixel grid), `padding: 0 5px`, `background: var(--vf-surface, var(--vf-white, #fff))` so it punches out the rule to match its surface.
|
|
411
413
|
- **Slots:** default, plus named slot `legend` (overrides attr).
|
|
412
414
|
- **Parts:** `fieldset`, `legend`.
|
|
413
415
|
|
|
@@ -439,9 +441,9 @@ The kit's layout primitive: a flexbox whose `gap`, `pad`, `width` and `height` a
|
|
|
439
441
|
|
|
440
442
|
#### `vf-container` (`VfContainer`, vf-container.ts)
|
|
441
443
|
A plain sized box: `width`/`height` in whole system px around a bare slot — nothing drawn but what `pattern` and `rule` name, no layout opinion. Explicit placement (src/position.ts) leaves one line of CSS the kit can't write for a consumer: children placed with `top`/`left` need a positioned ancestor, so a region of the consumer's *own* needs `position: relative` in a stylesheet. This component is that region as an element — a DITL's enclosing rectangle with nothing drawn in it.
|
|
442
|
-
- **Attributes/props:** `width` / `height`: number (whole system px, via `VfSized`); the `top` / `left` pair via `VfPositioned` like nearly every component; `pattern?: string` — a library pattern by name or sixteen hex digits (docs/PATTERNS.md), painted as the box's background: unset paints nothing, an unrecognized value paints nothing and warns once; `rule?: string` — edge names separated by spaces (`"bottom"`, `"top bottom"`, up to all four in any order), the 1px rule drawn on those edges
|
|
444
|
+
- **Attributes/props:** `width` / `height`: number (whole system px, via `VfSized`); the `top` / `left` pair via `VfPositioned` like nearly every component; `pattern?: string` — a library pattern by name or sixteen hex digits (docs/PATTERNS.md), painted as the box's background: unset paints nothing, an unrecognized value paints nothing and warns once; `rule?: string` — edge names separated by spaces (`"bottom"`, `"top bottom"`, up to all four in any order), the 1px rule drawn on those edges inside the declared size: unset draws none, a value naming anything but an edge draws none and warns once. On a **child**: `fill-width` and `fill-height`, bare attributes as in `vf-stack`. That is the whole API.
|
|
443
445
|
- **Not a `vf-stack`.** The stack is a flexbox with opinions — an axis, a cross-axis default, fills compiled into flex. The container has none: in-flow children get normal flow, placed children get a coordinate system. Reach for it when the stack's opinions are the thing in the way — a field of placed icons, a fixed stage for placed art, a composition that brings its own layout with it.
|
|
444
|
-
- **Visual:** none unless `pattern` or `rule` is set — no role, keyboard behavior or selection; what it holds decides what it is. With `pattern`, the shadow box carries the fill as its own background (`vfPatternFill`: the `vf-pattern-fill` class always, `vf-patterned` while a pattern is resolved — *Patterns* under *Tiled fills*), riding the snap correction with the coordinate system; the tile repeats over whatever size the box has, declared or not. With `rule`, the same box wears `vfRule`'s per-edge classes (§4): the rule is
|
|
446
|
+
- **Visual:** none unless `pattern` or `rule` is set — no role, keyboard behavior or selection; what it holds decides what it is. A page that makes the box focusable (a role and a `tabindex` — a pattern chooser's cells as radios) gets the kit's focus mark rather than the browser's ring: on `:focus-visible` the host's outline is off and the shadow box's `::after` is `vfFocusUnderline` one blank row below the box, spanning it — `vf-swatch`'s placement, since such a box is nothing but fill (`bottom: vfStrokeInset(-3)`, so a ruled box's stroke, a border under forced colors, is counted there). `npm run verify:focus`. With `pattern`, the shadow box carries the fill as its own background (`vfPatternFill`: the `vf-pattern-fill` class always, `vf-patterned` while a pattern is resolved — *Patterns* under *Tiled fills*), riding the snap correction with the coordinate system; the tile repeats over whatever size the box has, declared or not. With `rule`, the same box wears `vfRule`'s per-edge classes (§4): the rule is a stroke inside the declared size (`box-sizing: border-box`), so a 24px `rule="bottom"` strip is 23 rows of box over one of ink — the menu bar's anatomy. A pattern's paper reaches the rule and its phase anchors inside it (`background-origin: content-box`), so it begins directly inside a top or left rule. `npm run verify:rule`. `:host` is a block with `width: fit-content`, so an undeclared axis shrink-wraps rather than claiming the parent's width (the stack's rule — a layout box must not hand out a size nobody declared; a declared dimension lands on the host's inline style and beats it). The shadow box coincides with the host box (`.vf-snap`, `height: 100%`), and the slot is the box inside its rules (`position: relative`, `display: flow-root`, `height: 100%`) — the coordinate system: the anchor for `top`/`left` children, the component's purpose, and what percentage fills resolve against; flow-root so a slotted margin cannot collapse through the top edge and push the coordinate origin off the corner. The snap correction on the shadow box carries it. Content that outgrows the declared box overflows it rather than growing it.
|
|
445
447
|
- **Carries a `GridSnapController`.** A container's box is itself the consumer's coordinate system, including for non-`vf` content that cannot correct itself. The shadow box owns the positioning anchor and the `vf-snap` class *together*, so the correction moves the whole coordinate system and everything placed against it rides along, kit or not. A declared size and whole-px `top`/`left` are on the grid by construction; the controller covers the origin the page contributes. (This is the arrangement `vf-stack` has since adopted too — one shadow box owning anchor and correction together.)
|
|
446
448
|
- **Typographically transparent**, exactly as `vf-stack` and for the same reason: `font`, `-webkit-font-smoothing`, `color`, `user-select` and `text-align` return to `inherit`, so wrapping content in a sized box changes nothing about how that content reads.
|
|
447
449
|
- **`fill-width` / `fill-height`** are read about the host (`width`/`height: 100%`, for a parent that can give the box a size) and compiled for slotted children as the percentage form — normal flow has no flex axes to translate onto. `width: 100%` always binds against the box; `height: 100%` binds only against a declared `height` (percentage-against-auto computes to auto), so a fill with nothing to take is inert, not an error. A light-DOM declaration beats the `::slotted` rule, as everywhere.
|
|
@@ -480,7 +482,7 @@ The Finder icon — pixel art in a reserved cell with its name on a plate below,
|
|
|
480
482
|
- **Attributes/props:** `label`: string (the name; empty draws no plate — that is the "no label" setting, rather than a second attribute that could disagree with it). `size`: `large` (default, 32×32) | `small` (16×16) — picks the slot that paints *and* the cell it paints in. `selectable`, `selected`, `movable`, `editable`: boolean. `color`: boolean — declares the art a **color icon**, so selection darkens instead of inverting (see Visual). `open`: boolean — the icon's window is on screen, and the art paints as the derived open ghost (below). `target`: boolean (reflected) — the destination highlight under a drag: the selected treatment of the *art* alone (`invert(1)`, or `brightness(0.5)` under `color`) with none of `selected`'s semantics — no `vf-select`, no outside listener, no `aria-selected`, the plate untouched; the page sets it on the folder a `vf-drag` reports the pointer over. `width`: number (system px, **even**; the cell/grid pitch, which a longer name overflows rather than being bound by). `maxlength`: number (31).
|
|
481
483
|
- **Visual:** `display: inline-block`. A column: the art cell (`--_cell` system px square, `overflow: hidden`, art centered), then, directly under it (`--vf-icon-gap`, 0 by default), the name plate — body face on a `--vf-icon-label-height` (12px) line box, the Finder's own plate height: the face's 16px em centers in it, 3px above the ascenders and descenders on the bottom edge, exactly as the Finder drew it — 1px horizontal padding, **opaque `--vf-white`** in its own right rather than `--vf-surface`: on the desktop dither the name reads because it sits on a plate, and `--vf-surface` is unset out there (`Example screen (1-bit).png`, the "Macintosh HD" icon). Selected: the plate takes the `--vf-highlight` pair and the art takes `filter: invert(1)` — a System 7 icon is ink and opaque white on a transparent surround, i.e. precisely an image plus its mask, so inverting flips the two and leaves the surround alone. That is the complete classic selected appearance for 1-bit art. Color art would invert into a photographic negative, which System 7 never showed: declare `color` and selection **darkens** instead — Icon Utilities' ttSelected transform, every color blended halfway toward black (`filter: brightness(0.5)`), whites going gray, the surround still untouched. The declaration is the consumer's, since the component cannot tell color art from a mask by looking at a slotted image; it covers whatever the cell shows (the open ghost included, so a color icon has one selected treatment open or closed), and the plate inverts either way — the selected name was inverted whatever the art.
|
|
482
484
|
- **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art redraws as the Finder's open-window ghost — outline held in solid black, interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the slotted art itself, by compositing alone: a silhouette via `source-in`; a 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette shifted one pixel each way (off-canvas composites as transparent, so the raster's own edge erodes too and an interior hole rings itself); the lattice via `createPattern` anchored at the art's top-left, where 32 and 16 being whole multiples of the tile keeps a field of icons in one phase; then the fill composed over the un-eroded silhouette, so the ring the erosion removed reads as the outline. Never `getImageData` — a canvas that drew a cross-origin image is tainted, which forbids *reading* pixels but not drawing or displaying them, so the ghost works for CORS-less sources too — and never `ctx.filter` (Safari's shaky corner; compositing operators are universal). Keeping the interior *opaque white* is what lets a selected open icon invert under the same `filter: invert(1)` with no second treatment. The slot stays in the tree while the ghost paints, hidden via a class gated on a successful derivation — art the pipeline cannot draw (nothing slotted, a failed load, an inline `<svg>`) keeps rendering as itself. The ghost re-derives on slotchange, the art's `load`/`error`, and `open`/`size` changes, keyed by `currentSrc` so a refresh that changed nothing is a string compare; the source's `alt` is carried onto the ghost (`role="img"` + `aria-label`) so a graphic that names its icon keeps doing so while its slot is hidden. The ghost displays on `vf-img`'s terms: natural raster size in system px, `image-rendering: pixelated`, one image pixel per system pixel.
|
|
483
|
-
- **Behavior:** clicking selects, Shift/⌘ toggles, and a press
|
|
485
|
+
- **Behavior:** clicking selects, Shift/⌘ toggles, and a press elsewhere clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. For an icon alone, elsewhere is anywhere outside it. For an icon in a field, only a press on another icon or on a field (`vf-icon-field`, or a `role="listbox"` element of the page's own) clears it; a press on the menu bar, a menu, a dialog, a window's title bar or another window leaves the selection, so a menu command still has its icons to act on and a selection survives an application switch. Either way the press calls off a pending rename and a half-finished tap pair. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). **The instrument never changes what the icon means**, so a finger or a pen opens it too, on a *tap pair* the icon classifies from its own pointer events rather than from `dblclick`: two taps on the icon, the second pressed within `TAP_PAIR_MS` (500) of the first and no further than `TAP_PAIR_SLOP_PX` (24 CSS px) from it, `vf-open` firing on the second release. A platform reserves the double-tap for its zoom and may deliver no `dblclick` for one, and a movable icon's press is `preventDefault()`ed for the drag, which can suppress it as well; the pair depends on neither. A tap is a press released where it landed — one that became a drag, or that the platform cancelled, is not half of a pair — and a pair does not chain. The mouse keeps the platform's `dblclick`, which follows the user's own double-click setting; each press takes exactly one of the two routes, so a platform that also delivers a `dblclick` for a double-tap opens the icon once. The frame sets `touch-action: manipulation`, which takes the double-tap zoom off the icon and leaves panning and pinching to the page. Both are additions in the kit's idiom — System 7 had the mouse alone. Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape (the frame is the handle and sets `touch-action: none` while movable, as the title bars do), plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
|
|
484
486
|
- **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step past the **drag slop** — `DRAG_SLOP_PX` (4 CSS px) of travel from the press for the mouse, `DRAG_SLOP_COARSE_PX` (10) for a finger or pen (`dragSlop(event)`, `src/motion.ts`). Inside it the press is a click or a tap: the icon stays on its spot, a rename the press armed stays armed, and no drag event fires, so a click never fires a drop and a tap never nudges an icon off its cell. The delta is measured from the press all the same, so the outline begins under the pointer. CSS px rather than system px, because the slop measures the hand and not the art; `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press; in a box that *scrolls* (a `vf-scroll-area`'s plane, which a window body is under `scrollbars`) only the origin holds, since the rails reach anything placed past it and the Finder put an icon below a window's fold and scrolled to it — `#anchorScrolls`, a flat-tree walk from the icon toward its positioning parent through the slots it is assigned to, true when a scroll area is met before the parent — and whose `preventDefault()` writes nothing, the page re-parenting or placing the icon itself; `vf-drag-cancel` `{}` on Escape or a `pointercancel`, nothing written. `left`/`top` in the detail are the proposal *unclamped* (the origin plus the lattice-snapped delta); only the default action clamps, so a page that cancels gets the raw gesture. The drop is dispatched with the outline gone, the drag state cleared and the pointer capture released, so re-parenting inside the handler is safe (Chromium drops the `click` of a press whose node was re-inserted at `pointerup`; the icon listens for no click and times its double-click on presses).
|
|
485
487
|
- **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the silhouette clipped to the cell as the cell clips the art, the one-pixel erosion by eight `destination-in` draws on a layer the outline's size (the layer is transparent around the art, so its edge erodes as the off-canvas edge did) — the 3×3 box, the region inset `FrameRgn` painted a frame from, rather than the ghost's four-neighbor plus: a 45° edge comes out two pixels wide with both parities of the checker, where a one-pixel diagonal is all one parity and the pen would keep all of it or none, and over the dither "none" is the dither's own ink diagonal — the plate's rectangle filled solid into the same silhouette before the erosion (the Finder's drag region was the mask united with the name's rect, framed once — where the two abut, the outline wraps around both and no edge runs between them; apart, each is ringed on its own), the ring the erosion removes via `destination-out`; compositing only, so cross-origin art derives too. Art the pipeline cannot draw falls back to the cell's rectangle — never a blank. One canvas the size of the frame widened to the plate — a name wider than a declared `width` overhangs the frame on both sides — at one image px per system px; the ring is derived once per drag, and each step re-dots it against a 2×2 checker (`destination-in`) at the pen's phase for where it lands on the *screen* (`penPhase`): ink where `x + y` is odd in screen coordinates, the diagonal the desktop dither (`gray-50`, ink at even) leaves white, so the dots fall between the dither's ink wherever the outline lands, as QuickDraw's screen-anchored pattern pen did — over a white body a dotted black line, over the dither a black line, each dot flipping a white pixel with the dither's own ink filling in between. Painted with the cursor's XOR recipe, `filter: invert(1)` under `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`, sized and placed as live `sysLength()`s so a scale change mid-gesture cannot smear it; its origin is that box's whole-system-px offset from the surface plus the delta. **The surface is the desktop's, not the top layer:** the icon dispatches `vf-drag-surface-request` (bubbles, `composed: false`, `detail.surface` null) on the first step, and the nearest `vf-desktop` on its light-DOM path fills it with its drag surface (§5 vf-desktop); a non-composed event from a slotted icon still travels through the slots it is assigned to, so the handshake works from inside a window body. Without a desktop the canvas is appended to the icon's own `.frame` (the vf-snap box, `position: relative`), translated by the delta alone and clipped by whatever clips the icon — the honest fallback, with the same gesture and events. Not `position: fixed` inside the icon: a `vf-window` is a stacking context, so a fixed outline inside it would paint at the window's z, under every other window (KNOWN-BUGS.md #1 in another costume). One drag, no opt-in: the Finder never moved the icon itself, the drop lands where it always did, and the kit ships no taste knobs. Escape is heard by a document `keydown` listener scoped to the gesture (`DocumentListenersController`, capture phase, the key stopped there so an enclosing `vf-dialog` never reads it as a dismissal) rather than the icon's own handler, because a `movable`-only icon never takes focus — an addition in the kit's idiom (§1), not a claim about the Finder. `DragController` gained the two trailing parameters this needs (`onDrag(x, y, event)`, `onDragEnd(event, cancelled)`) and a `cancel()`; `vf-window` and `vf-dialog` ignore them. A selected icon re-parented at the drop keeps its outside-press listener: it re-attaches on connect, since the controller detaches on disconnect and `updated()` does not re-run for an unchanged property. `npm run verify:icon-drag`.
|
|
486
488
|
- **The selection travels.** A drag that begins on a *selected* icon carries every other selected, movable icon of its field (`closest('vf-icon-field, [role="listbox"]')`, document order) — the Finder outlined the whole selection under a drag on any member. Each follower is joined through `vf-icon.followDrag(surface)` (internal to the group drag): it seeds its own origin, builds its own outline on the surface the leader's handshake found (or in its own frame), and hands back a handle `{ icon, origin, size, move(dx, dy), drop(dx, dy), end() }`. The leader moves every outline by its lattice delta on each step; every event's detail carries the set as `icons`, the leader first (`VfIconDragDetail`). The drop's default action clamps the delta **once for the whole group** — per axis, the tightest of every member's room to its edges, so the arrangement is kept and the group stops when its outermost icon meets the container (a group no edge can hold is held off the near edge only, the single-icon rule; `PlacementController.bounds` is the box, measured at the press) — then writes each member's origin plus that delta through its own `moveTo`. A cancel, or a cancelled drop, takes every outline down and writes nothing for anyone. **The press keeps the selection:** an icon's outside-press listener leaves the selection alone when a plain press lands on an icon already selected in the same field (document capture runs before the pressed icon's own handler, so `selected` reads as the press found it); a click on such an icon, released with no drag, collapses the selection to it on the `pointerup` — so a click still single-selects, and the same press could have dragged them all. A press on an unselected icon makes it the selection first, so it drags alone; a Shift press that deselects the pressed icon drags it alone too; a selected icon in another field, or one that cannot move, stays. `npm run verify:icon-drag` (GROUP).
|
|
@@ -511,7 +513,7 @@ The container a field of `vf-icon`s sits in — a desktop's icons, a folder wind
|
|
|
511
513
|
|
|
512
514
|
## 7. The faux desktop (moved out)
|
|
513
515
|
|
|
514
|
-
The full-viewport System 7 desktop that used to close this spec — menu bar, nine windows, the composed alert, the utility palette, the Finder icons — is no longer part of this repo. It lives in **[aportilla/
|
|
516
|
+
The full-viewport System 7 desktop that used to close this spec — menu bar, nine windows, the composed alert, the utility palette, the Finder icons — is no longer part of this repo. It lives in **[aportilla/system-online](https://github.com/aportilla/system-online)** and consumes `vintage-frames` from npm, so it exercises the same published API a consumer gets rather than reaching into `src/`. Its clause-by-clause spec moved with it, to that repo's `docs/SPEC.md`.
|
|
515
517
|
|
|
516
518
|
What the kit keeps as its own demo surface is `index.html`, the component reference (every element, its API, a live specimen of each state). See [DEVELOPING.md](DEVELOPING.md).
|
|
517
519
|
|