vintage-frames 0.6.0 → 0.6.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.
@@ -115,11 +115,19 @@ var v = class extends r(d) {
115
115
  fixed-width box, a wide image) grows the plane — precisely the
116
116
  content that overflows sideways. It also makes this box the
117
117
  containing block of a sticky child, so a sticky left: 0 strip holds
118
- across the scroll. */
118
+ across the scroll.
119
+
120
+ Never shorter than the viewport either: a fixed child (position.ts)
121
+ is a sticky box whose containing block is this plane, and a plane
122
+ shorter than the child's stated top clamps the child to the plane's
123
+ bottom edge (measured: top 100 over 50px of content landed at 50).
124
+ Content that fits still fits — the plane grows to the viewport, not
125
+ past it, so nothing overflows that didn't. */
119
126
  .content {
120
127
  position: relative;
121
128
  width: fit-content;
122
129
  min-width: 100%;
130
+ min-height: 100%;
123
131
  }
124
132
  /* Focusable so keyboard users can scroll; inset ring to stay in-box. */
125
133
  .viewport:focus-visible {
@@ -30,6 +30,13 @@ declare const VfWindow_base: (new (...args: any[]) => import("../size.js").VfSiz
30
30
  * says so once in the console.
31
31
  *
32
32
  * @slot - Default slot: window body content.
33
+ * @slot header - Optional header content — a band between the title bar and
34
+ * the body, the full width of the window (the Finder window's header
35
+ * line): a white interior over a 1px rule, no inset of its own, a
36
+ * positioning anchor for placed children. As tall as its content unless
37
+ * `header-height` states it. Under `scrollbars` the vertical rail begins
38
+ * below it, so the header spans the rail's column. Takes no space until
39
+ * populated.
33
40
  * @slot status - Optional status-bar content — the classic bottom readout
34
41
  * strip ("40px x 40px"): a 1px rule over a 15px white band under the body,
35
42
  * body-face text on its native line. Takes no space until populated; a
@@ -40,6 +47,8 @@ declare const VfWindow_base: (new (...args: any[]) => import("../size.js").VfSiz
40
47
  * @csspart close-box - The close widget (left).
41
48
  * @csspart zoom-box - The zoom widget (right).
42
49
  * @csspart body - The content area.
50
+ * @csspart header - The header strip between the title bar and the body
51
+ * (when the `header` slot is populated).
43
52
  * @csspart status-bar - The bottom status strip (when the `status` slot is
44
53
  * populated).
45
54
  * @csspart grow-box - The resize widget (bottom-right, when `resizable`).
@@ -134,6 +143,15 @@ export declare class VfWindow extends VfWindow_base {
134
143
  * placed inside the body.
135
144
  */
136
145
  scrollbars?: 'vertical' | 'horizontal' | 'both';
146
+ /**
147
+ * The header's height in whole system px, rule included — the way every
148
+ * kit bar counts its rule (an 18px title bar is 17 + 1). Unset, the header
149
+ * is as tall as what is slotted into it, plus the rule. Only matters while
150
+ * the `header` slot is populated.
151
+ */
152
+ headerHeight?: number | null;
153
+ /** Whether the `header` slot has assigned content (drives the header). */
154
+ private _hasHeader;
137
155
  /** Whether the `status` slot has assigned content (drives the strip). */
138
156
  private _hasStatus;
139
157
  /** Default-on display scaling (true 72dpi size); see src/scale.ts. */
@@ -190,6 +208,8 @@ export declare class VfWindow extends VfWindow_base {
190
208
  disconnectedCallback(): void;
191
209
  /** The `.empty` gate: the strip renders only while the slot is populated. */
192
210
  private _onStatusSlotChange;
211
+ /** The header's own gate, on the same terms. */
212
+ private _onHeaderSlotChange;
193
213
  private _onCloseClick;
194
214
  private _onZoomClick;
195
215
  private _onGrowPointerDown;
@@ -25,7 +25,7 @@ function V(e, t, n) {
25
25
  }
26
26
  var H = 24, U = 8, W = class extends E(c(N)) {
27
27
  constructor(...e) {
28
- super(...e), this.heading = "", this.active = !0, this.closable = !0, this.zoomable = !1, this.movable = !1, this.resizable = !1, this._hasStatus = !1, this.scale = new n(this), this.gridSnap = new T(this), this.titleCenter = new O(this), this._dotsPattern = "", this.#e = new x(), this.#t = (e, t, n) => {
28
+ super(...e), this.heading = "", this.active = !0, this.closable = !0, this.zoomable = !1, this.movable = !1, this.resizable = !1, this._hasHeader = !1, this._hasStatus = !1, this.scale = new n(this), this.gridSnap = new T(this), this.titleCenter = new O(this), this._dotsPattern = "", this.#e = new x(), this.#t = (e, t, n) => {
29
29
  let r = a(this.offsetWidth, this);
30
30
  return {
31
31
  x: Math.min(Math.max(e, H - r), n.width - H),
@@ -204,13 +204,39 @@ var H = 24, U = 8, W = class extends E(c(N)) {
204
204
  and a resizable window's grow box (z-index 1) lands over the rail
205
205
  corner cell — which the render reserves on a single-axis rail too
206
206
  (the area's corner flag), except when the status strip holds the
207
- grow box instead. */
207
+ grow box instead. The same overhang lands the area's top frame line
208
+ on the header's rule, so the vertical rail's top arrow begins
209
+ under the header. */
208
210
  .edge-scroll {
209
211
  width: calc(100% + var(--vf-scale, 1) * 2px);
210
212
  height: calc(100% + var(--vf-scale, 1) * 2px);
211
213
  margin: calc(var(--vf-scale, 1) * -1px);
212
214
  }
213
215
 
216
+ /* --- Header (slot="header") --------------------------------------- */
217
+ /* A band between the title bar and the body, the full width of the
218
+ window — the Finder window's header line: a white interior over a
219
+ 1px rule (vfRule's vf-rule-bottom on the element). Like the body it
220
+ carries no inset and is a positioning anchor: (0,0) is the header's
221
+ own corner, flow content starts there too, an inset is the content's.
222
+ As tall as its content unless header-height states it — rule
223
+ included, the way every kit bar counts its rule (an 18px title bar
224
+ is 17 + 1). Under the scrollbars parameter the edge rails sit in the
225
+ body below it, so the header spans the rail's column and the
226
+ vertical rail's arrows begin under it. Takes no space until the slot
227
+ is populated. Clipped like the body; a drop-open panel still escapes
228
+ (see the body's note). A div with a class, never a <header>: that
229
+ element maps to a banner landmark (see render()). */
230
+ .header {
231
+ position: relative;
232
+ flex: none;
233
+ background: var(--vf-white, #ffffff);
234
+ overflow: hidden;
235
+ }
236
+ .header.empty {
237
+ display: none;
238
+ }
239
+
214
240
  /* --- Status bar (slot="status") ---------------------------------- */
215
241
  /* The classic bottom readout strip: a 1px rule over a white interior,
216
242
  15px in all — the grow box's own height, so a resizable window's
@@ -329,6 +355,10 @@ var H = 24, U = 8, W = class extends E(c(N)) {
329
355
  let t = e.target;
330
356
  this._hasStatus = t.assignedElements().length > 0;
331
357
  }
358
+ _onHeaderSlotChange(e) {
359
+ let t = e.target;
360
+ this._hasHeader = t.assignedElements().length > 0;
361
+ }
332
362
  _onCloseClick() {
333
363
  e(this, "vf-close", { reason: "close" });
334
364
  }
@@ -378,6 +408,13 @@ var H = 24, U = 8, W = class extends E(c(N)) {
378
408
  <span class="vf-title" part="title" id="title">${this.heading}</span>
379
409
  ${this.zoomable ? M(j("Zoom", this.heading), this._onZoomClick) : I}
380
410
  `, this.variant === "utility" ? "vf-dots" : "vf-stripes", this.variant === "utility" ? this._dotsTexture() : void 0)}
411
+ <div
412
+ class="header vf-rule-bottom ${this._hasHeader ? "" : "empty"}"
413
+ part="header"
414
+ style=${this.headerHeight == null ? I : `height:${i(this.headerHeight)}`}
415
+ >
416
+ <slot name="header" @slotchange=${this._onHeaderSlotChange}></slot>
417
+ </div>
381
418
  <div class="body" part="body">
382
419
  ${this.scrollbars ? F`
383
420
  <vf-scroll-area
@@ -436,6 +473,9 @@ o([L({ reflect: !0 })], W.prototype, "variant", void 0), o([L()], W.prototype, "
436
473
  })], W.prototype, "maxWidth", void 0), o([L({
437
474
  type: Number,
438
475
  attribute: "max-height"
439
- })], W.prototype, "maxHeight", void 0), o([L({ reflect: !0 })], W.prototype, "scrollbars", void 0), o([R()], W.prototype, "_hasStatus", void 0), W = o([t("vf-window")], W);
476
+ })], W.prototype, "maxHeight", void 0), o([L({ reflect: !0 })], W.prototype, "scrollbars", void 0), o([L({
477
+ type: Number,
478
+ attribute: "header-height"
479
+ })], W.prototype, "headerHeight", void 0), o([R()], W.prototype, "_hasHeader", void 0), o([R()], W.prototype, "_hasStatus", void 0), W = o([t("vf-window")], W);
440
480
  //#endregion
441
481
  export { W as VfWindow };
@@ -9,7 +9,14 @@ type Constructor<T = object> = new (...args: any[]) => T;
9
9
  export declare abstract class VfPositionedInterface extends LitElement {
10
10
  top?: number | null;
11
11
  left?: number | null;
12
+ fixed: boolean;
12
13
  }
14
+ /** A host that takes the placement trio. */
15
+ type PositionedHost = HTMLElement & {
16
+ top?: number | null;
17
+ left?: number | null;
18
+ fixed?: boolean;
19
+ };
13
20
  /**
14
21
  * Explicit placement — `top`/`left` in whole system px, on **every** component.
15
22
  *
@@ -76,6 +83,24 @@ export declare abstract class VfPositionedInterface extends LitElement {
76
83
  * element. The controller re-applies **only when the values changed**, so an
77
84
  * unrelated update — a heading change, a desktop toggling `active` — never
78
85
  * re-asserts a coordinate and costs nothing.
86
+ *
87
+ * **`fixed`** holds the placement against the *visible* region of the nearest
88
+ * scrolling ancestor instead of its scrolled plane — a tool strip over a
89
+ * document, a column header over rows — and the flag alone places at (0,0).
90
+ * The engine underneath is `position: sticky`, the one way the platform holds
91
+ * a box against a scrollport from inside it on the compositor (a scroll
92
+ * listener counter-translating lags a frame; a layer outside the scroller
93
+ * stops wheel and touch over it). Sticky boxes stay in flow, which is not
94
+ * what a placement is, so the controller erases the footprint: the host is
95
+ * blockified (no line box), shrink-wrapped, and given a negative right/bottom
96
+ * margin equal to its own box — a 0×0 margin box that flow content lays out
97
+ * as if it weren't there, measured by a ResizeObserver so a relabel or a zoom
98
+ * step keeps it exact. `z-index: 1` puts it over the plane's placed children,
99
+ * which is where a fixed strip belongs. Two facts of the engine that the
100
+ * docs state as rules: sticky only ever pushes a box *down* from where the
101
+ * flow put it, so a fixed child comes before the flow content in its parent;
102
+ * and a scroll container that never scrolls (a plain window body is
103
+ * `overflow: hidden`) holds it exactly where placement would.
79
104
  */
80
105
  export declare const VfPositioned: <T extends Constructor<LitElement>>(Base: T) => Constructor<VfPositionedInterface> & T;
81
106
  /** The box a gesture is clamped into, in system px — see {@link PlacementClamp}. */
@@ -127,10 +152,7 @@ export type PlacementClamp = (x: number, y: number, bounds: PlacementBounds) =>
127
152
  */
128
153
  export declare class PlacementController {
129
154
  #private;
130
- constructor(host: HTMLElement & {
131
- top?: number | null;
132
- left?: number | null;
133
- }, clamp: PlacementClamp);
155
+ constructor(host: PositionedHost, clamp: PlacementClamp);
134
156
  /** Whether a gesture has placed this host (as opposed to markup or CSS). */
135
157
  get placed(): boolean;
136
158
  /**
@@ -189,8 +211,5 @@ export declare class PlacementController {
189
211
  * Returns whether it warned, so the caller can latch — one warning per element,
190
212
  * not per render.
191
213
  */
192
- export declare function warnMovableContract(host: HTMLElement & {
193
- top?: number | null;
194
- left?: number | null;
195
- }, what: string, example: string): boolean;
214
+ export declare function warnMovableContract(host: PositionedHost, what: string, example: string): boolean;
196
215
  export {};
package/dist/position.js CHANGED
@@ -2,31 +2,83 @@ import { snapSys as e, sysLength as t, toSysExact as n } from "./scale.js";
2
2
  import r from "./_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js";
3
3
  import { property as i } from "lit/decorators.js";
4
4
  //#region src/position.ts
5
- var a = (e) => {
5
+ var a = {
6
+ inline: "block",
7
+ "inline-block": "block",
8
+ "inline-flex": "flex",
9
+ "inline-grid": "grid",
10
+ "inline-table": "table"
11
+ }, o = (e) => {
6
12
  class t extends e {
7
13
  constructor(...e) {
8
- super(...e), new o(this);
14
+ super(...e), this.fixed = !1, new s(this);
9
15
  }
10
16
  }
11
- return r([i({ type: Number })], t.prototype, "top", void 0), r([i({ type: Number })], t.prototype, "left", void 0), t;
12
- }, o = class {
17
+ return r([i({ type: Number })], t.prototype, "top", void 0), r([i({ type: Number })], t.prototype, "left", void 0), r([i({
18
+ type: Boolean,
19
+ reflect: !0
20
+ })], t.prototype, "fixed", void 0), t;
21
+ }, s = class {
13
22
  #e;
14
23
  #t = null;
15
24
  #n = null;
16
25
  #r = !1;
26
+ #i = !1;
27
+ #a = null;
17
28
  constructor(e) {
18
29
  this.#e = e, e.addController(this);
19
30
  }
31
+ hostConnected() {
32
+ this.#i && this.#r && this.#c();
33
+ }
34
+ hostDisconnected() {
35
+ this.#a?.disconnect(), this.#a = null;
36
+ }
20
37
  hostUpdated() {
21
- let e = this.#e.top ?? null, n = this.#e.left ?? null, r = this.#e.style;
22
- if (e === null && n === null) {
23
- if (!this.#r) return;
24
- this.#r = !1, r.removeProperty("position"), r.removeProperty("top"), r.removeProperty("left"), r.removeProperty("right"), r.removeProperty("bottom"), r.removeProperty("margin");
38
+ let e = this.#e.top ?? null, n = this.#e.left ?? null, r = this.#e.fixed === !0, i = this.#e.style;
39
+ if (e === null && n === null && !r) {
40
+ if (!this.#i) return;
41
+ this.#r && this.#s(), this.#i = !1, this.#r = !1, i.removeProperty("position"), i.removeProperty("top"), i.removeProperty("left"), i.removeProperty("right"), i.removeProperty("bottom"), i.removeProperty("margin");
25
42
  return;
26
43
  }
27
- this.#r && e === this.#t && n === this.#n || (this.#r = !0, this.#t = e, this.#n = n, r.position = "absolute", r.top = t(e ?? 0), r.left = t(n ?? 0), r.right = "auto", r.bottom = "auto", r.margin = "0");
44
+ if (this.#i && e === this.#t && n === this.#n && r === this.#r) return;
45
+ let a = this.#i && this.#r;
46
+ this.#i = !0, this.#t = e, this.#n = n, this.#r = r, i.position = r ? "sticky" : "absolute", i.top = t(e ?? 0), i.left = t(n ?? 0), i.right = "auto", i.bottom = "auto", r ? a || this.#o() : (a && this.#s(), i.margin = "0");
28
47
  }
29
- }, s = class {
48
+ #o() {
49
+ let e = this.#e.style;
50
+ e.maxWidth = "fit-content", e.zIndex = "1", this.#l = !1;
51
+ let t = this.#e.getBoundingClientRect();
52
+ (t.width > 0 || t.height > 0) && this.#u(t.width, t.height), this.#c();
53
+ }
54
+ #s() {
55
+ this.#a?.disconnect(), this.#a = null, this.#l = !1;
56
+ let e = this.#e.style;
57
+ e.removeProperty("display"), e.removeProperty("max-width"), e.removeProperty("z-index");
58
+ }
59
+ #c() {
60
+ this.#a ??= new ResizeObserver((e) => {
61
+ let t = e[e.length - 1]?.borderBoxSize?.[0];
62
+ if (t) this.#u(t.inlineSize, t.blockSize);
63
+ else {
64
+ let e = this.#e.getBoundingClientRect();
65
+ this.#u(e.width, e.height);
66
+ }
67
+ }), this.#a.observe(this.#e);
68
+ }
69
+ #l = !1;
70
+ #u(e, t) {
71
+ let n = this.#e.style;
72
+ if (!this.#l) {
73
+ let e = getComputedStyle(this.#e).display;
74
+ if (e) {
75
+ let t = a[e];
76
+ t && (n.display = t), this.#l = !0;
77
+ }
78
+ }
79
+ n.margin = `0 ${-e}px ${-t}px 0`;
80
+ }
81
+ }, c = class {
30
82
  #e;
31
83
  #t;
32
84
  #n = !1;
@@ -43,7 +95,7 @@ var a = (e) => {
43
95
  x: t.left ?? 0,
44
96
  y: t.top ?? 0
45
97
  };
46
- let r = getComputedStyle(t), i = r.position === "absolute" || r.position === "fixed", a = i ? parseFloat(r.left) || 0 : t.offsetLeft, o = i ? parseFloat(r.top) || 0 : t.offsetTop;
98
+ let r = getComputedStyle(t), i = l.has(r.position), a = i ? parseFloat(r.left) || 0 : t.offsetLeft, o = i ? parseFloat(r.top) || 0 : t.offsetTop;
47
99
  return {
48
100
  x: e(n(a, t), t),
49
101
  y: e(n(o, t), t)
@@ -60,15 +112,19 @@ var a = (e) => {
60
112
  height: n(r?.clientHeight ?? window.innerHeight, e)
61
113
  };
62
114
  }
63
- };
64
- function c(e, t, n) {
65
- let r = e.top != null || e.left != null, i = getComputedStyle(e).position, a = i === "absolute" || i === "fixed", o = null;
66
- if (!r && !a) o = "unplaced";
115
+ }, l = /* @__PURE__ */ new Set([
116
+ "absolute",
117
+ "fixed",
118
+ "sticky"
119
+ ]);
120
+ function u(e, t, n) {
121
+ let r = e.top != null || e.left != null || e.fixed === !0, i = l.has(getComputedStyle(e).position), a = null;
122
+ if (!r && !i) a = "unplaced";
67
123
  else {
68
124
  let t = e.offsetParent;
69
- t && t !== e.ownerDocument.body && (t.clientWidth === 0 || t.clientHeight === 0) && (o = "unsized-parent");
125
+ t && t !== e.ownerDocument.body && (t.clientWidth === 0 || t.clientHeight === 0) && (a = "unsized-parent");
70
126
  }
71
- return o !== null && (console.warn(o === "unplaced" ? `${t}: movable, but in normal flow — so the first drag has to pull it out, reflowing everything after it on the page. State the origin in system px and it never was in flow: ${n}` : `${t}: the positioning parent has no box, so there is nowhere to drag to. Give it a size (and \`position: relative\`, if it is not a kit container) — the clamp is falling back to the viewport meanwhile.`), !0);
127
+ return a !== null && (console.warn(a === "unplaced" ? `${t}: movable, but in normal flow — so the first drag has to pull it out, reflowing everything after it on the page. State the origin in system px and it never was in flow: ${n}` : `${t}: the positioning parent has no box, so there is nowhere to drag to. Give it a size (and \`position: relative\`, if it is not a kit container) — the clamp is falling back to the viewport meanwhile.`), !0);
72
128
  }
73
129
  //#endregion
74
- export { s as PlacementController, a as VfPositioned, c as warnMovableContract };
130
+ export { c as PlacementController, o as VfPositioned, u as warnMovableContract };
package/docs/SPEC.md CHANGED
@@ -46,7 +46,7 @@ Modern requirements that we deliberately keep (accessibility over purity):
46
46
  - **The name/description bridge** (`VfShadowRoleControl`, src/form-control.ts). On the controls whose role lives on a shadow-internal node (the three fields, `vf-select`, `vf-swatch`, `vf-button`), a host-level `aria-labelledby`, `aria-label` or associated `<label for>` resolves — in html-aam precedence — to the inner focusable element's `aria-label` whenever the `label` property is empty (`hostLabel`). Those six extend `VfShadowRoleControl`; the host-role controls (`vf-checkbox`, `vf-radio-group`, `vf-slider`) extend the plain `VfFormControl` and so never *carry* the bridge's API at all — an inherited `description` that renders nothing is the advertised-but-inert shape this split removes, and `verify:manifest` enforces it (a tag whose manifest lists `description` must call `renderDescription()`). `vf-button` takes the ARIA half alone (`hostAriaLabel`): a `<button>` is not a labelable element, so no caption names a native one and none names this one either — its slotted content is the name a bridge doesn't override. A `description` property (or, when it's empty, a host-level `aria-describedby`) renders as a hidden span in the control's own shadow root with the inner control's `aria-describedby` pointing at it — the shadow-internal IDREF idiom `vf-dialog`'s title patch uses — and a failing constraint's `validationMessage` joins the same node. Referenced text is flattened at render time, so an edit to a referenced element's *text* lands on the next render rather than instantly — the one divergence from native. Controls whose role sits on the host (the toggles, slider, radio group, bars) need none of this: the platform reads their host attributes directly.
47
47
  - **Constraint validation** (`VfFormControl`): a reflected `required` fails validation with `valueMissing` while the control is empty by its own definition (fields/select `value === ''`, checkbox unchecked, radio group unselected — each with its native counterpart's message), through one `syncValidity()` funnel run before each render. The native surface is complete — `checkValidity`/`reportValidity`/`validity`/`validationMessage`/ `willValidate`/`setCustomValidity` — `:invalid` matches on the host, `form.reportValidity()` blocks, and `disabled`/`readonly` bar validation per HTML's own rules. One divergence the platform imposes: `willValidate` is `true` on a `vf-button type="button"`, where a native button is barred from constraint validation. A form-associated custom element cannot opt out of the candidate set, so this is a platform limit. AT wiring is `aria-required`/`aria-invalid` on the inner control (plus internals mirrors for host-role controls) — never a forwarded native `required`, which would put UA `:user-invalid` styling on the artwork. Enter's implicit submission routes through the browser's validation, so it cannot submit past a failing constraint.
48
48
  - Components must render nothing surprising outside their box: no margins on `:host` by default.
49
- - **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's *content region* (the frame's inner edge below the title bar, with no inset of its own — flow content starts there too), a dialog's content area, 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. `npm run verify:position`.
49
+ - **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's *content region* (the frame's inner edge below the title bar, with no inset of its own — flow content starts there too), a dialog's content area, 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. **`fixed`** (bare attribute, reflected) holds the placement against the *visible* region of the nearest scrolling ancestor instead of its scrolled plane: `position: sticky` in place of `absolute`, the same live offsets, the flag alone at (0,0). Sticky boxes stay in flow, so the controller erases the footprint — the host is blockified (the outer display change `absolute` makes on its own: `inline-block` → `block`, `inline-flex` → `flex`, …), `max-width: fit-content`, `z-index: 1` (over the plane's placed children), and a negative right/bottom margin equal to its own border box, kept exact by a ResizeObserver — a 0×0 margin box that flow content lays out as if it weren't there. Two engine facts stated as rules: sticky only ever pushes a box *down* from where the flow put it, so a fixed child precedes the flow content in its parent; and a scroll container that never scrolls (a plain window body is `overflow: hidden`) holds it exactly where placement would. Removing the flag returns the host to ordinary placement with the recipe unwound. `npm run verify:position`.
50
50
  - `vf-dialog` takes the pair in **viewport** coordinates: `showModal()` puts the box in the top layer, whose containing block is the viewport rather than the nearest positioned ancestor. Unset means centered — recomputed on open and on every box/viewport/scale change, so a modal whose content upgrades after opening is never stranded; `position` is left to the UA and only the four inset/margin declarations are written.
51
51
  - **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e).
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.
@@ -208,21 +208,22 @@ Full-bleed classic desktop container.
208
208
 
209
209
  #### `vf-window` (`VfWindow`, vf-window.ts)
210
210
  The desktop-window shell: the classic document window (see DragThing screenshot), parameterized down to the windoid (see the Group A recipe table).
211
- - **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), `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).
212
- - **Visual:** `vfChromeFrame` + `vfTitleBar` (§4), plus a full-size flex-column layout on the frame. `display: block`. Sets `--vf-surface: var(--vf-white, #fff)` on itself.
211
+ - **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), `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).
212
+ - **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.
213
213
  - 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]`.
214
214
  - 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).
215
215
  - Close box: LEFT side, 11×11px, 8px from the inner-left edge, with 3px of clear white above and below it, `1px solid black`, white bg, no bevel, surrounded by a 1px white patch interrupting the stripes. `:active` (pressed) → the interior fills with the classic radiating "go-away" sunburst: black 1-bit spokes on the white face (four orthogonal 3px spokes plus four diagonal 2px ones around an empty center), traced pixel-for-pixel from the UI kit's close-button-active-state sprite.
216
216
  - 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.
217
217
  - 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.
218
218
  - 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.
219
+ - 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.
219
220
  - 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.
220
221
  - 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 (the border-floor term alone), 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).
221
222
  - 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.
222
223
  - **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`.
223
224
  - **Behavior:** close box click → `vf-close` (does NOT remove itself; consumer decides). Zoom box click → `vf-zoom`. If `movable`: dragging the title bar moves the window — the drag seeds its origin from the current offset position (once, converting to system px) and then states `left`/`top` via pointer capture, in whole system px like any authored placement. If `resizable`: dragging the grow box states `width`/`height` the same way, each axis clamped into the sizeRect (`min-width`/`max-width`, `min-height`/`max-height` — GrowWindow's: the app stated the rectangle of sizes and the Window Manager clamped the drag to it; the mins default to the 80×54 floor a window can still be worked at, the maxes are unbounded, and a min equal to its max locks the axis — the strip that scrolls sideways and never grows taller). The clamp runs after the lattice snap, so a bound lands exactly the way an authored size does even off the lattice (an odd height at 2×); where a min and max cross the max wins, so a window authored under the floor and held there never jumps to it. The rect bounds the gesture only: a size authored outside it renders as declared, and the first grow-box move brings it inside. The drag fires `vf-resize` (detail `{ width, height, commit }`, sizes in whole system px) — one event per size the drag actually writes (`commit: false`), fired *after* the new box is applied so a handler that measures reads the resized layout, then a final `commit: true` as the gesture settles (release or cancel), only when it changed the size. Window content can follow the grow box from this event alone, no ResizeObserver. Fired by the gesture only: a programmatic `width`/`height` write fires nothing, the way a value set fires no `vf-change`.
224
- - **Slots:** default (body content), `status` (the bottom status strip — see Visual).
225
- - **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `body`, `status-bar`, `grow-box`, plus `viewport` re-exported from the built-in scroll area when `scrollbars` is set.
225
+ - **Slots:** default (body content), `header` (the header strip between the title bar and the body), `status` (the bottom status strip — see Visual).
226
+ - **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `header`, `body`, `status-bar`, `grow-box`, plus `viewport` re-exported from the built-in scroll area when `scrollbars` is set.
226
227
  - **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
227
228
 
228
229
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
@@ -390,7 +391,7 @@ Classic list box.
390
391
  #### `vf-scroll-area` (`VfScrollArea`, vf-scroll-area.ts)
391
392
  A container whose scrollbars look like System 7.
392
393
  - **Attributes/props:** `axis: 'vertical' | 'horizontal' | 'both'` (default `'vertical'`, reflected) — which scroll rails to reserve as permanent placeholders (see "always-a-rail" below); `corner: boolean` (default false, reflected) — reserves the bottom-right corner cell on a single-axis rail (the rail stops 15px short of the frame, the viewport spans the rest), for a grow box to land in; inert on `both`, which always has the cell; `label: string` — accessible name for the viewport (`aria-label` on the viewport, since an `aria-label` on the host cannot reach into the shadow DOM). The viewport is a **Tab stop only while its content actually overflows** — the same state `ScrollStateController` measures for the rails; a fitting scroll area used to be a focusable stop with `role: generic` and no name, a dead Tab press. Whenever it is a stop it carries a role: `role="region"` when `label` names it (a named landmark), `role="group"` when not (an unnamed region is inert, so that role is reserved for the labelled case).
393
- - **Visual:** `display: block`; the snapped wrapper carries a real 1px black frame and a grid reserving each rail as its own edge column/row — `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the white inner viewport carrying no inset of its own — only the border-floor compensation (a `mod()` term restoring what engines floor off the fractional border-width), so slotted content and the (0,0) of placed children sit exactly 1 system px from the frame box at every scale; an inset is the content's (a `vf-stack pad`). Consumer sets width/height on host. The scrolled plane — the wrapper the slot renders into, and the positioned anchor for placed children — is `width: fit-content; min-width: 100%`: never narrower than the viewport, as wide as content that cannot wrap, so the controllers' ResizeObserver on it sees a row growing sideways the way it sees copy growing down (a block wrapper's auto width was the viewport's whatever the row did, and a row gaining a cell left the thumb and the overflow state stale until something else re-measured); copy wraps as before, and a `position: sticky; left: 0` child holds across the scroll, the plane being its containing block. In the overflowing case a percentage-width or centered block child resolves against the grown plane, not the viewport. A `slotchange` re-measures; `measure()` covers a scroll range that changes with no box changing. The rails are the drawn `vfScrollRail` subtree (§4 — the classic 16px cell counting the frame line: divider, 14px channel, 15px arrow cells, the fixed 16px thumb, the `tileRaster` dot-dither trough), rendered as later siblings of the viewport and synced to its native scrolling by `ScrollRailController`; the viewport carries the `vf-scroll` class, which hides the native bar without touching the native scrolling. When both rails are reserved, the corner cell joins them, supplying the interior dividers the adjacent arrow cells leave to it; `corner` reserves the same 15×15 cell beside a single rail, its other divider continuing the rail's line across the cell — DrawGrowIcon's form, for the grow box. Every engine renders the same rail; the old `::-webkit-scrollbar` skin, its `.vf-scroll-frame` overlay contortion (WebKit quantized native scrollbar rects to whole CSS px) and the Firefox `scrollbar-color` fallback are all retired.
394
+ - **Visual:** `display: block`; the snapped wrapper carries a real 1px black frame and a grid reserving each rail as its own edge column/row — `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the white inner viewport carrying no inset of its own — only the border-floor compensation (a `mod()` term restoring what engines floor off the fractional border-width), so slotted content and the (0,0) of placed children sit exactly 1 system px from the frame box at every scale; an inset is the content's (a `vf-stack pad`). Consumer sets width/height on host. The scrolled plane — the wrapper the slot renders into, and the positioned anchor for placed children — is `width: fit-content; min-width: 100%`: never narrower than the viewport, as wide as content that cannot wrap, so the controllers' ResizeObserver on it sees a row growing sideways the way it sees copy growing down (a block wrapper's auto width was the viewport's whatever the row did, and a row gaining a cell left the thumb and the overflow state stale until something else re-measured); copy wraps as before, and a `position: sticky; left: 0` child holds across the scroll, the plane being its containing block. `min-height: 100%` likewise: a `fixed` child (§2 Explicit placement) is a sticky box the plane contains, and a plane shorter than the child's stated `top` would clamp it to the plane's bottom edge; content that fits still fits. In the overflowing case a percentage-width or centered block child resolves against the grown plane, not the viewport. A `slotchange` re-measures; `measure()` covers a scroll range that changes with no box changing. The rails are the drawn `vfScrollRail` subtree (§4 — the classic 16px cell counting the frame line: divider, 14px channel, 15px arrow cells, the fixed 16px thumb, the `tileRaster` dot-dither trough), rendered as later siblings of the viewport and synced to its native scrolling by `ScrollRailController`; the viewport carries the `vf-scroll` class, which hides the native bar without touching the native scrolling. When both rails are reserved, the corner cell joins them, supplying the interior dividers the adjacent arrow cells leave to it; `corner` reserves the same 15×15 cell beside a single rail, its other divider continuing the rail's line across the cell — DrawGrowIcon's form, for the grow box. Every engine renders the same rail; the old `::-webkit-scrollbar` skin, its `.vf-scroll-frame` overlay contortion (WebKit quantized native scrollbar rects to whole CSS px) and the Firefox `scrollbar-color` fallback are all retired.
394
395
  - **Always-a-rail behavior:** each *reserved* axis (per `axis`) renders its rail element as a permanent placeholder — arrow buttons on an empty white channel (dither off, no thumb; System 7 drew the arrows on any bar in an active window, and a scroller outside a window always counts as active) — until the content overflows that axis, when the dither and thumb fill in and the arrows go live (an idle axis's arrows are drawn but inert — the press guard in `ScrollRailController` skips them). `ScrollStateController` (`src/scroll-state.ts`) measures both axes and writes `data-overflow-x` / `data-overflow-y` (`"true"` / `"false"`) on the scroll element; the recipe keys the dither and thumb off those attributes. The unreserved axis still scrolls natively (wheel, keyboard) but draws no rail. Shared by vf-list and vf-text-area; a future `@container scroll-state(scrollable)` query could replace the JS for slotted-content components.
395
396
  - **Inactive-window blanking:** the HIG's non-frontmost window must not display interactive scroll UX, so the controller also finds the nearest `vf-window` up the composed tree (light-DOM ancestor for a slotted scroller, shadow ancestor for `vf-window[scrollbars]`'s own edge rails), watches its reflected `active` attribute, and toggles a presence-only `data-window-inactive` on the scroll element. While present, the recipe empties dither/thumb/arrows on BOTH axes regardless of overflow — the bare channel, arrows included (unlike the idle rail, which keeps them), exactly as System 7 blanked a deactivated window's bars (its List Manager/TextEdit deactivated in-window scrollbars too). No `vf-window` ancestor → the attribute never appears: dialogs have no inactive state and a bare scroll component always draws live. Like the overflow half, this signal could one day go declarative — a custom property cascaded under `vf-window:not([active])` gating the recipe via an `@container style()` query; the rails being ordinary DOM, that migration is a plain selector swap (see the FUTURE note in scroll-state.ts).
396
397
  - **Document-window (TeachText) composition:** to put the rails on a window's edge rather than inset in its body, slot the scroll area into a `vf-window` sized `calc(100% + 2px·scale)` with `margin: -1px·scale` — one system pixel under the window frame on every side. Its own frame border then repaints the window's border lines exactly (no doubled frame), and a resizable window's grow box lands exactly over the rail's corner cell, giving the classic System 7 document window. `vf-window[scrollbars]` renders exactly this composition from its own shadow tree (forwarding `corner` for a resizable window without a status strip), so the one-liner and the slotted form are geometrically identical; slot it yourself when the well should sit inside the body instead of on its edge (the installer's read-me well, placed with `top`/`left`).