vintage-frames 0.4.0 → 0.5.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.
@@ -1,15 +1,5 @@
1
- import { type TileRect } from './tile.js';
2
- /**
3
- * The windoid bar's dither: a 2-system-px motif with a single black pixel at
4
- * its origin, stated once as rect data. Transparent ground, because the layer
5
- * floats over the bar and the same art doubles as the forced-colors mask,
6
- * where the ground would be opacity rather than paint. The SVG tile below is
7
- * the CSS-repeated form (the no-declared-width fallback and the forced-colors
8
- * mask); `vf-window` renders the exact fill — the whole-surface raster or a
9
- * consumer token's placed tile grid — from the same data (src/tile-grid.ts).
10
- */
11
- export declare const DOT_MOTIF = 2;
12
- export declare const DOT_RECTS: readonly TileRect[];
1
+ export declare const DOT_MOTIF: number;
2
+ export declare const DOT_RECTS: readonly import("./tile.js").TileRect[];
13
3
  /** The dots' tile size in system px (30) — the consumer token's documented box. */
14
4
  export declare const DOT_SPAN: number;
15
5
  /**
@@ -1,12 +1,8 @@
1
1
  import { tileImage as e, tileRects as t, tileSpan as n, vfTileMaskSize as r, vfTileSize as i } from "./tile.js";
2
- import { css as a, unsafeCSS as o } from "lit";
3
- var s = [[
4
- 0,
5
- 0,
6
- 1,
7
- 1,
8
- "#000000"
9
- ]], c = e(2, 2, t(s)), l = n(2), u = a`
2
+ import { PATTERNS as a, patternMotif as o } from "../../patterns.js";
3
+ import { css as s, unsafeCSS as c } from "lit";
4
+ //#region src/styles/recipes/pattern.ts
5
+ var l = o(a.dots), u = l.width, d = l.rects, f = e(u, u, t(d)), p = n(u), m = s`
10
6
  .vf-stripes {
11
7
  position: absolute;
12
8
  inset: calc(var(--vf-scale, 1) * 3px) calc(var(--vf-scale, 1) * 1px);
@@ -44,15 +40,15 @@ var s = [[
44
40
  background: var(--vf-black, #000000);
45
41
  }
46
42
  }
47
- `, d = a`
43
+ `, h = s`
48
44
  .vf-dots {
49
45
  position: absolute;
50
46
  inset: calc(var(--vf-scale, 1) * 2px) 0;
51
- background-image: var(--vf-dots-pattern, ${o(c)});
52
- ${i(2)}
47
+ background-image: var(--vf-dots-pattern, ${c(f)});
48
+ ${i(u)}
53
49
  pointer-events: none;
54
50
  /* The consumer-token art channel for the placed tile grid. */
55
- --_vf-tile-image: var(--vf-dots-pattern, ${o(c)});
51
+ --_vf-tile-image: var(--vf-dots-pattern, ${c(f)});
56
52
  }
57
53
  .vf-dots.vf-tile-grid {
58
54
  background-image: none;
@@ -69,8 +65,8 @@ var s = [[
69
65
  .vf-dots {
70
66
  background-image: none;
71
67
  background-color: var(--vf-black, #000);
72
- mask-image: var(--vf-dots-pattern, ${o(c)});
73
- ${r(2)}
68
+ mask-image: var(--vf-dots-pattern, ${c(f)});
69
+ ${r(u)}
74
70
  }
75
71
  .vf-dots .vf-tile,
76
72
  .vf-dots .vf-tile-raster {
@@ -79,4 +75,4 @@ var s = [[
79
75
  }
80
76
  `;
81
77
  //#endregion
82
- export { s as DOT_RECTS, l as DOT_SPAN, d as vfDots, u as vfStripes };
78
+ export { u as DOT_MOTIF, d as DOT_RECTS, p as DOT_SPAN, h as vfDots, m as vfStripes };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The four edges a rule can sit on, in CSS shorthand order.
3
+ */
4
+ export declare const RULE_EDGES: readonly ["top", "right", "bottom", "left"];
5
+ export type RuleEdge = (typeof RULE_EDGES)[number];
6
+ /**
7
+ * The 1px rule — the single black line that is the 1-bit art's only edge —
8
+ * drawn on one side of a box. One class per edge, composable: the menu bar's
9
+ * floor is `.vf-rule-bottom`, a window's status strip's ceiling is
10
+ * `.vf-rule-top`, and `vf-container rule="…"` takes the edges by name.
11
+ *
12
+ * A border, not a pseudo-element or a painted line, so it lives inside the
13
+ * box's own `border-box` size (vfBase) and insets the padding box the way
14
+ * FrameRect insets a rectangle's interior: content, percentage fills and
15
+ * children placed against the box all begin inside the rule. Scales with
16
+ * `--vf-scale`, so it is one system px at every density, and paints in
17
+ * `--vf-black`, so it remaps under forced colors with the rest of the ink.
18
+ *
19
+ * One declaration per edge, so the kit cannot grow two rules — the
20
+ * {@link vfHardShadowDecls} principle, applied to the line.
21
+ *
22
+ * Being a border, it shares the kit's border-floor residual
23
+ * (docs/THREE-X-DISPLAYS.md): Chromium floors a fractional border-width to
24
+ * whole CSS px, so above 1× the line paints thinner than a system px (2
25
+ * device px of 3 at 2×) and the padding box begins that much inside — the
26
+ * same as every kit frame, and the same line the menu bar always drew.
27
+ * `npm run verify:rule` asserts a container's rule against the menu bar's.
28
+ */
29
+ export declare const vfRule: import("lit").CSSResult;
30
+ /**
31
+ * The `rule` attribute grammar: edge names separated by whitespace, in any
32
+ * order (`"bottom"`, `"top bottom"`, all four for a framed box). Returns the
33
+ * edges in top/right/bottom/left order with repeats dropped; an empty array
34
+ * for an unset or blank value; and `null` for a value carrying any token that
35
+ * is not an edge name — the whole value is refused, not the one token, as
36
+ * `parsePattern` refuses an unrecognized pattern. Case-sensitive, like the
37
+ * pattern names.
38
+ */
39
+ export declare function parseRule(value: string | null | undefined): RuleEdge[] | null;
40
+ /** The classes a resolved rule puts on its box: `vf-rule-top vf-rule-bottom`. */
41
+ export declare function ruleClasses(edges: readonly RuleEdge[]): string;
@@ -0,0 +1,32 @@
1
+ import { css as e, unsafeCSS as t } from "lit";
2
+ //#region src/styles/recipes/rule.ts
3
+ var n = [
4
+ "top",
5
+ "right",
6
+ "bottom",
7
+ "left"
8
+ ], r = t("calc(var(--vf-scale, 1) * 1px) solid var(--vf-black, #000)"), i = e`
9
+ .vf-rule-top {
10
+ border-top: ${r};
11
+ }
12
+ .vf-rule-right {
13
+ border-right: ${r};
14
+ }
15
+ .vf-rule-bottom {
16
+ border-bottom: ${r};
17
+ }
18
+ .vf-rule-left {
19
+ border-left: ${r};
20
+ }
21
+ `;
22
+ function a(e) {
23
+ if (e == null) return [];
24
+ let t = e.trim().split(/\s+/).filter(Boolean), r = new Set(t);
25
+ for (let e of r) if (!n.includes(e)) return null;
26
+ return n.filter((e) => r.has(e));
27
+ }
28
+ function o(e) {
29
+ return e.map((e) => `vf-rule-${e}`).join(" ");
30
+ }
31
+ //#endregion
32
+ export { n as RULE_EDGES, a as parseRule, o as ruleClasses, i as vfRule };
@@ -1,15 +1,6 @@
1
- import { type TileRect } from './tile.js';
2
- /**
3
- * The trough's 25% lattice — a 4×2-system-px motif with a dot at (0,0) and
4
- * (2,1), traced from the UI kit's "Scroll bg" sprite — stated once as rect
5
- * data. The DOM trough renders it as a whole-surface raster
6
- * (`tileRaster`, written by `ScrollRailController` — 1-bit at every scale,
7
- * zoom-minted ones included); the SVG span tile below is the forced-colors
8
- * mask, which repaints the same art in the remapped ink token.
9
- */
10
- export declare const TROUGH_MOTIF_X = 4;
11
- export declare const TROUGH_MOTIF_Y = 2;
12
- export declare const TROUGH_RECTS: readonly TileRect[];
1
+ export declare const TROUGH_MOTIF_X: number;
2
+ export declare const TROUGH_MOTIF_Y: number;
3
+ export declare const TROUGH_RECTS: readonly import("./tile.js").TileRect[];
13
4
  /**
14
5
  * System 7 scroll rails, drawn by the kit as ordinary shadow DOM — the skin
15
6
  * for the subtree `renderScrollRail()` (src/scroll-rail.ts) produces. The
@@ -1,16 +1,8 @@
1
1
  import { tileImage as e, tileRects as t, vfTileMaskSize as n } from "./tile.js";
2
- import { css as r, unsafeCSS as i } from "lit";
3
- var a = [[
4
- 0,
5
- 0,
6
- 1,
7
- 1
8
- ], [
9
- 2,
10
- 1,
11
- 1,
12
- 1
13
- ]], o = e(4, 2, t(a)), s = r`
2
+ import { PATTERNS as r, patternMotif as i } from "../../patterns.js";
3
+ import { css as a, unsafeCSS as o } from "lit";
4
+ //#region src/styles/recipes/scroll-rail.ts
5
+ var s = i(r["gray-25"]), c = s.width, l = s.height, u = s.rects, d = e(c, l, t(u)), f = a`
14
6
  /* Hide the native bar, keep the native scrolling. Both lines ship:
15
7
  scrollbar-width reaches Chromium 121+/Firefox/Safari 18.2+, the pseudo
16
8
  covers older WebKit — the last scrollbar pseudo left in the kit. */
@@ -210,8 +202,8 @@ var a = [[
210
202
  @media (forced-colors: active) {
211
203
  .vf-rail-trough {
212
204
  background-color: var(--vf-black, #000);
213
- mask-image: ${i(o)};
214
- ${n(4, 2)}
205
+ mask-image: ${o(d)};
206
+ ${n(c, l)}
215
207
  }
216
208
  .vf-rail-trough-art {
217
209
  display: none;
@@ -219,4 +211,4 @@ var a = [[
219
211
  }
220
212
  `;
221
213
  //#endregion
222
- export { a as TROUGH_RECTS, s as vfScrollRail };
214
+ export { c as TROUGH_MOTIF_X, l as TROUGH_MOTIF_Y, u as TROUGH_RECTS, f as vfScrollRail };
@@ -4,14 +4,13 @@
4
4
  */
5
5
  export declare const vfPanel: import("lit").CSSResult;
6
6
  /**
7
- * Chrome-frame recipe for the two framed containers (vf-window, vf-dialog):
8
- * the same white face, 1px black border and hard offset shadow as
9
- * {@link vfPanel}. Apply `.vf-frame` to the outer frame element.
7
+ * Chrome-frame recipe for the desktop window (vf-window): the same white
8
+ * face, 1px black border and hard offset shadow as {@link vfPanel}. Apply
9
+ * `.vf-frame` to the outer frame element.
10
10
  *
11
- * Skin only — each component adds its own layout, because they size very
12
- * differently: vf-window's frame is a full-size flex column (the body flexes,
13
- * the title bar and grow box don't), while vf-dialog's is a plain block that
14
- * the native `<dialog>` shrink-wraps.
11
+ * Skin only — the component adds its own layout (a full-size flex column: the
12
+ * body flexes, the title bar and grow box don't). vf-dialog does not use it:
13
+ * both of its chromes are {@link vfModalFrame}, which casts no shadow.
15
14
  */
16
15
  export declare const vfChromeFrame: import("lit").CSSResult;
17
16
  /**
@@ -24,5 +23,17 @@ export declare const vfChromeFrame: import("lit").CSSResult;
24
23
  *
25
24
  * Apply `.vf-modal-frame` to the outer element and `.vf-modal-frame-inner` to
26
25
  * a wrapper inside it; content goes in the wrapper.
26
+ *
27
+ * **The movable modal (movableDBoxProc) is this same frame with the striped
28
+ * title bar set into it** — traced from a 2× capture of a System 7 movable
29
+ * modal: the standard 18px bar (`vfTitleBar`, 17-row interior, 3 white rows /
30
+ * six stripes / 3 white rows, the title patch's 7px margins) sits directly
31
+ * under the outer rule, inset 2px at either end so its stripes keep the same
32
+ * 2px of white from the outer rule that the inner band keeps; the bar's own
33
+ * 1px rule plus the inner box's 1px top border make the 2px band its floor,
34
+ * which then continues down both sides and along the bottom as the band
35
+ * around the body. No shadow anywhere. Put the `.vf-title-bar` between the
36
+ * two elements, as the first child of `.vf-modal-frame`, and the three rules
37
+ * below do the rest.
27
38
  */
28
39
  export declare const vfModalFrame: import("lit").CSSResult;
@@ -22,6 +22,21 @@ var r = n(`
22
22
  margin: calc(var(--vf-scale, 1) * 2px);
23
23
  border: calc(var(--vf-scale, 1) * 2px) solid var(--vf-black, #000);
24
24
  }
25
+ /* The bar set into the frame: 2px in from the outer rule at either end, its
26
+ stripes flush to the bar's own edge (vfStripes' 1px side inset is the
27
+ window frame's buffer — here the frame's gap is that buffer). */
28
+ .vf-modal-frame > .vf-title-bar {
29
+ margin: 0 calc(var(--vf-scale, 1) * 2px);
30
+ }
31
+ .vf-modal-frame > .vf-title-bar .vf-stripes {
32
+ inset: calc(var(--vf-scale, 1) * 3px) 0;
33
+ }
34
+ /* Under a bar, the inner box's top border is the second row of the band —
35
+ the bar's 1px rule is the first — and there is no gap row between. */
36
+ .vf-modal-frame > .vf-title-bar + .vf-modal-frame-inner {
37
+ margin-top: 0;
38
+ border-top-width: calc(var(--vf-scale, 1) * 1px);
39
+ }
25
40
  `;
26
41
  //#endregion
27
42
  export { a as vfChromeFrame, o as vfModalFrame, i as vfPanel };
@@ -103,9 +103,11 @@ export declare function patternOverride(el: Element, token: string): string;
103
103
  * A one-entry cache for a surface's whole-surface raster, keyed by its
104
104
  * system-px size — so every render with an unchanged size (all of them except
105
105
  * a declared-size change) reuses the encoded image instead of re-running
106
- * `tileRaster`'s canvas encode.
106
+ * `tileRaster`'s canvas encode. A surface whose *art* can change under the
107
+ * same size (a `pattern` attribute, src/pattern-fill.ts) passes an `id` for
108
+ * it — the fixed-motif surfaces leave it empty.
107
109
  */
108
110
  export declare class TileRasterCache {
109
111
  #private;
110
- for(motifWidth: number, motifHeight: number, rects: readonly TileRect[], width: number, height: number): string;
112
+ for(motifWidth: number, motifHeight: number, rects: readonly TileRect[], width: number, height: number, id?: string): string;
111
113
  }
package/dist/tile-grid.js CHANGED
@@ -44,9 +44,9 @@ function o(e, t) {
44
44
  var s = class {
45
45
  #e = "";
46
46
  #t = "";
47
- for(e, n, r, i, a) {
48
- let o = `${i}x${a}`;
49
- return this.#e !== o && (this.#e = o, this.#t = t(e, n, r, i, a)), this.#t;
47
+ for(e, n, r, i, a, o = "") {
48
+ let s = `${o}|${i}x${a}`;
49
+ return this.#e !== s && (this.#e = s, this.#t = t(e, n, r, i, a)), this.#t;
50
50
  }
51
51
  };
52
52
  //#endregion
@@ -20,6 +20,10 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
20
20
  * why re-observing is skipped when the element hasn't changed: `observe()` on
21
21
  * an already-observed element re-fires the initial callback, and `hostUpdated`
22
22
  * runs on every update.
23
+ *
24
+ * The height comes along from the same observation, for a box measured on
25
+ * both axes: `PatternFillController` (src/pattern-fill.ts) sizes a pattern
26
+ * raster to a box whose extent nobody declared.
23
27
  */
24
28
  export declare class TrackWidthController implements ReactiveController {
25
29
  private readonly host;
@@ -27,6 +31,7 @@ export declare class TrackWidthController implements ReactiveController {
27
31
  private resizeObserver?;
28
32
  private observed;
29
33
  private measured;
34
+ private measuredHeight;
30
35
  constructor(host: ReactiveControllerHost, getTrack: () => HTMLElement | null | undefined);
31
36
  /**
32
37
  * Content width of the track in CSS px (already scaled — convert with
@@ -34,6 +39,8 @@ export declare class TrackWidthController implements ReactiveController {
34
39
  * each component treats as "not measured yet" and falls back from.
35
40
  */
36
41
  get width(): number;
42
+ /** Content height of the track in CSS px, on the same terms as {@link width}. */
43
+ get height(): number;
37
44
  hostConnected(): void;
38
45
  hostUpdated(): void;
39
46
  hostDisconnected(): void;
@@ -1,11 +1,14 @@
1
1
  //#region src/track-width.ts
2
2
  var e = class {
3
3
  constructor(e, t) {
4
- this.host = e, this.getTrack = t, this.observed = null, this.measured = 0, e.addController(this);
4
+ this.host = e, this.getTrack = t, this.observed = null, this.measured = 0, this.measuredHeight = 0, e.addController(this);
5
5
  }
6
6
  get width() {
7
7
  return this.measured;
8
8
  }
9
+ get height() {
10
+ return this.measuredHeight;
11
+ }
9
12
  hostConnected() {
10
13
  this.wire();
11
14
  }
@@ -21,8 +24,8 @@ var e = class {
21
24
  !e || e === this.observed || (this.resizeObserver ??= new ResizeObserver((e) => {
22
25
  let t = e[0];
23
26
  if (!t) return;
24
- let n = Math.floor(t.contentRect.width);
25
- n !== this.measured && (this.measured = n, this.host.requestUpdate());
27
+ let n = Math.floor(t.contentRect.width), r = Math.floor(t.contentRect.height);
28
+ (n !== this.measured || r !== this.measuredHeight) && (this.measured = n, this.measuredHeight = r, this.host.requestUpdate());
26
29
  }), this.resizeObserver.observe(e), this.observed = e);
27
30
  }
28
31
  };
package/docs/SPEC.md CHANGED
@@ -133,6 +133,8 @@ The span construction (`vfTileSize`, `tileImage`, `tileSpan`) makes the stored l
133
133
 
134
134
  The span construction stays load-bearing for the CSS-repeated underlays beneath the opaque kit fills and for the forced-colors mask branches (no mask pipeline rasterizes exactly at a zoom-minted scale, so forced-colors-plus-zoom remains the one accepted residual). The scroll trough — once the one surface that could not convert, a `::-webkit-scrollbar` pseudo hosting no children — is ordinary DOM now (§4 `vfScrollRail`) and renders through `tileRaster` like the desktop dither: 1-bit at every scale, zoom-minted ones included. `npm run verify:tile` asserts zero gray on the four converted surfaces at eight densities — the ladder plus 1.7 and 2.3, the emulated stand-ins for Safari's broken rungs; `npm run verify:scrollbars` asserts the same on the trough.
135
135
 
136
+ **Patterns.** The 38 standard patterns (`src/patterns.ts`; the table is docs/PATTERNS.md) are 8-byte QuickDraw `Pattern`s in palette order and QuickDraw phase. `gray-50`, `gray-25` and `dots` are the desktop dither, the scroll trough and the windoid bar's motifs, and those surfaces draw them by reference — `patternMotif` on the minimal cell, so the 2×2 / 4×2 rect data and the 30/60-px token spans are the ones they always had. `vf-container pattern` and `vf-desktop pattern` take a name or sixteen hex digits and paint the pattern as the box's **own background** (`PatternFillController` + `vfPatternFill`, src/pattern-fill.ts): the same whole-surface raster, its `background-size` stated in system px — one length quantized once over the whole image — under `image-rendering: pixelated`, literal black ink on the `--vf-white` paper the recipe supplies. A background rather than a child element, so a patterned box is no stacking context (a slotted control's fixed panel is never trapped under a later sibling) and keeps `overflow: visible`; `image-rendering` inherits, so the recipe hands `auto` back to the slot. A declared axis sizes the raster exactly (plus one cell of overdraw the painting area crops); an undeclared one is measured through `TrackWidthController`. Phase is anchored at the box's own origin. Forced colors: flat Canvas, the desktop's posture. `npm run verify:pattern` asserts the library, the three motif equivalences and zero gray at the same eight densities.
137
+
136
138
  **`--vf-cursor` (hiding the pointer for a page-drawn cursor).** A page that draws its own cursor — a JS-positioned image on the system-pixel grid, which is what `applyCursor()` (`src/cursor.ts`) sets up with the kit's embedded System 7 pointer set — needs the native pointer gone from every surface, and `html { cursor: none }` alone cannot get it there: inheritance stops wherever a shadow stylesheet states a cursor of its own, and the kit states one deliberately on its chrome — control hosts, the title-bar widgets, the number stepper, the scrollbar rails, the grow box, the editable wells (whose I-beam Firefox's UA sheet would otherwise pin), and a modal's top-layer backdrop, which no page rule reaches at all. Every one of those declarations reads this token first, so `--vf-cursor: none` on `:root` (custom properties inherit through every shadow root) covers all of them. The page's own side must be a blanket, not a root rule: the UA sheets put `cursor: default` on `<button>` and `cursor: text` on `<input>`, and an element's own declaration beats anything it would inherit — so a bare `html { cursor: none }` leaves the OS arrow alive over every native control in the light DOM. The pair that empties everything is `* { cursor: none !important }` plus the token — `applyCursor()` applies both itself, once its art has decoded; state the pair by hand only under a hand-rolled overlay. Unset, each spot keeps its classic pointer — the arrow on chrome, the I-beam in an enabled well.
137
139
 
138
140
  ## 4. Shared recipes (in `src/styles/base.ts`)
@@ -145,8 +147,9 @@ The span construction stays load-bearing for the CSS-repeated underlays beneath
145
147
  - `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 the underlays and the forced-colors masks; the converted surfaces (the scroll trough now among them) render through `vfTileGrid` / `tileGrid` / `tileRaster` instead. See *Tiled fills* below.
146
148
  - `vfHardShadowDecls` — the hard 1-bit drop shadow on its own, for composing into a surface that supplies its own border: `box-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. Every raised surface in the kit composes this one declaration (`vfPanel`, `vfChromeFrame`; `vf-swatch` composes it without either surface class).
147
149
  - `vfPanel` — a `.vf-panel` class for menus/popups: white bg, `border: 1px solid var(--vf-black, #000)`, `vfHardShadowDecls`.
148
- - `vfChromeFrame` — a `.vf-frame` class for the framed containers (`vf-window`, `vf-dialog`): the same white face, 1px black border and hard offset shadow as `vfPanel`, no bevels. Skin only — each component supplies its own layout, since a window frame is a full-size flex column while a dialog's is a block the native `<dialog>` shrink-wraps.
149
- - `vfModalFrame` — the dBoxProc modal-dialog frame (`vf-dialog frame="plain"`): `.vf-modal-frame` (white face, 1px black border, **no shadow**) around `.vf-modal-frame-inner` (`margin: 2px; border: 2px solid`). 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.
150
+ - `vfChromeFrame` — a `.vf-frame` class for the desktop window (`vf-window`): the same white face, 1px black border 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`.
151
+ - `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 `border-<edge>: 1px solid var(--vf-black, #000)` scaled by `--vf-scale`. A border, so it sits inside the box's `border-box` size and insets the padding box — content, percentage fills and children placed against the box begin inside the rule, as a rectangle's interior begins inside FrameRect's line. One declaration per edge, the `vfHardShadowDecls` principle applied to the line: `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. Being a border it shares the kit's border-floor residual (docs/THREE-X-DISPLAYS.md thin above in Chromium, the padding box that much inside), exactly as every kit frame does. `npm run verify:rule` asserts a container's rule against the menu bar's own at four densities.
152
+ - `vfModalFrame` — the dBoxProc modal-dialog frame, both `vf-dialog` chromes: `.vf-modal-frame` (white face, 1px black border, **no shadow**) around `.vf-modal-frame-inner` (`margin: 2px; border: 2px solid`). **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 border to 1px, so the bar's own 1px rule plus that border 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.
150
153
  - `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.
151
154
  - `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.
152
155
  - 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`.
@@ -191,9 +194,9 @@ Every recipe also declares its size — `width`, and `height` where the window i
191
194
 
192
195
  #### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
193
196
  Full-bleed classic desktop container.
194
- - **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 two *top* corners wear the `SCREEN_CORNER` mask (the classic framebuffer rounded only the top pair; the raster's bottom corners ran square), painted above everything like the hardware mask — the masks land 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).
197
+ - **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 two *top* corners wear the `SCREEN_CORNER` mask (the classic framebuffer rounded only the top pair; the raster's bottom corners ran square), painted above everything like the hardware mask — the masks land 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.
195
198
  - **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 empty; utility windows are never the holder.
196
- - **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 = classic 50% dither: a 2×2 motif with an opaque white base and two black pixels on the diagonal, rendered as the exact fill (one whole-surface raster; a consumer `--vf-desktop-pattern` renders as a placed tile grid on the token's 30-system-px tile see *Tiled fills*) over a CSS-repeated underlay of the same art. The art is opaque black-on-white — the authentic System 7 dither — so it covers `var(--vf-desktop, #808080)` beneath it; that base color shows only under a custom pattern with transparent cells (or `none`).
199
+ - **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, one whole-surface raster 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.
197
200
  - **Slots:** default (menu bar, windows, anything).
198
201
  - **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`.
199
202
  - **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 at pointer-gesture end (a mid-gesture node move would clear the pointer capture a title-bar drag or grow-box resize holds) and on programmatic `bringToFront`, never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); focus surviving its own window's move is restored without re-raising that window. Non-window children (a menu bar, page content) keep their positions. `npm run verify:window-a11y`.
@@ -212,7 +215,7 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
212
215
  - 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.
213
216
  - Body: `padding: 12px` (0 if `flush` or `scrollbars`), 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.
214
217
  - 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.
215
- - Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule 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 (the dialog-footer pattern), 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.
218
+ - 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 (the dialog-footer pattern), 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.
216
219
  - 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. Same caveats as the slotted composition (see vf-scroll-area §5), which remains supported for inset wells.
217
220
  - 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.
218
221
  - **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`.
@@ -222,12 +225,12 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
222
225
  - **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
223
226
 
224
227
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
225
- The modal-dialog shell: movable modal by default (see "Format" screenshot, striped title bar over a white body), the dBoxProc modal dialog box with `frame="plain"` (see the Group A recipe table).
228
+ The modal-dialog shell: movable modal by default (the dBoxProc double frame with the striped title bar set into it — traced from a System 7 capture of a movable modal), the modal dialog box with `frame="plain"` (the bare double frame; see the Group A recipe table).
226
229
  - **Attributes/props:** `open: boolean` (reflect), `heading: string`, `top: number` / `left: number` (whole system px, in **viewport** coordinates — see §1 Explicit placement; unset means centered), `width: number` / `height: number` (**declare them both** — whole system px, the same fixed box `vf-window` is. A native `<dialog>` is `width: fit-content` measured against the space left beside its own offsets, and stating an offset is how the movable modal is positioned, so an undeclared dialog squeezes itself and reflows as it is dragged toward an edge. The two fall back differently — width to 260 system px, height to the content — and it names whichever are missing, once, on the open that first shows it), `label: string` (accessible name for a dialog with no `heading`), `closable: boolean` (default **false** — the bare movable-modal bar; the close box is opt-in because the HIG's Chapter 6 text denies a movable modal one while its Figure 5-1 grants it — the parameter enables either reading), `frame?: 'plain'`.
227
230
  - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap). `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both directions. Drag the title bar to move it (shared `DragController` with `vf-window`), stating `top`/`left` in system px; drags starting on the close widget are ignored (same composedPath guard as `vf-window`). Escape → close + `vf-close` detail `{ reason: 'escape' }`; close box/programmatic/close() → `{ reason: 'close' }`. No backdrop dimming: `::backdrop { background: transparent; }`. **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`): HTML's dialog removing steps skip the close algorithm entirely, which is exactly what a framework unmount does — the teardown routes through the same native-`close` funnel (`vf-close` fires on the removed element; nothing bubbles, it left the tree), `open` and the written origin reconcile so a re-append mounts closed, and focus returns to the element focused at open time. **The placement re-settles while open** whenever the dialog's own box resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving under zoom — the viewport resizes, or the scale changes. What survives depends on how the modal got where it is: an unplaced one re-centers (it never claimed a spot, and the stranded case is exactly this one), while a dragged or authored origin is kept and only re-clamped on screen.
228
- - **Visual (default chrome):** `vfChromeFrame` + `vfTitleBar` (§4) — literally the same two recipes `vf-window` uses, so the bar is identical by construction (stripes + centered title) rather than by matching copies. It takes the default `--vf-title-inset` (16px) — 60px when `closable`, the same clearance as `vf-window`, since the centered title needs symmetric room for the widget — and sets `touch-action: none` unconditionally, having no immovable state. `closable` renders the shared close box (`vfWindowWidgets` + `closeBox()` — byte-identical to `vf-window`'s, per the `moveable modal dialog.png` reference). Body is WHITE (`--vf-surface: #fff`), separated from title bar by 1px black line, `padding: 16px`. An optional `buttons` slot renders a bottom-right `vf-button-group` footer that only takes space when populated (equal-width, faces aligned). Both chromes are full-height flex columns and the body takes the slack, for the same reason `vf-window`'s frame is: the declared `height` lands on the `<dialog>` (see `dialogSize`), and the recipes are skin only. The frame is the flex child of the `<dialog>` itself (`dialog[open]` is a flex column in `modalDialogStyles` — `[open]`-scoped, or it would out-cascade the UA's `dialog:not([open]) { display: none }`), not a `height: 100%` block: a percentage can't resolve against the undeclared-height dialog that only the UA's `dialog:modal` max-height caps, and that spill was how a viewport-tall modal stranded its buttons off-screen. **The box never grows, but over-stuffed content scrolls instead of clipping**: the body is a flex column of a `.content` scroll region (heading + default slot; part `content`) over the pinned footer. While the content fits, nothing matches — rendering is pixel-identical to the old block flow. Once it overflows (`ScrollStateController`, the always-a-rail machinery), the region reserves the 16px channel as its own right padding, wears the drawn `vfScrollRail` rail (§4) boxed by a 1px `.scroll-frame` overlay, and becomes a keyboard stop (`tabindex="0"`, `role="group"`, the kit's dotted ring) so the copy is scrollable without a pointer. The rail rides the wrapper as an overlay pinned to its right edge, deliberately out of the layout flow: a rail *column*'s two fixed 15px arrow cells would hand the region a 32px minimum height, and a short dialog would then measure as fitting with the rail shown and overflowing without it — flip-flopping forever. The drop-open exemption is unchanged.
231
+ - **Visual (default chrome):** `vfModalFrame` with `vfTitleBar` set into it (§4) — the bar is literally the recipe `vf-window` uses, so it is identical by construction (stripes + centered title) rather than by matching copies; the frame is the modal double frame, **not** the window's shadowed `vfChromeFrame`. Outside → in: 1px outer rule; the 18px bar directly under it, 2px in at either end with its stripes starting 2px from the rule; the 2px band as the bar's floor (the bar's rule + the inner box's 1px top border); 2px gap + 2px band down the sides and along the bottom; no shadow. The body begins 20px below the dialog's top and 5px inside its other three edges. The bar takes the default `--vf-title-inset` (16px) — 60px when `closable`, the same clearance as `vf-window`, since the centered title needs symmetric room for the widget — and sets `touch-action: none` unconditionally, having no immovable state. `closable` renders the shared close box (`vfWindowWidgets` + `closeBox()` — byte-identical to `vf-window`'s, per the `moveable modal dialog.png` reference). Body is WHITE (`--vf-surface: #fff`), `padding: 16px`. An optional `buttons` slot renders a bottom-right `vf-button-group` footer that only takes space when populated (equal-width, faces aligned). Both chromes are full-height flex columns and the body takes the slack, for the same reason `vf-window`'s frame is: the declared `height` lands on the `<dialog>` (see `dialogSize`), and the recipes are skin only. The frame is the flex child of the `<dialog>` itself (`dialog[open]` is a flex column in `modalDialogStyles` — `[open]`-scoped, or it would out-cascade the UA's `dialog:not([open]) { display: none }`), not a `height: 100%` block: a percentage can't resolve against the undeclared-height dialog that only the UA's `dialog:modal` max-height caps, and that spill was how a viewport-tall modal stranded its buttons off-screen. **The box never grows, but over-stuffed content scrolls instead of clipping**: the body is a flex column of a `.content` scroll region (heading + default slot; part `content`) over the pinned footer. While the content fits, nothing matches — rendering is pixel-identical to the old block flow. Once it overflows (`ScrollStateController`, the always-a-rail machinery), the region reserves the 16px channel as its own right padding, wears the drawn `vfScrollRail` rail (§4) boxed by a 1px `.scroll-frame` overlay, and becomes a keyboard stop (`tabindex="0"`, `role="group"`, the kit's dotted ring) so the copy is scrollable without a pointer. The rail rides the wrapper as an overlay pinned to its right edge, deliberately out of the layout flow: a rail *column*'s two fixed 15px arrow cells would hand the region a 32px minimum height, and a short dialog would then measure as fitting with the rail shown and overflowing without it — flip-flopping forever. The drop-open exemption is unchanged.
229
232
  - **The CSS and the controller state the same thing.** `.content` is `overflow-y: hidden` until the controller flags overflow, then `overflow-y: scroll`. It is deliberately never `auto`: the controller ignores the body face's negative half-leading (`LEADING_SPILL_SYS`, src/scroll-state.ts — `vf-paragraph` sets a 12-system-px line box under a 16-system-px em, so the inline box spills 2 inkless system px past the block box and `scrollHeight` counts it), and `auto` has no way to know that. With `auto` the two disagreed, and a fixed info dialog whose copy ends in a `vf-paragraph` rubber-banded under the wheel with no rail shown — 6 CSS px at scale 3. `hidden` still scrolls programmatically, so `scrollIntoView` on a focused control is unaffected. `npm run verify:contract`, OVERFLOW group.
230
- - **Visual (`frame="plain"`):** `vfModalFrame` (§4 — 1px outer, 2px gap, 2px inner band, no shadow, per `Windows/modal dialog.png`), no title bar, and immovable like the original dBoxProc dialog (nothing renders a drag handle). A `heading` renders as a centered display-face heading at the top of the body (`margin-bottom: 16px`) — the way those dialogs drew their title in content — and `closable` is ignored, there being no bar to carry the widget.
233
+ - **Visual (`frame="plain"`):** the same `vfModalFrame` (§4 — 1px outer, 2px gap, 2px inner band, no shadow, per `Windows/modal dialog.png`) with no title bar, and immovable like the original dBoxProc dialog (nothing renders a drag handle). The body begins 5px inside every edge. A `heading` renders as a centered display-face heading at the top of the body (`margin-bottom: 16px`) — the way those dialogs drew their title in content — and `closable` is ignored, there being no bar to carry the widget.
231
234
  - **A11y:** named by its own title patch — or, on the plain frame, its body-top heading; both carry `id="title"` — via `aria-labelledby` when `heading` is set. With no heading there is nothing to point at — `aria-labelledby` would resolve to an empty node and leave the dialog unnamed — so it falls back to `aria-label`, taking `label` if given and otherwise `'Dialog'`. An explicit `label` wins over `heading`. The close box is labeled `Close ${heading}` like `vf-window`'s.
232
235
  - **Slots:** default, `buttons`.
233
236
  - **Parts:** `frame`, `title-bar` (default chrome), `title`, `close-box` (when `closable`), `body`, `content`, `footer`, `buttons`.
@@ -352,7 +355,7 @@ The classic popup menu control ("Macintosh HD ▼").
352
355
 
353
356
  #### `vf-menu-bar` (`VfMenuBar`, vf-menu-bar.ts)
354
357
  - **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.
355
- - **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, `border-bottom: 1px solid var(--vf-black, #000)`, 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.
358
+ - **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.
356
359
  - **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*.
357
360
  - **Slots:** default (vf-menu elements). **Parts:** `bar`.
358
361
 
@@ -426,10 +429,10 @@ The kit's layout primitive: a flexbox whose `gap`, `pad`, `width` and `height` a
426
429
  - **Slots:** default (the children; `fill-width` / `fill-height` on any of them). **Parts:** none. **Events:** none.
427
430
 
428
431
  #### `vf-container` (`VfContainer`, vf-container.ts)
429
- A plain sized box: `width`/`height` in whole system px around a bare slot — no paint, 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.
430
- - **Attributes/props:** `width` / `height`: number (whole system px, via `VfSized`); the `top` / `left` pair via `VfPositioned` like nearly every component. On a **child**: `fill-width` and `fill-height`, bare attributes as in `vf-stack`. That is the whole API.
432
+ 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.
433
+ - **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 as the box's own border 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.
431
434
  - **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.
432
- - **Visual:** none — no border, background, role, keyboard behavior or selection; what it holds decides what it is. `: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 slot sits in one shadow box coinciding with the host box (`.vf-snap`, `display: flow-root`, `height: 100%`): flow-root so a slotted margin cannot collapse through the top edge and push the coordinate origin off the host's corner, 100% so percentage fills resolve against a declared height. That box owns `position: relative` — the anchor for `top`/`left` children — the component's purpose. Content that outgrows the declared box overflows it rather than growing it.
435
+ - **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 raster is sized from the declared axes and measured on an undeclared one. With `rule`, the same box wears `vfRule`'s per-edge classes (§4): the rule is its border, 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 — and the padding box, which placed children anchor to and percentage fills resolve against, begins inside the rule. A pattern's paper reaches the rule and its phase anchors at the padding 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 slot sits in one shadow box coinciding with the host box (`.vf-snap`, `display: flow-root`, `height: 100%`): flow-root so a slotted margin cannot collapse through the top edge and push the coordinate origin off the host's corner, 100% so percentage fills resolve against a declared height. That box owns `position: relative` — the anchor for `top`/`left` children — the component's purpose. Content that outgrows the declared box overflows it rather than growing it.
433
436
  - **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.)
434
437
  - **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.
435
438
  - **`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.