vintage-frames 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The Finder "open" ghost — an icon's art redrawn as outline plus dither,
3
- * derived from the art itself in the client.
3
+ * derived from the art itself in the client — and, on the same terms, the
4
+ * dotted outline an icon drags as ({@link deriveDragOutline}).
4
5
  *
5
6
  * When a folder or application is open, the Finder redraws its icon as a
6
7
  * ghost: the outline stays, and everything inside it becomes pattern. The kit
@@ -38,3 +39,61 @@
38
39
  * an environment refusing a 2d context.
39
40
  */
40
41
  export declare function deriveOpenArt(art: HTMLImageElement | HTMLCanvasElement): HTMLCanvasElement | null;
42
+ /** A box inside an icon's frame, in system px. */
43
+ export interface DragOutlineBox {
44
+ x: number;
45
+ y: number;
46
+ width: number;
47
+ height: number;
48
+ }
49
+ /**
50
+ * Where an icon's parts sit inside its frame, in system px — measured by the
51
+ * icon, so the outline lands on the same pixels the frame paints.
52
+ */
53
+ export interface DragOutlineGeometry {
54
+ /** The frame. */
55
+ width: number;
56
+ height: number;
57
+ /**
58
+ * The art's own box, where it paints. Null when the pipeline has nothing
59
+ * to draw — nothing slotted, a failed load, an inline `<svg>` — and the
60
+ * cell's rectangle stands in.
61
+ */
62
+ art: DragOutlineBox | null;
63
+ /** The reserved art cell. */
64
+ cell: DragOutlineBox;
65
+ /** The name plate, or null with no name. */
66
+ plate: DragOutlineBox | null;
67
+ }
68
+ /**
69
+ * Derive the outline an icon drags as: the mask's boundary — the same
70
+ * one-pixel ring the open ghost finds, by the same erosion — and the name
71
+ * plate's rectangle, solid black on transparent, one canvas the size of the
72
+ * icon's frame at one image px per system px. Not yet dotted: the ring is
73
+ * derived once per drag, and {@link dotOutline} re-applies the checker at
74
+ * each step's phase.
75
+ *
76
+ * Art the pipeline cannot draw falls back to the cell's rectangle — the
77
+ * ghost's rule: never a blank where a state cannot show. Compositing only,
78
+ * never a readback, so cross-origin art derives too.
79
+ *
80
+ * Returns null for a frame with no size, or an environment refusing a 2d
81
+ * context.
82
+ */
83
+ export declare function deriveDragOutline(art: HTMLImageElement | HTMLCanvasElement | null, geometry: DragOutlineGeometry): HTMLCanvasElement | null;
84
+ /**
85
+ * Paint a selection rectangle — the rubber band `vf-icon-field` drags on its
86
+ * background — into `into`: a one-pixel outline `width` × `height` system px,
87
+ * dotted at `phase` the way the drag outline is. A degenerate axis paints as
88
+ * a one-pixel line rather than nothing.
89
+ */
90
+ export declare function paintSelectionRect(into: HTMLCanvasElement, width: number, height: number, phase: number): void;
91
+ /**
92
+ * Paint `ring` into `into` dotted with the checker at `phase` — 0 keeps the
93
+ * dots where `x + y` is even in the canvas's own coordinates, 1 where it is
94
+ * odd. The caller picks the phase from where the outline lands on screen,
95
+ * so the dots share the desktop dither's phase wherever the outline goes,
96
+ * as QuickDraw's screen-anchored pattern pen did. Sizing `into` to the ring
97
+ * clears it, so each step is a fresh paint.
98
+ */
99
+ export declare function dotOutline(ring: HTMLCanvasElement, into: HTMLCanvasElement, phase: number): void;
package/dist/open-art.js CHANGED
@@ -31,5 +31,51 @@ function r(t) {
31
31
  let c = o.ctx.createPattern(s, "repeat");
32
32
  return c && (o.ctx.globalCompositeOperation = "source-atop", o.ctx.fillStyle = c, o.ctx.fillRect(0, 0, r, i)), a.ctx.globalCompositeOperation = "source-over", a.ctx.drawImage(o.canvas, 0, 0), a.canvas;
33
33
  }
34
+ var i = null, a = () => {
35
+ if (!i) {
36
+ let t = e(2, 2);
37
+ if (!t) return null;
38
+ t.ctx.fillStyle = "#000", t.ctx.fillRect(0, 0, 1, 1), t.ctx.fillRect(1, 1, 1, 1), i = t.canvas;
39
+ }
40
+ return i;
41
+ }, o = (e, t) => {
42
+ t.width <= 0 || t.height <= 0 || (e.fillStyle = "#000", e.fillRect(t.x, t.y, t.width, t.height), t.width > 2 && t.height > 2 && e.clearRect(t.x + 1, t.y + 1, t.width - 2, t.height - 2));
43
+ };
44
+ function s(t, n) {
45
+ let { width: r, height: i } = n;
46
+ if (!r || !i) return null;
47
+ let a = e(r, i);
48
+ if (!a) return null;
49
+ let s = t ? n.art : null;
50
+ if (t && s && s.width > 0 && s.height > 0) {
51
+ let n = e(r, i), o = e(r, i);
52
+ if (!n || !o) return null;
53
+ n.ctx.drawImage(t, s.x, s.y, s.width, s.height), n.ctx.globalCompositeOperation = "source-in", n.ctx.fillStyle = "#000", n.ctx.fillRect(0, 0, r, i), o.ctx.drawImage(n.canvas, 0, 0), o.ctx.globalCompositeOperation = "destination-in";
54
+ for (let [e, t] of [
55
+ [1, 0],
56
+ [-1, 0],
57
+ [0, 1],
58
+ [0, -1]
59
+ ]) o.ctx.drawImage(n.canvas, e, t);
60
+ a.ctx.drawImage(n.canvas, 0, 0), a.ctx.globalCompositeOperation = "destination-out", a.ctx.drawImage(o.canvas, 0, 0), a.ctx.globalCompositeOperation = "source-over";
61
+ } else o(a.ctx, n.cell);
62
+ return n.plate && o(a.ctx, n.plate), a.canvas;
63
+ }
64
+ function c(t, n, r, i) {
65
+ let a = Math.max(1, Math.round(n)), s = Math.max(1, Math.round(r)), c = e(a, s);
66
+ c && (o(c.ctx, {
67
+ x: 0,
68
+ y: 0,
69
+ width: a,
70
+ height: s
71
+ }), l(c.canvas, t, i));
72
+ }
73
+ function l(e, t, n) {
74
+ let r = a(), i = t.getContext("2d");
75
+ if (!r || !i) return;
76
+ t.width = e.width, t.height = e.height, i.imageSmoothingEnabled = !1, i.drawImage(e, 0, 0);
77
+ let o = i.createPattern(r, "repeat");
78
+ o && (i.globalCompositeOperation = "destination-in", i.fillStyle = o, i.translate(n & 1, 0), i.fillRect(-2, 0, e.width + 2, e.height), i.setTransform(1, 0, 0, 1, 0, 0), i.globalCompositeOperation = "source-over");
79
+ }
34
80
  //#endregion
35
- export { r as deriveOpenArt };
81
+ export { s as deriveDragOutline, r as deriveOpenArt, l as dotOutline, c as paintSelectionRect };
@@ -84,6 +84,16 @@ type PositionedHost = HTMLElement & {
84
84
  * unrelated update — a heading change, a desktop toggling `active` — never
85
85
  * re-asserts a coordinate and costs nothing.
86
86
  *
87
+ * **Every write it makes is announced** as `vf-placement-change` (bubbles,
88
+ * `composed: false`, detail `{ left, top }` — nulls on a return to flow). It
89
+ * is an internal coordination event on the terms of the menu handshakes:
90
+ * parent and child share one light tree, and a kit protocol must not leak out
91
+ * of a consumer's shadow boundary. `vf-scroll-area` listens for it and
92
+ * re-measures, because a placed child moved through `top`/`left` changes the
93
+ * scroll range with no box changing anywhere a ResizeObserver can see — the
94
+ * plane's box is never grown by an absolutely positioned child. One funnel
95
+ * covers a drop, an arrow nudge and a page's own `icon.left = …` alike.
96
+ *
87
97
  * **`fixed`** holds the placement against the *visible* region of the nearest
88
98
  * scrolling ancestor instead of its scrolled plane — a tool strip over a
89
99
  * document, a column header over rows — and the flag alone places at (0,0).
@@ -103,6 +113,25 @@ type PositionedHost = HTMLElement & {
103
113
  * `overflow: hidden`) holds it exactly where placement would.
104
114
  */
105
115
  export declare const VfPositioned: <T extends Constructor<LitElement>>(Base: T) => Constructor<VfPositionedInterface> & T;
116
+ /**
117
+ * A viewport point as a placement in `anchor`'s coordinates: `{ left, top }`
118
+ * in whole system px on the placement lattice, measured from the anchor's
119
+ * box the way a placed child's `top`/`left` are — the unit and lattice a
120
+ * drag lands on, so the result can be written straight to a child's pair.
121
+ *
122
+ * The three containers whose anchor sits in shadow DOM (`vf-window`'s
123
+ * content region, `vf-desktop`'s screen, `vf-scroll-area`'s plane) expose it
124
+ * as `placementAt(clientX, clientY)`; everywhere else the anchor is the
125
+ * element's own padding box, and `toSysExact` against
126
+ * `getBoundingClientRect()` is the documented conversion. The anchor is
127
+ * expected to carry no border of its own (the three do not), so its border
128
+ * box is its padding box; `scaleAt` is the element whose `--vf-scale` the
129
+ * conversion reads — the component's host.
130
+ */
131
+ export declare function placementIn(anchor: Element, clientX: number, clientY: number, scaleAt?: Element): {
132
+ left: number;
133
+ top: number;
134
+ };
106
135
  /** The box a gesture is clamped into, in system px — see {@link PlacementClamp}. */
107
136
  export type PlacementBounds = {
108
137
  width: number;
@@ -155,6 +184,12 @@ export declare class PlacementController {
155
184
  constructor(host: PositionedHost, clamp: PlacementClamp);
156
185
  /** Whether a gesture has placed this host (as opposed to markup or CSS). */
157
186
  get placed(): boolean;
187
+ /**
188
+ * The box the gesture is clamped into, in system px: the one {@link seed}
189
+ * measured, or a fresh measurement before any gesture. A group drag reads
190
+ * it to clamp a whole selection's delta at once.
191
+ */
192
+ get bounds(): PlacementBounds;
158
193
  /**
159
194
  * The origin a move adds its delta to, in system px.
160
195
  *
package/dist/position.js CHANGED
@@ -1,24 +1,25 @@
1
- import { snapSys as e, sysLength as t, toSysExact as n } from "./scale.js";
2
- import r from "./_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js";
3
- import { property as i } from "lit/decorators.js";
1
+ import { emit as e } from "./events.js";
2
+ import { snapSys as t, sysLength as n, toSysExact as r } from "./scale.js";
3
+ import i from "./_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js";
4
+ import { property as a } from "lit/decorators.js";
4
5
  //#region src/position.ts
5
- var a = {
6
+ var o = {
6
7
  inline: "block",
7
8
  "inline-block": "block",
8
9
  "inline-flex": "flex",
9
10
  "inline-grid": "grid",
10
11
  "inline-table": "table"
11
- }, o = (e) => {
12
+ }, s = (e) => {
12
13
  class t extends e {
13
14
  constructor(...e) {
14
- super(...e), this.fixed = !1, new s(this);
15
+ super(...e), this.fixed = !1, new c(this);
15
16
  }
16
17
  }
17
- return r([i({ type: Number })], t.prototype, "top", void 0), r([i({ type: Number })], t.prototype, "left", void 0), r([i({
18
+ return i([a({ type: Number })], t.prototype, "top", void 0), i([a({ type: Number })], t.prototype, "left", void 0), i([a({
18
19
  type: Boolean,
19
20
  reflect: !0
20
21
  })], t.prototype, "fixed", void 0), t;
21
- }, s = class {
22
+ }, c = class {
22
23
  #e;
23
24
  #t = null;
24
25
  #n = null;
@@ -29,56 +30,70 @@ var a = {
29
30
  this.#e = e, e.addController(this);
30
31
  }
31
32
  hostConnected() {
32
- this.#i && this.#r && this.#c();
33
+ this.#i && this.#r && this.#l();
33
34
  }
34
35
  hostDisconnected() {
35
36
  this.#a?.disconnect(), this.#a = null;
36
37
  }
37
38
  hostUpdated() {
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) {
39
+ let e = this.#e.top ?? null, t = this.#e.left ?? null, r = this.#e.fixed === !0, i = this.#e.style;
40
+ if (e === null && t === null && !r) {
40
41
  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");
42
+ this.#r && this.#c(), this.#i = !1, this.#r = !1, i.removeProperty("position"), i.removeProperty("top"), i.removeProperty("left"), i.removeProperty("right"), i.removeProperty("bottom"), i.removeProperty("margin"), this.#o(null, null);
42
43
  return;
43
44
  }
44
- if (this.#i && e === this.#t && n === this.#n && r === this.#r) return;
45
+ if (this.#i && e === this.#t && t === this.#n && r === this.#r) return;
45
46
  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");
47
+ this.#i = !0, this.#t = e, this.#n = t, this.#r = r, i.position = r ? "sticky" : "absolute", i.top = n(e ?? 0), i.left = n(t ?? 0), i.right = "auto", i.bottom = "auto", r ? a || this.#s() : (a && this.#c(), i.margin = "0"), this.#o(t ?? 0, e ?? 0);
48
+ }
49
+ #o(t, n) {
50
+ e(this.#e, "vf-placement-change", {
51
+ left: t,
52
+ top: n
53
+ }, { composed: !1 });
47
54
  }
48
- #o() {
55
+ #s() {
49
56
  let e = this.#e.style;
50
- e.maxWidth = "fit-content", e.zIndex = "1", this.#l = !1;
57
+ e.maxWidth = "fit-content", e.zIndex = "1", this.#u = !1;
51
58
  let t = this.#e.getBoundingClientRect();
52
- (t.width > 0 || t.height > 0) && this.#u(t.width, t.height), this.#c();
59
+ (t.width > 0 || t.height > 0) && this.#d(t.width, t.height), this.#l();
53
60
  }
54
- #s() {
55
- this.#a?.disconnect(), this.#a = null, this.#l = !1;
61
+ #c() {
62
+ this.#a?.disconnect(), this.#a = null, this.#u = !1;
56
63
  let e = this.#e.style;
57
64
  e.removeProperty("display"), e.removeProperty("max-width"), e.removeProperty("z-index");
58
65
  }
59
- #c() {
66
+ #l() {
60
67
  this.#a ??= new ResizeObserver((e) => {
61
68
  let t = e[e.length - 1]?.borderBoxSize?.[0];
62
- if (t) this.#u(t.inlineSize, t.blockSize);
69
+ if (t) this.#d(t.inlineSize, t.blockSize);
63
70
  else {
64
71
  let e = this.#e.getBoundingClientRect();
65
- this.#u(e.width, e.height);
72
+ this.#d(e.width, e.height);
66
73
  }
67
74
  }), this.#a.observe(this.#e);
68
75
  }
69
- #l = !1;
70
- #u(e, t) {
76
+ #u = !1;
77
+ #d(e, t) {
71
78
  let n = this.#e.style;
72
- if (!this.#l) {
79
+ if (!this.#u) {
73
80
  let e = getComputedStyle(this.#e).display;
74
81
  if (e) {
75
- let t = a[e];
76
- t && (n.display = t), this.#l = !0;
82
+ let t = o[e];
83
+ t && (n.display = t), this.#u = !0;
77
84
  }
78
85
  }
79
86
  n.margin = `0 ${-e}px ${-t}px 0`;
80
87
  }
81
- }, c = class {
88
+ };
89
+ function l(e, n, i, a = e) {
90
+ let o = e.getBoundingClientRect();
91
+ return {
92
+ left: t(r(n - o.left, a), a),
93
+ top: t(r(i - o.top, a), a)
94
+ };
95
+ }
96
+ var u = class {
82
97
  #e;
83
98
  #t;
84
99
  #n = !1;
@@ -89,36 +104,39 @@ var a = {
89
104
  get placed() {
90
105
  return this.#n;
91
106
  }
107
+ get bounds() {
108
+ return this.#r ?? this.#i();
109
+ }
92
110
  seed() {
93
- let t = this.#e;
94
- if (this.#r = this.#i(), t.left != null || t.top != null) return {
95
- x: t.left ?? 0,
96
- y: t.top ?? 0
111
+ let e = this.#e;
112
+ if (this.#r = this.#i(), e.left != null || e.top != null) return {
113
+ x: e.left ?? 0,
114
+ y: e.top ?? 0
97
115
  };
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;
116
+ let n = getComputedStyle(e), i = d.has(n.position), a = i ? parseFloat(n.left) || 0 : e.offsetLeft, o = i ? parseFloat(n.top) || 0 : e.offsetTop;
99
117
  return {
100
- x: e(n(a, t), t),
101
- y: e(n(o, t), t)
118
+ x: t(r(a, e), e),
119
+ y: t(r(o, e), e)
102
120
  };
103
121
  }
104
- moveTo(t, n) {
105
- let r = this.#e, i = this.#t(t, n, this.#r ?? this.#i());
106
- r.left = e(i.x, r), r.top = e(i.y, r), this.#n = !0;
122
+ moveTo(e, n) {
123
+ let r = this.#e, i = this.#t(e, n, this.#r ?? this.#i());
124
+ r.left = t(i.x, r), r.top = t(i.y, r), this.#n = !0;
107
125
  }
108
126
  #i() {
109
- let e = this.#e, t = e.offsetParent, r = t && t.clientWidth > 0 && t.clientHeight > 0 ? t : null;
127
+ let e = this.#e, t = e.offsetParent, n = t && t.clientWidth > 0 && t.clientHeight > 0 ? t : null;
110
128
  return {
111
- width: n(r?.clientWidth ?? window.innerWidth, e),
112
- height: n(r?.clientHeight ?? window.innerHeight, e)
129
+ width: r(n?.clientWidth ?? window.innerWidth, e),
130
+ height: r(n?.clientHeight ?? window.innerHeight, e)
113
131
  };
114
132
  }
115
- }, l = /* @__PURE__ */ new Set([
133
+ }, d = /* @__PURE__ */ new Set([
116
134
  "absolute",
117
135
  "fixed",
118
136
  "sticky"
119
137
  ]);
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;
138
+ function f(e, t, n) {
139
+ let r = e.top != null || e.left != null || e.fixed === !0, i = d.has(getComputedStyle(e).position), a = null;
122
140
  if (!r && !i) a = "unplaced";
123
141
  else {
124
142
  let t = e.offsetParent;
@@ -127,4 +145,4 @@ function u(e, t, n) {
127
145
  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);
128
146
  }
129
147
  //#endregion
130
- export { c as PlacementController, o as VfPositioned, u as warnMovableContract };
148
+ export { u as PlacementController, s as VfPositioned, l as placementIn, f as warnMovableContract };
package/docs/SPEC.md CHANGED
@@ -48,7 +48,7 @@ Modern requirements that we deliberately keep (accessibility over purity):
48
48
  - Components must render nothing surprising outside their box: no margins on `:host` by default.
49
49
  - **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's *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
- - **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).
51
+ - **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }`) — an internal coordination event on the menu handshakes' terms, which is how a `vf-scroll-area` re-measures under a child dropped, nudged or set past its viewport with no code on the page. `npm run verify:icon-drag` (RE-MEASURE).
52
52
  - **The movable contract.** A host that moves under a gesture states its own rectangle, and its positioning parent is a box with a size. Both halves are the consumer's — a component can supply neither for itself — and both used to fail quietly, so each is a one-time `console.warn` (`warnMovableContract`, latched per element like `vf-window`'s size warning). `npm run verify:position` group CONTRACT.
53
53
  - `vf-window[movable]` states `top`/`left` **and** `width`/`height`; a WIND resource carried the whole rect. `vf-icon[movable]` states `top`/`left` only — it is not `VfSized`, its height is its content's, and `width` is the cell pitch. `vf-dialog` is exempt: `showModal()` anchors it to the viewport, and unset means centered.
54
54
  - What the first half actually requires is being **out of flow** when the gesture starts; `top`/`left` are the kit's way and the only one that scales, but a stylesheet's own `position: absolute` satisfies it and `seed()` reads that case from the computed offsets (a faux desktop places its icons this way). A host still in flow has to be taken out of it by its first move, which reflows everything after it and collapses an auto-height parent to whatever is left. **The clamp therefore runs against the box measured once by `seed()`, not a fresh one per move**: re-reading it would hold the rest of the gesture inside a box the host never sat in, walking it to the parent's origin while the user drags the other way.
@@ -195,13 +195,14 @@ Every recipe also declares its size — `width`, and `height` where the window i
195
195
  #### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
196
196
  Full-bleed classic desktop container.
197
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.
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.
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. `placementAt(clientX, clientY)` → a viewport point as a placement on the screen, `{ left, top }` in whole system px on the placement lattice, measured from the raster's corner with the bezel excluded — the pair a child dropped onto the desktop is written with (`placementIn`, src/position.ts; the anchor sits in shadow, which is why it is a method).
199
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.
200
200
  - **Slots:** default (menu bar, windows, anything).
201
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`.
202
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 in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); focus 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`.
203
203
  - **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant` *attribute*, so a not-yet-upgraded element still lands right.
204
- - **Menu tier:** a slotted `vf-menu-bar` — or a free-standing `vf-menu` placed on the desktop — is restated at `z-index: 2_000_000` by the desktop's own `::slotted(vf-menu-bar), ::slotted(vf-menu)` rule (an outer-tree `::slotted` declaration beats the bar's inner `:host { z-index: 1000 }`), a band above the floating tier, so a dropped menu paints over palettes and document windows alike — the Menu Manager drew menus over every window. Only the screen-corner mask is in front. The full stack inside the screen's isolated context: consumer tile grid (−1) < document windows (counter) < utility windows (counter + 1_000_000) < menu tier (2_000_000) < corner mask (max). A popup *inside* a window (`vf-select`'s fixed panel) is a different case: it stacks within its window's context, so a palette can still cover it — KNOWN-BUGS.md. `npm run verify:archetypes` (MENU TIER).
204
+ - **Menu tier:** a slotted `vf-menu-bar` — or a free-standing `vf-menu` placed on the desktop — is restated at `z-index: 2_000_000` by the desktop's own `::slotted(vf-menu-bar), ::slotted(vf-menu)` rule (an outer-tree `::slotted` declaration beats the bar's inner `:host { z-index: 1000 }`), a band above the floating tier, so a dropped menu paints over palettes and document windows alike — the Menu Manager drew menus over every window. Only the drag outline and the screen-corner mask are in front. The full stack inside the screen's isolated context: consumer tile grid (−1) < document windows (counter) < utility windows (counter + 1_000_000) < menu tier (2_000_000) < drag outline (2_000_001) < corner mask (max). A popup *inside* a window (`vf-select`'s fixed panel) is a different case: it stacks within its window's context, so a palette can still cover it — KNOWN-BUGS.md. `npm run verify:archetypes` (MENU TIER).
205
+ - **The drag surface:** the Finder drew a dragged icon's outline on the *screen*, clipped at its edge and phase-locked to its raster, so the screen renders the layer that outline draws on — `.drag-surface`, a full-bleed child of `.screen` after the slot, `pointer-events: none` (never a hit for the page's `elementsFromPoint`), inside the raster's clip. It is deliberately no stacking context (no z-index of its own): a stacking context is an isolated group, and the outline's `mix-blend-mode` would then composite against the empty layer instead of the windows and dither beneath; the tier goes on its children (`.drag-surface > * { z-index: 2_000_001 }`), which places the outline in `.screen`'s own context, above the menu bar and under the corner mask. An icon reaches it through the `vf-drag-surface-request` handshake (§5 vf-icon): the desktop listens on its host, and the nearest desktop fills a still-null `detail.surface`. Not the top layer: stable Safari cannot blend an element there against the page (`XOR_UNAVAILABLE`, src/cursor.ts), and a second kit entry in the top layer would need the re-promotion handshake KNOWN-BUGS.md #1 describes. `npm run verify:icon-drag` (OUTLINE).
205
206
  - **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
206
207
  - **Events:** `vf-activate` (detail `{ window: HTMLElement | null }` — the new holder, `null` on deactivation).
207
208
  - **Parts:** `desktop`.
@@ -222,6 +223,7 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
222
223
  - 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.
223
224
  - **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`.
224
225
  - **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`.
226
+ - **Methods:** `placementAt(clientX, clientY)` → a viewport point as a placement in the content region — `{ left, top }` in whole system px on the placement lattice: the body's corner, or under `scrollbars` the scrolled plane with the scroll offset and the border-floor padding already in it (the built-in area's own `placementAt`). The pair a child dropped into the window is written with. `measure()` → the built-in scroll area's `measure()`, a no-op without `scrollbars`; the area re-measures by itself on its content's box, its slot and every placement write (`vf-placement-change`, §2), so this is the escape hatch for a range that changes with none of those.
225
227
  - **Slots:** default (body content), `header` (the header strip between the title bar and the body), `status` (the bottom status strip — see Visual).
226
228
  - **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.
227
229
  - **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
@@ -395,7 +397,8 @@ A container whose scrollbars look like System 7.
395
397
  - **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.
396
398
  - **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).
397
399
  - **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`).
398
- - **Methods:** `measure()` → re-measure overflow and re-sync the rails. The area tracks its content's box and its slot by itself; this is for a scroll range that changes with no box changing anywhere — a placed child moved through `top`/`left`, a transform — the way the kit's own fields re-measure on input.
400
+ - **Re-measures under a moved child.** A placed child moved through `top`/`left` changes the scroll range with no box changing anywhere the ResizeObserver can see — the plane's box is never grown by an absolutely positioned child — so the placement controller announces every write as `vf-placement-change` (§2) and the area re-measures on it, heard on the host: the event is non-composed, but from a slotted child it still travels through the slot it is assigned to, which is how the window's built-in area (this element, in the window's shadow) hears an icon slotted into the window body. A drop, an arrow nudge and a page's own `icon.left = …` all go through the one funnel.
401
+ - **Methods:** `measure()` → re-measure overflow and re-sync the rails, for a scroll range that changes with no box, no slot and no placement write — a transform — the way the kit's own fields re-measure on input. `placementAt(clientX, clientY)` → a viewport point as a placement on the scrolled plane, `{ left, top }` in whole system px on the placement lattice; the plane travels with the scroll, so its box already carries the offset and the border-floor padding.
399
402
  - **Slots:** default. **Parts:** `viewport`.
400
403
 
401
404
  #### `vf-fieldset` (`VfFieldset`, vf-fieldset.ts)
@@ -471,18 +474,31 @@ A raster image — the reference art's pixel icons — on the kit's grid, treati
471
474
 
472
475
  #### `vf-icon` (`VfIcon`, vf-icon.ts)
473
476
  The Finder icon — pixel art in a reserved cell with its name on a plate below, as one selectable, movable, renameable unit. `vf-img` puts a picture on the grid; this makes it the thing the Finder manipulates.
474
- - **Attributes/props:** `label`: string (the name; empty draws no plate — that is the "no label" setting, rather than a second attribute that could disagree with it). `size`: `large` (default, 32×32) | `small` (16×16) — picks the slot that paints *and* the cell it paints in. `selectable`, `selected`, `movable`, `editable`: boolean. `color`: boolean — declares the art a **color icon**, so selection darkens instead of inverting (see Visual). `open`: boolean — the icon's window is on screen, and the art paints as the derived open ghost (below). `width`: number (system px, **even**; the cell/grid pitch, which a longer name overflows rather than being bound by). `maxlength`: number (31).
477
+ - **Attributes/props:** `label`: string (the name; empty draws no plate — that is the "no label" setting, rather than a second attribute that could disagree with it). `size`: `large` (default, 32×32) | `small` (16×16) — picks the slot that paints *and* the cell it paints in. `selectable`, `selected`, `movable`, `editable`: boolean. `color`: boolean — declares the art a **color icon**, so selection darkens instead of inverting (see Visual). `open`: boolean — the icon's window is on screen, and the art paints as the derived open ghost (below). `target`: boolean (reflected) — the destination highlight under a drag: the selected treatment of the *art* alone (`invert(1)`, or `brightness(0.5)` under `color`) with none of `selected`'s semantics — no `vf-select`, no outside listener, no `aria-selected`, the plate untouched; the page sets it on the folder a `vf-drag` reports the pointer over. `width`: number (system px, **even**; the cell/grid pitch, which a longer name overflows rather than being bound by). `maxlength`: number (31).
475
478
  - **Visual:** `display: inline-block`. A column: the art cell (`--_cell` system px square, `overflow: hidden`, art centered), `--vf-icon-gap` (2px), then the name plate — body face on a `--vf-icon-label-height` (12px) line box, the Finder's own plate height: the face's 16px em centers in it, 3px above the ascenders and descenders on the bottom edge, exactly as the Finder drew it — 1px horizontal padding, **opaque `--vf-white`** in its own right rather than `--vf-surface`: on the desktop dither the name reads because it sits on a plate, and `--vf-surface` is unset out there (`Example screen (1-bit).png`, the "Macintosh HD" icon). Selected: the plate takes the `--vf-highlight` pair and the art takes `filter: invert(1)` — a System 7 icon is ink and opaque white on a transparent surround, i.e. precisely an image plus its mask, so inverting flips the two and leaves the surround alone. That is the complete classic selected appearance for 1-bit art. Color art would invert into a photographic negative, which System 7 never showed: declare `color` and selection **darkens** instead — Icon Utilities' ttSelected transform, every color blended halfway toward black (`filter: brightness(0.5)`), whites going gray, the surround still untouched. The declaration is the consumer's, since the component cannot tell color art from a mask by looking at a slotted image; it covers whatever the cell shows (the open ghost included, so a color icon has one selected treatment open or closed), and the plate inverts either way — the selected name was inverted whatever the art.
476
479
  - **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art redraws as the Finder's open-window ghost — outline held in solid black, interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the slotted art itself, by compositing alone: a silhouette via `source-in`; a 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette shifted one pixel each way (off-canvas composites as transparent, so the raster's own edge erodes too and an interior hole rings itself); the lattice via `createPattern` anchored at the art's top-left, where 32 and 16 being whole multiples of the tile keeps a field of icons in one phase; then the fill composed over the un-eroded silhouette, so the ring the erosion removed reads as the outline. Never `getImageData` — a canvas that drew a cross-origin image is tainted, which forbids *reading* pixels but not drawing or displaying them, so the ghost works for CORS-less sources too — and never `ctx.filter` (Safari's shaky corner; compositing operators are universal). Keeping the interior *opaque white* is what lets a selected open icon invert under the same `filter: invert(1)` with no second treatment. The slot stays in the tree while the ghost paints, hidden via a class gated on a successful derivation — art the pipeline cannot draw (nothing slotted, a failed load, an inline `<svg>`) keeps rendering as itself. The ghost re-derives on slotchange, the art's `load`/`error`, and `open`/`size` changes, keyed by `currentSrc` so a refresh that changed nothing is a string compare; the source's `alt` is carried onto the ghost (`role="img"` + `aria-label`) so a graphic that names its icon keeps doing so while its slot is hidden. The ghost displays on `vf-img`'s terms: natural raster size in system px, `image-rendering: pixelated`, one image pixel per system pixel.
477
- - **Behavior:** clicking selects, Shift/⌘ toggles, and a press outside every icon clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape, plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
478
- - **An icon alone is a picture; an icon in a field is an option.** `option` is invalid without a `listbox` that owns it — written unconditionally the browser drops the role *and* `aria-selected`, which is how a `selectable` icon reached AT as a bare generic in every configuration the kit shipped. So the role follows the container: `role="option"` + `aria-selected` when a `[role="listbox"]` ancestor claims it, otherwise `role="img"` named from `label` (else the art's `alt`), since `img` is not a name-from-content role. Not `button` — that promises Enter/Space activate, while here Return renames and the open route is ⌘O / ⌘↓. Re-derived on every connect, so re-parenting between the two contexts re-grades the icon. **The recipe for a field of icons is a container carrying `role="listbox"`** (`aria-label` + `aria-multiselectable`); `vf-desktop` cannot be it, since it also holds windows and a menu bar and a non-`option` child of a listbox is invalid the same way. Divergence from APG, recorded: its listbox options share one roving tab stop, the kit's stay one stop each.
480
+ - **Behavior:** clicking selects, Shift/⌘ toggles, and a press outside every icon clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape (the frame is the handle and sets `touch-action: none` while movable, as the title bars do), plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
481
+ - **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step away from the press (the jitter test: a stationary press snaps back onto the same system px and never starts a drag, so a plain click never fires a drop); `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press — and whose `preventDefault()` writes nothing, the page re-parenting or placing the icon itself; `vf-drag-cancel` `{}` on Escape or a `pointercancel`, nothing written. `left`/`top` in the detail are the proposal *unclamped* (the origin plus the lattice-snapped delta); only the default action clamps, so a page that cancels gets the raw gesture. The drop is dispatched with the outline gone, the drag state cleared and the pointer capture released, so re-parenting inside the handler is safe (Chromium drops the `click` of a press whose node was re-inserted at `pointerup`; the icon listens for no click and times its double-click on presses).
482
+ - **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the one-pixel erosion by four `destination-in` draws on a frame-sized layer (the frame is transparent around the art, so its edge erodes as the off-canvas edge did), the ring the erosion removes via `destination-out`, plus the plate's rectangle as fills (a 1px stroke straddles the pixel); compositing only, so cross-origin art derives too. Art the pipeline cannot draw falls back to the cell's rectangle — never a blank. One canvas the size of the frame at one image px per system px; the ring is derived once per drag, and each step re-dots it against a 2×2 checker (`destination-in`) at the parity that lands on the *screen*: ink where `x + y` is even in screen coordinates, the desktop dither's own phase (`gray-50`), so the outline and the dither share one phase wherever the outline lands, as QuickDraw's screen-anchored pattern pen did — over a white body a dotted black line, over the dither its dots invert the dither's ink. Painted with the cursor's XOR recipe, `filter: invert(1)` under `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`, sized and placed as live `sysLength()`s so a scale change mid-gesture cannot smear it; its origin is the frame's whole-system-px offset from the surface plus the delta. **The surface is the desktop's, not the top layer:** the icon dispatches `vf-drag-surface-request` (bubbles, `composed: false`, `detail.surface` null) on the first step, and the nearest `vf-desktop` on its light-DOM path fills it with its drag surface (§5 vf-desktop); a non-composed event from a slotted icon still travels through the slots it is assigned to, so the handshake works from inside a window body. Without a desktop the canvas is appended to the icon's own `.frame` (the vf-snap box, `position: relative`), translated by the delta alone and clipped by whatever clips the icon — the honest fallback, with the same gesture and events. Not `position: fixed` inside the icon: a `vf-window` is a stacking context, so a fixed outline inside it would paint at the window's z, under every other window (KNOWN-BUGS.md #1 in another costume). One drag, no opt-in: the Finder never moved the icon itself, the drop lands where it always did, and the kit ships no taste knobs. Escape is heard by a document `keydown` listener scoped to the gesture (`DocumentListenersController`, capture phase, the key stopped there so an enclosing `vf-dialog` never reads it as a dismissal) rather than the icon's own handler, because a `movable`-only icon never takes focus — an addition in the kit's idiom (§1), not a claim about the Finder. `DragController` gained the two trailing parameters this needs (`onDrag(x, y, event)`, `onDragEnd(event, cancelled)`) and a `cancel()`; `vf-window` and `vf-dialog` ignore them. A selected icon re-parented at the drop keeps its outside-press listener: it re-attaches on connect, since the controller detaches on disconnect and `updated()` does not re-run for an unchanged property. `npm run verify:icon-drag`.
483
+ - **The selection travels.** A drag that begins on a *selected* icon carries every other selected, movable icon of its field (`closest('vf-icon-field, [role="listbox"]')`, document order) — the Finder outlined the whole selection under a drag on any member. Each follower is joined through `vf-icon.followDrag(surface)` (internal to the group drag): it seeds its own origin, builds its own outline on the surface the leader's handshake found (or in its own frame), and hands back a handle `{ icon, origin, size, move(dx, dy), drop(dx, dy), end() }`. The leader moves every outline by its lattice delta on each step; every event's detail carries the set as `icons`, the leader first (`VfIconDragDetail`). The drop's default action clamps the delta **once for the whole group** — per axis, the tightest of every member's room to its edges, so the arrangement is kept and the group stops when its outermost icon meets the container (a group no edge can hold is held off the near edge only, the single-icon rule; `PlacementController.bounds` is the box, measured at the press) — then writes each member's origin plus that delta through its own `moveTo`. A cancel, or a cancelled drop, takes every outline down and writes nothing for anyone. **The press keeps the selection:** an icon's outside-press listener leaves the selection alone when a plain press lands on an icon already selected in the same field (document capture runs before the pressed icon's own handler, so `selected` reads as the press found it); a click on such an icon, released with no drag, collapses the selection to it on the `pointerup` — so a click still single-selects, and the same press could have dragged them all. A press on an unselected icon makes it the selection first, so it drags alone; a Shift press that deselects the pressed icon drags it alone too; a selected icon in another field, or one that cannot move, stays. `npm run verify:icon-drag` (GROUP).
484
+ - **An icon alone is a picture; an icon in a field is an option.** `option` is invalid without a `listbox` that owns it — written unconditionally the browser drops the role *and* `aria-selected`, which is how a `selectable` icon reached AT as a bare generic in every configuration the kit shipped. So the role follows the container: `role="option"` + `aria-selected` when a `vf-icon-field` or a `[role="listbox"]` ancestor claims it (`closest('vf-icon-field, [role="listbox"]')` — the field's role lives in internals and never lands as an attribute), otherwise `role="img"` named from `label` (else the art's `alt`), since `img` is not a name-from-content role. Not `button` — that promises Enter/Space activate, while here Return renames and the open route is ⌘O / ⌘↓. Re-derived on every connect, so re-parenting between the two contexts re-grades the icon. **The container for a field of icons is `vf-icon-field`** (below); a consumer's own element carrying `role="listbox"` still owns an icon the same way. `vf-desktop` cannot be it, since it also holds windows and a menu bar and a non-`option` child of a listbox is invalid the same way. Divergence from APG, recorded: its listbox options share one roving tab stop, the kit's stay one stop each.
479
485
  - **`selectable` is what makes an icon focusable** — `movable` and `editable` require it, as the Finder did (you cannot move or rename what you have not selected; the rename already opens only on an ALREADY-selected icon). A `movable`-only icon is draggable but not a tab stop, rather than a tab stop that announces nothing.
480
486
  - **A name is never abbreviated, and never folded:** no ellipsis, no clipping, no wrapping — `white-space: nowrap`, one line always. System 7 solved the long-name problem at the other end — HFS capped a filename at 31 characters — so the Finder always drew the name in full. Wider than its cell, it overflows it, centered. `width` is therefore the cell (the grid pitch), not a bound on the name.
481
487
  - **Every centered offset is whole, by parity.** The frame centers two things over one axis, and a centered child sits at `(box − child) / 2` — whole exactly when box and child share a parity. Half a system pixel is what fringes 1-bit art: glyph stems smear across two device columns and go gray while the plate behind them stays sharp, since backgrounds are pixel-snapped by the compositor and glyphs are not (hence a crisp plate under a gray name). So the component makes the parities agree rather than correcting afterwards. The cell is 32 or 16; `#measurePlate()` measures the text with a `Range` over the plate's own contents — the plate already carries the width this last computed, so reading the element back would only return it — and sizes the plate (an `inline-block`) to that plus its 1px padding, rounded UP to a whole **even** number of system px, so it never clips the run it was measured from. The measurement is in system px and therefore scale-invariant, which is why nothing re-runs on a density change; it re-runs on `label`, `_draft`, `_editing`, and once on `document.fonts.ready`, since a name measured against the fallback face is a different width. The text is NOT centered inside its own plate (`text-align: left` against the block's `center`) — that would re-introduce the half pixel one level down; it starts at the 1px padding edge, so its x is the plate's plus one whole pixel, and the rounding slack lands after it as a hairline of extra plate. The rename field puts its run exactly where the plate's is, so the name does not shift when editing starts; its 1px border and 1px white well are a wrapper around the input rather than the input's own border and padding, because the selection behind a highlighted name paints at the face's full 16px em and is clipped only at the input's own edge — split, the input stands the plate's 12px line box (clipping the selection to it) and the well stays white, for an edit box 16 system px tall in all, not the em plus trim. Staying on one line is part of the same argument: one run, one measured width, one parity. Nothing is snapped and nothing leans on the rasterizer — the kit's normal antialiasing stays on, and `verify:icon` asserts both the geometry and zero gray pixels across a field of names at dpr 1/2/3, declared width and auto, with a control that forces an odd plate and requires the fringe to return. The one thing the component cannot round is a `width` the consumer chose, so that must be even.
482
488
  - **The 31-char cap** is `maxlength` on the rename field, and exceeding it fires `vf-name-too-long` rather than dropping characters silently. Detected in `beforeinput`, not `input`: with `maxlength` set, a blocked keystroke fires `beforeinput` and then *no* `input` at all, while an overshooting paste reaches `input` already trimmed — only the "about to change" event still knows what was attempted. `input` carries a trimming backstop for any route that skips it. The cap bounds typing only; a `label` set from consumer code is displayed as given and fires nothing (the name is the consumer's data).
483
489
  - **An empty name is refused, not applied.** A file has to be called something, and System 7 would not commit a nameless one: a rename committed empty — or as nothing but spaces, which would read as blank either way — drops the edit and restores the previous name, firing `vf-name-rejected` and deliberately no `vf-change`. An empty `label` remains a valid *starting* state (a freshly made icon has no name yet): it draws no plate but stays selectable, focusable and renameable. The caption element is therefore rendered whenever there is a name **or** an edit is open — without that an unnamed icon has nowhere to put the field and could never be named at all. The plate hugs its text in BOTH states, which is what keeps a commit from moving the name: it is the same box either side, so the glyphs do not shift. The single exception is an edit with no text in it at all, which reserves the cell width so the field is usable rather than a 2px sliver, and goes back to hugging on the first character. That floor is the cell, which is even, so the parity rule holds.
490
+ - **Methods:** `startEditing()`, `commitEditing()`, `cancelEditing()` — the rename field's three routes. `setSelected(next)` → select or deselect as a press would, `vf-select` included (a `selected` write from code is silent, the way a value set fires no `vf-change`); the route a container's gesture takes — `vf-icon-field`'s rubber band. `touches(box)` → whether a viewport box (CSS px `left/top/right/bottom`) reaches the icon's art cell or its name plate — the Finder's selection-rectangle test, in which the empty cell around narrower art and the frame's margin beside the plate count for nothing; the rubber band reads it.
484
491
  - **Slots:** `large` (32×32 art, normally a `vf-img`), `small` (16×16). **Parts:** `frame`, `icon`, `label`, `plate`, `input`.
485
- - **Events:** `vf-select` `{ selected }`, `vf-change` `{ label, previous }`, `vf-open` `{}`, `vf-name-too-long` `{ attempted, accepted, limit }`, `vf-name-rejected` `{ attempted, kept, reason }`.
492
+ - **Events:** `vf-select` `{ selected }`, `vf-change` `{ label, previous }`, `vf-open` `{}`, `vf-name-too-long` `{ attempted, accepted, limit }`, `vf-name-rejected` `{ attempted, kept, reason }`, `vf-drag-start` `{ left, top, icons }`, `vf-drag` and `vf-drop` `{ clientX, clientY, left, top, x, y, icons }` (`vf-drop` cancelable), `vf-drag-cancel` `{}`.
493
+
494
+ #### `vf-icon-field` (`VfIconField`, vf-icon-field.ts)
495
+ The container a field of `vf-icon`s sits in — a desktop's icons, a folder window's contents. Every other set in the kit has its container (`vf-list`/`vf-list-item`, `vf-radio-group`/`vf-radio`, `vf-menu`/`vf-menu-item`, `vf-select`/`vf-option`); `vf-icon` was the one item whose container the consumer wrote by hand, and a consumer who forgot the role got icons that silently degraded to pictures.
496
+ - **Attributes/props:** `label`: string — the listbox's accessible name, through internals. `size?: 'large' | 'small'` (reflected) — the view's icon size, written onto every icon in the field (descendants, so a wrapper is no barrier) on slotchange and whenever it changes — View → by Small Icon as one attribute; unset, each icon keeps its own. `top`/`left` (`VfPositioned`) and `width`/`height` (`VfSized`), like the other declared-box containers. `fill-width` / `fill-height`, bare attributes read about the host the way `vf-stack` and `vf-container` read them (`:host([fill-width]) { width: 100% }`): 100% of a desktop's screen or a window body (a definite flex item), the field staying static so its icons keep their anchor — what it gains is a surface to press.
497
+ - **Semantics through internals:** `role="listbox"` and `aria-multiselectable="true"` always — the Finder was always multi-select, so there is no attribute for it — so a consumer's own `role`/`aria-*` on the tag wins (SPEC §2). A `selectable` icon inside is an `option` carrying `aria-selected`.
498
+ - **Visual:** none. `display: block; width: fit-content` — an in-flow block with no size, no inset and no position of its own, painting nothing (the `vf-stack` posture, with its typographic-transparency reset). A field whose icons are all placed is a zero-height block, so the icons keep anchoring to the desktop's raster or the window's plane, stored coordinates mean what they meant, and a press on the bare dither still reaches the desktop. Stated `top`/`left` and `width`/`height` make it a box and the anchor its icons place against — the placement working, as documented for everything. No `GridSnapController`: it paints nothing and its icons correct their own origins. Carries a `ScaleController` for a declared size on a plain page (the `vf-stack` reasoning).
499
+ - **The rubber band** — the kit's first piece of a container-level selection model. A `pointerdown` on the host whose `target` is the host itself (nothing slotted was hit; a press on an icon arrives with the icon as target and is the icon's own gesture), button 0, on a field with a box: the pointer is captured on the host, the press's default suppressed (no text selection across the page), and the **anchor** read — the icons selected *as the press found them*; the icons' outside-press listeners are document capture, so they have already run: a plain press cleared everything (an empty anchor), Shift or ⌘ kept the selection. The first move whose point (whole system px from the field's box) differs from the press starts the band; each changed rectangle — press to point, both held inside the band's *reach* — is repainted (`paintSelectionRect`, `open-art.ts`: the drag outline's dotted one-pixel ring at the rectangle's size, a degenerate axis as a one-pixel line, phased on the rectangle's own corner parity in the field's box, so a field at the screen's origin shares the dither's phase) and the icons re-read by the Finder's one rule: **a `selectable` icon is selected exactly when it is in the anchor XOR the rectangle touches it** — so a plain band selects what it touches and releases what it leaves, and a Shift band *toggles*: a selected icon it covers deselects while covered and comes back when it leaves, an unselected one selects. "Touches" is the icon's own reading (`vf-icon.touches(box)`: its art cell or its name plate, not the empty cell around narrower art or the frame's margin beside the plate, so a rectangle through the gap between two icons selects neither). Each change goes through `vf-icon.setSelected()` so it fires `vf-select` as a press would; the release keeps the result, a cancel restores the anchor. **The reach** is the field's own box intersected with every ancestor that clips (`overflow` other than `visible` on either axis — a window body, a scroll viewport, the desktop's raster), found by walking the flat tree through assigned slots and shadow hosts, rounded inward to whole system px; so the band never paints outside the region the icons are visible in. **Where it draws:** a canvas in the field's own shadow, `position: fixed` against the viewport (a layout-neutral field has no positioned box to anchor to; the field's box sits on the device grid, so whole system px from it land on the grid too), carrying the drag outline's XOR pen inline (`filter: invert(1)`, `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`). That is the field's own stacking level, which is where the Finder drew its marquee: on the desktop, under the windows (the field precedes them in flow and takes no z-index) and over the dither and icons; in a window body, inside that window's stacking context — over its content, under any window above it — not the desktop's drag surface, which would put a window's marquee over every other window. Escape (a gesture-scoped document capture listener, stopped so a dialog never dismisses) and `pointercancel` cancel: the canvas goes and the selection is put back to the kept set; a release keeps what the band made. `touch-action` is deliberately left alone: a touch drag on a scrolling window's background pans it as before, and the `pointercancel` the browser then sends cancels the band. A field with no box (unplaced, unfilled) receives no press and draws none. `npm run verify:icon-drag` (BAND).
500
+ - **The desktop uses it and renders none.** Desktop icons sit in a field slotted into `vf-desktop` beside the windows, written by the page; a folder window's icons in one slotted into the window body. `vf-desktop` builds no field of its own: its furniture is slotted light DOM and the page decides what it is (the `clearActive` rule), a desktop with no icons should carry no empty listbox, and a field inside the desktop's shadow is one `closest()` could not reach.
501
+ - **Slots:** default (the icons). `npm run verify:icon-drag` (FIELD); `verify:position`'s UNIVERSAL group counts it as the 32nd element.
486
502
 
487
503
  ## 6. Exports
488
504