vintage-frames 0.2.1 → 0.3.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.
@@ -9,15 +9,16 @@ import { vfFocusUnderline as s } from "../styles/recipes/focus.js";
9
9
  import { GridSnapController as c } from "../grid-snap.js";
10
10
  import { DocumentListenersController as l, releaseAfterGesture as u } from "../document-listeners.js";
11
11
  import { deferActivation as d, emit as f } from "../events.js";
12
- import { FocusRuleController as p } from "../focus-modality.js";
13
- import { TypeAheadBuffer as m } from "../type-ahead.js";
14
- import { MenuPressController as h } from "../menu-press.js";
15
- import { LitElement as g, css as _, html as v, nothing as y } from "lit";
16
- import { property as b, query as x, queryAssignedElements as S } from "lit/decorators.js";
12
+ import { runSelectionBlink as p } from "../motion.js";
13
+ import { FocusRuleController as m } from "../focus-modality.js";
14
+ import { TypeAheadBuffer as h } from "../type-ahead.js";
15
+ import { MenuPressController as g } from "../menu-press.js";
16
+ import { LitElement as _, css as v, html as y, nothing as b } from "lit";
17
+ import { property as x, query as S, queryAssignedElements as C, state as w } from "lit/decorators.js";
17
18
  //#region src/components/vf-menu.ts
18
- var C = class extends r(g) {
19
+ var T = class extends r(_) {
19
20
  constructor(...e) {
20
- super(...e), this.scale = new t(this), this.gridSnap = new c(this), this.#e = new p(this), this.label = "", this.open = !1, this.barTabIndex = 0, this.#t = new h(this, {
21
+ super(...e), this.scale = new t(this), this.gridSnap = new c(this), this.#e = new m(this), this.label = "", this.open = !1, this.shortcuts = !1, this.barTabIndex = 0, this.#t = new g(this, {
21
22
  menus: () => [this],
22
23
  open: (e) => {
23
24
  e.open = !0;
@@ -25,11 +26,17 @@ var C = class extends r(g) {
25
26
  close: () => {
26
27
  this.open = !1;
27
28
  }
28
- }), this.#r = this.attachInternals(), this.#i = new m(), this.#a = !1, this.#o = () => {
29
+ }), this.#r = this.attachInternals(), this.#i = new h(), this.#a = !1, this.#o = () => {
29
30
  this.#n || this.focus(), this.open = !1;
30
- }, this.#s = (e) => {
31
+ }, this._flashOn = !1, this.#c = () => {
32
+ this.open || (this.#l(), this.#s = p((e) => {
33
+ this._flashOn = e;
34
+ }, () => {
35
+ this._flashOn = !1, this.#s = void 0;
36
+ }));
37
+ }, this.#u = (e) => {
31
38
  e.composedPath().includes(this) || (this.open = !1);
32
- }, this.#c = (e) => {
39
+ }, this.#d = (e) => {
33
40
  if (!e.defaultPrevented) switch (e.key) {
34
41
  case "Escape":
35
42
  e.preventDefault(), this.open = !1, this.focus();
@@ -39,7 +46,7 @@ var C = class extends r(g) {
39
46
  break;
40
47
  case "ArrowDown":
41
48
  case "ArrowUp":
42
- e.preventDefault(), this.#l(e.key === "ArrowDown" ? 1 : -1);
49
+ e.preventDefault(), this.#f(e.key === "ArrowDown" ? 1 : -1);
43
50
  break;
44
51
  case "Home":
45
52
  case "End": {
@@ -59,21 +66,21 @@ var C = class extends r(g) {
59
66
  break;
60
67
  }
61
68
  }
62
- }, this.#u = new l(this, () => [[
69
+ }, this.#p = new l(this, () => [[
63
70
  document,
64
71
  "pointerdown",
65
- this.#s,
72
+ this.#u,
66
73
  !0
67
74
  ], [
68
75
  document,
69
76
  "keydown",
70
- this.#c,
77
+ this.#d,
71
78
  !0
72
- ]]), this.#f = (e) => {
79
+ ]]), this.#h = (e) => {
73
80
  if (!this.open || this.#n) return;
74
81
  let t = e.relatedTarget;
75
82
  t instanceof Node && (this.contains(t) || this.renderRoot.contains(t)) || (this.open = !1);
76
- }, this.#p = (e) => {
83
+ }, this.#g = (e) => {
77
84
  this.#e.suppress(), !this.#n && this.#t.onPointerDown(e);
78
85
  };
79
86
  }
@@ -82,7 +89,7 @@ var C = class extends r(g) {
82
89
  a,
83
90
  i,
84
91
  o,
85
- _`
92
+ v`
86
93
  :host {
87
94
  display: inline-block;
88
95
  position: relative;
@@ -105,7 +112,12 @@ var C = class extends r(g) {
105
112
  touch-action: none;
106
113
  cursor: var(--vf-cursor, default);
107
114
  }
108
- :host([open]) .label {
115
+ /* .flash-on: the key-equivalent acknowledgment — a closed menu whose
116
+ item was activated by its shortcut flashes the title on the item
117
+ blink's own cadence, the way MenuKey() flashed the bar. Shares the
118
+ open inversion so the two can't drift apart. */
119
+ :host([open]) .label,
120
+ .label.flash-on {
109
121
  /* Forced colors: exempt the inverted title from the mode's text
110
122
  backplate, which would land a Canvas slab on the highlight bar —
111
123
  see vf-list-item's forced-colors note. The pair is already the
@@ -240,30 +252,35 @@ var C = class extends r(g) {
240
252
  #o;
241
253
  #s;
242
254
  #c;
243
- #l(e) {
255
+ #l() {
256
+ this.#s?.cancel(), this.#s = void 0, this._flashOn = !1;
257
+ }
258
+ #u;
259
+ #d;
260
+ #f(e) {
244
261
  let t = this.items;
245
262
  if (t.length === 0) return;
246
263
  let n = t.indexOf(document.activeElement), r;
247
264
  r = n < 0 ? e === 1 ? 0 : t.length - 1 : (n + e + t.length) % t.length, t[r]?.focus();
248
265
  }
249
- #u;
266
+ #p;
250
267
  connectedCallback() {
251
- super.connectedCallback(), this.#d(), this.requestUpdate(), this.addEventListener("vf-menu-close-request", this.#o), this.addEventListener("pointerdown", this.#p), this.addEventListener("focusout", this.#f);
268
+ super.connectedCallback(), this.#m(), this.requestUpdate(), this.addEventListener("vf-menu-close-request", this.#o), this.addEventListener("vf-menu-flash-request", this.#c), this.addEventListener("pointerdown", this.#g), this.addEventListener("focusout", this.#h);
252
269
  }
253
- #d() {
270
+ #m() {
254
271
  this.#r.role = this.#n ? "none" : null;
255
272
  }
256
273
  disconnectedCallback() {
257
- super.disconnectedCallback(), this.removeEventListener("vf-menu-close-request", this.#o), this.removeEventListener("pointerdown", this.#p), this.removeEventListener("focusout", this.#f);
274
+ super.disconnectedCallback(), this.removeEventListener("vf-menu-close-request", this.#o), this.removeEventListener("vf-menu-flash-request", this.#c), this.removeEventListener("pointerdown", this.#g), this.removeEventListener("focusout", this.#h), this.#l();
258
275
  }
259
- #f;
276
+ #h;
260
277
  updated(e) {
261
278
  if (e.has("open")) {
262
- if (this.open && !this.#n ? this.#u.attach() : this.#u.detach(), !this.open) for (let e of this.allItems) e.active = !1;
279
+ if (this.open && this.#l(), this.open && !this.#n ? this.#p.attach() : this.#p.detach(), !this.open) for (let e of this.allItems) e.active = !1;
263
280
  this.open || this.#i.reset();
264
281
  }
265
282
  }
266
- #p;
283
+ #g;
267
284
  focus(e) {
268
285
  this._labelEl?.focus(e);
269
286
  }
@@ -271,19 +288,19 @@ var C = class extends r(g) {
271
288
  this.focus();
272
289
  }
273
290
  render() {
274
- return v`
291
+ return y`
275
292
  <div
276
- class="label vf-snap ${this.#e.marked ? "vf-focus-rule" : ""}"
293
+ class="label vf-snap ${this.#e.marked ? "vf-focus-rule" : ""} ${this._flashOn ? "flash-on" : ""}"
277
294
  part="label"
278
295
  role=${this.#n ? "menuitem" : "button"}
279
296
  tabindex=${this.barTabIndex}
280
297
  aria-haspopup="menu"
281
298
  aria-expanded=${this.open ? "true" : "false"}
282
- aria-label=${this.label || y}
283
- @pointerdown=${this.#h}
284
- @click=${this.#g}
285
- @pointerenter=${this.#_}
286
- @keydown=${this.#v}
299
+ aria-label=${this.label || b}
300
+ @pointerdown=${this.#v}
301
+ @click=${this.#y}
302
+ @pointerenter=${this.#b}
303
+ @keydown=${this.#x}
287
304
  >
288
305
  <span class="title"><slot name="label">${this.label}</slot></span>
289
306
  </div>
@@ -291,13 +308,13 @@ var C = class extends r(g) {
291
308
  class="panel vf-panel"
292
309
  part="panel"
293
310
  role="menu"
294
- aria-label=${this.label || y}
311
+ aria-label=${this.label || b}
295
312
  >
296
313
  <slot></slot>
297
314
  </div>
298
315
  `;
299
316
  }
300
- #m() {
317
+ #_() {
301
318
  if (!this.#n) {
302
319
  this.open = !this.open;
303
320
  return;
@@ -307,41 +324,44 @@ var C = class extends r(g) {
307
324
  composed: !1
308
325
  }) && (this.open = !this.open);
309
326
  }
310
- #h() {
327
+ #v() {
311
328
  this.#a = !0, u(() => {
312
329
  this.#a = !1;
313
330
  });
314
331
  }
315
- #g(e) {
332
+ #y(e) {
316
333
  if (this.#a) {
317
334
  this.#a = !1;
318
335
  return;
319
336
  }
320
- d(this, e, () => this.#m());
337
+ d(this, e, () => this.#_());
321
338
  }
322
- #_() {
339
+ #b() {
323
340
  f(this, "vf-menu-hover", { menu: this }, { composed: !1 });
324
341
  }
325
- #v(e) {
342
+ #x(e) {
326
343
  if (e.key === "Enter" || e.key === " ") {
327
344
  e.preventDefault();
328
345
  let t = this.open;
329
- this.#m(), !t && this.open && this.#y();
330
- } else e.key === "ArrowDown" && !this.open && (e.preventDefault(), this.#m(), this.open && this.#y());
346
+ this.#_(), !t && this.open && this.#S();
347
+ } else e.key === "ArrowDown" && !this.open && (e.preventDefault(), this.#_(), this.open && this.#S());
331
348
  }
332
- async #y() {
349
+ async #S() {
333
350
  await this.updateComplete, this.items[0]?.focus();
334
351
  }
335
352
  };
336
- n([b()], C.prototype, "label", void 0), n([b({
353
+ n([x()], T.prototype, "label", void 0), n([x({
354
+ type: Boolean,
355
+ reflect: !0
356
+ })], T.prototype, "open", void 0), n([x({
337
357
  type: Boolean,
338
358
  reflect: !0
339
- })], C.prototype, "open", void 0), n([b({
359
+ })], T.prototype, "shortcuts", void 0), n([x({
340
360
  type: Number,
341
361
  attribute: !1
342
- })], C.prototype, "barTabIndex", void 0), n([x(".label")], C.prototype, "_labelEl", void 0), n([S({
362
+ })], T.prototype, "barTabIndex", void 0), n([S(".label")], T.prototype, "_labelEl", void 0), n([C({
343
363
  selector: "vf-menu-item",
344
364
  flatten: !0
345
- })], C.prototype, "_assignedItems", void 0), C = n([e("vf-menu")], C);
365
+ })], T.prototype, "_assignedItems", void 0), n([w()], T.prototype, "_flashOn", void 0), T = n([e("vf-menu")], T);
346
366
  //#endregion
347
- export { C as VfMenu };
367
+ export { T as VfMenu };
@@ -30,12 +30,18 @@ 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 status - Optional status-bar content — the classic bottom readout
34
+ * strip ("40px x 40px"): a 1px rule over a 15px white band under the body,
35
+ * body-face text on its native line. Takes no space until populated; a
36
+ * `resizable` window's grow box sits flush in its right end.
33
37
  * @csspart frame - The outer chrome frame.
34
38
  * @csspart title-bar - The striped (or dithered) title bar.
35
39
  * @csspart title - The centered title patch (hidden on the utility bar).
36
40
  * @csspart close-box - The close widget (left).
37
41
  * @csspart zoom-box - The zoom widget (right).
38
42
  * @csspart body - The content area.
43
+ * @csspart status-bar - The bottom status strip (when the `status` slot is
44
+ * populated).
39
45
  * @csspart grow-box - The resize widget (bottom-right, when `resizable`).
40
46
  * @csspart viewport - The built-in scroll area's viewport (when `scrollbars`;
41
47
  * re-exported from vf-scroll-area).
@@ -43,6 +49,13 @@ declare const VfWindow_base: (new (...args: any[]) => import("../size.js").VfSiz
43
49
  * compatible with vf-dialog's `vf-close`). The window does NOT
44
50
  * remove itself; the consumer decides what closing means.
45
51
  * @fires vf-zoom - Zoom box clicked. Detail `{}`.
52
+ * @fires vf-resize - The grow box resized the window. Detail `{ width,
53
+ * height, commit }`, sizes in whole system px: one event per size the drag
54
+ * writes (`commit: false`), fired after the new box is applied so a handler
55
+ * that measures reads the resized layout, then a final `commit: true` as
56
+ * the gesture settles — only when it changed the size. Fired by the gesture
57
+ * alone: a programmatic `width`/`height` write fires nothing, the way a
58
+ * value set fires no `vf-change`.
46
59
  * @cssprop --vf-dots-pattern - the windoid bar's dot-grid dither — a 2×2 motif,
47
60
  * one black pixel at its origin, on a 30-system-px tile (`vfDots`; override
48
61
  * the whole tile like `--vf-desktop-pattern` — consumer art renders as a
@@ -51,6 +64,10 @@ declare const VfWindow_base: (new (...args: any[]) => import("../size.js").VfSiz
51
64
  * @cssprop [--vf-titlebar-height-utility=12px] - the slim
52
65
  * `vf-window[variant="utility"]` (windoid) bar — 11px interior + 1px bottom
53
66
  * rule, traced from `Windows/utility-window.png`
67
+ * @cssprop [--vf-status-bar-height=15px] - the status strip: 1px rule + 14px
68
+ * interior — the grow box's own height, so the two compose flush
69
+ * @cssprop [--vf-line-height=12px] - the body face's native line, which the
70
+ * status strip's text rides (whole-pixel centered in the 14px interior)
54
71
  */
55
72
  export declare class VfWindow extends VfWindow_base {
56
73
  #private;
@@ -90,6 +107,8 @@ export declare class VfWindow extends VfWindow_base {
90
107
  * vf-scroll-area) still works for windows that want an inset well instead.
91
108
  */
92
109
  scrollbars?: 'vertical' | 'horizontal' | 'both';
110
+ /** Whether the `status` slot has assigned content (drives the strip). */
111
+ private _hasStatus;
93
112
  /** Default-on display scaling (true 72dpi size); see src/scale.ts. */
94
113
  private readonly scale;
95
114
  /** Device-pixel grid snapping; see src/grid-snap.ts. */
@@ -134,9 +153,16 @@ export declare class VfWindow extends VfWindow_base {
134
153
  * are the same declaration and neither can be re-asserted over the other.
135
154
  * Controllers run before this hook, so the inline style the warning reads is
136
155
  * already written.
156
+ *
157
+ * The stream half of `vf-resize` also fires here, not from the pointermove
158
+ * that wrote the size: by this hook the controller has applied the box, so
159
+ * a handler that measures reads the resized layout — the contract that lets
160
+ * window content follow the grow box without a ResizeObserver.
137
161
  */
138
162
  protected updated(): void;
139
163
  disconnectedCallback(): void;
164
+ /** The `.empty` gate: the strip renders only while the slot is populated. */
165
+ private _onStatusSlotChange;
140
166
  private _onCloseClick;
141
167
  private _onZoomClick;
142
168
  private _onGrowPointerDown;
@@ -15,15 +15,15 @@ import { TitleCenterController as T, chromeTitleBar as E, closeBox as D, widgetL
15
15
  import { emit as A } from "../events.js";
16
16
  import "./vf-scroll-area.js";
17
17
  import { LitElement as j, css as M, html as N, nothing as P } from "lit";
18
- import { property as F } from "lit/decorators.js";
18
+ import { property as F, state as I } from "lit/decorators.js";
19
19
  //#region src/components/vf-window.ts
20
- var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
20
+ var L = 80, R = 54, z = 24, B = 8, V = class extends C(s(j)) {
21
21
  constructor(...e) {
22
- super(...e), this.heading = "", this.active = !0, this.closable = !0, this.zoomable = !1, this.movable = !1, this.resizable = !1, this.flush = !1, this.scale = new t(this), this.gridSnap = new S(this), this.titleCenter = new T(this), this._dotsPattern = "", this.#e = new v(), this.#t = (e, t, n) => {
22
+ super(...e), this.heading = "", this.active = !0, this.closable = !0, this.zoomable = !1, this.movable = !1, this.resizable = !1, this.flush = !1, this._hasStatus = !1, this.scale = new t(this), this.gridSnap = new S(this), this.titleCenter = new T(this), this._dotsPattern = "", this.#e = new v(), this.#t = (e, t, n) => {
23
23
  let r = i(this.offsetWidth, this);
24
24
  return {
25
- x: Math.min(Math.max(e, R - r), n.width - R),
26
- y: Math.min(Math.max(t, 0), Math.max(0, n.height - R))
25
+ x: Math.min(Math.max(e, z - r), n.width - z),
26
+ y: Math.min(Math.max(t, 0), Math.max(0, n.height - z))
27
27
  };
28
28
  }, this._placement = new o(this, (e, t, n) => this.#t(e, t, n)), this._drag = new w(this, {
29
29
  onDragStart: (e) => !this.movable || e.button !== 0 || e.composedPath().some((e) => e instanceof HTMLElement && e.classList.contains("box")) ? null : (this.#i(), this._placement.seed()),
@@ -206,6 +206,36 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
206
206
  margin: calc(var(--vf-scale, 1) * -1px);
207
207
  }
208
208
 
209
+ /* --- Status bar (slot="status") ---------------------------------- */
210
+ /* The classic bottom readout strip: a 1px rule over a white interior,
211
+ 15px in all — the grow box's own height, so a resizable window's
212
+ grow box sits flush in the strip's right end the way it sits in the
213
+ scroll rails' corner cell (its top and left borders take over the
214
+ rule there). Body-face text rides its native 12px line, whole-pixel
215
+ centered in the 14px interior. Under the scrollbars parameter, the
216
+ edge rails' bottom frame line lands exactly on the strip's rule (the
217
+ 1px overhang), so the two never double up. Takes no space until the
218
+ slot is populated. */
219
+ .status {
220
+ flex: none;
221
+ display: flex;
222
+ align-items: center;
223
+ height: calc(var(--vf-scale, 1) * var(--vf-status-bar-height, 15px));
224
+ border-top: calc(var(--vf-scale, 1) * 1px) solid var(--vf-black, #000000);
225
+ background: var(--vf-white, #ffffff);
226
+ padding-inline: calc(var(--vf-scale, 1) * 6px);
227
+ line-height: calc(var(--vf-scale, 1) * var(--vf-line-height, 12px));
228
+ white-space: nowrap;
229
+ overflow: hidden;
230
+ }
231
+ /* Clear the grow box: its 15px cell plus the strip's own 6px inset. */
232
+ :host([resizable]) .status {
233
+ padding-inline-end: calc(var(--vf-scale, 1) * 21px);
234
+ }
235
+ .status.empty {
236
+ display: none;
237
+ }
238
+
209
239
  /* --- Grow box --------------------------------------------------- */
210
240
  .grow {
211
241
  position: absolute;
@@ -261,7 +291,7 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
261
291
  rows: 1,
262
292
  tile: d
263
293
  });
264
- let t = Math.ceil((e + 2) / d) * d, n = z + 2;
294
+ let t = Math.ceil((e + 2) / d) * d, n = B + 2;
265
295
  return N`<div
266
296
  class="vf-tile-raster"
267
297
  style="width:${r(t)};height:${r(n)};background-image:${this.#e.for(2, 2, u, t, n)}"
@@ -270,6 +300,12 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
270
300
  #t;
271
301
  updated() {
272
302
  this.#a();
303
+ let e = this._resizeState;
304
+ e?.emitPending && (e.emitPending = !1, A(this, "vf-resize", {
305
+ width: this.width,
306
+ height: this.height,
307
+ commit: !1
308
+ }));
273
309
  }
274
310
  #n;
275
311
  #r;
@@ -284,6 +320,10 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
284
320
  disconnectedCallback() {
285
321
  super.disconnectedCallback(), this._resizeState = null;
286
322
  }
323
+ _onStatusSlotChange(e) {
324
+ let t = e.target;
325
+ this._hasStatus = t.assignedElements().length > 0;
326
+ }
287
327
  _onCloseClick() {
288
328
  A(this, "vf-close", { reason: "close" });
289
329
  }
@@ -298,21 +338,27 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
298
338
  startX: e.clientX,
299
339
  startY: e.clientY,
300
340
  baseWidth: i(t.width, this),
301
- baseHeight: i(t.height, this)
341
+ baseHeight: i(t.height, this),
342
+ emitPending: !1,
343
+ resized: !1
302
344
  }, e.currentTarget.setPointerCapture(e.pointerId), e.preventDefault();
303
345
  }
304
346
  _onGrowPointerMove(e) {
305
347
  let t = this._resizeState;
306
348
  if (!t || e.pointerId !== t.pointerId) return;
307
- let r = Math.max(I, t.baseWidth + i(e.clientX - t.startX, this)), a = Math.max(L, t.baseHeight + i(e.clientY - t.startY, this));
308
- this.width = n(r, this), this.height = n(a, this);
349
+ let r = n(Math.max(L, t.baseWidth + i(e.clientX - t.startX, this)), this), a = n(Math.max(R, t.baseHeight + i(e.clientY - t.startY, this)), this);
350
+ (r !== this.width || a !== this.height) && (t.emitPending = !0, t.resized = !0), this.width = r, this.height = a;
309
351
  }
310
352
  _onGrowPointerEnd(e) {
311
353
  let t = this._resizeState;
312
354
  if (!t || e.pointerId !== t.pointerId) return;
313
355
  this._resizeState = null;
314
356
  let n = e.currentTarget;
315
- n.hasPointerCapture(e.pointerId) && n.releasePointerCapture(e.pointerId);
357
+ n.hasPointerCapture(e.pointerId) && n.releasePointerCapture(e.pointerId), t.resized && A(this, "vf-resize", {
358
+ width: this.width,
359
+ height: this.height,
360
+ commit: !0
361
+ });
316
362
  }
317
363
  render() {
318
364
  return N`
@@ -339,6 +385,9 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
339
385
  </vf-scroll-area>
340
386
  ` : N`<slot></slot>`}
341
387
  </div>
388
+ <div class="status ${this._hasStatus ? "" : "empty"}" part="status-bar">
389
+ <slot name="status" @slotchange=${this._onStatusSlotChange}></slot>
390
+ </div>
342
391
  ${this.resizable ? N`
343
392
  <div
344
393
  class="grow"
@@ -354,24 +403,24 @@ var I = 80, L = 54, R = 24, z = 8, B = class extends C(s(j)) {
354
403
  `;
355
404
  }
356
405
  };
357
- a([F({ reflect: !0 })], B.prototype, "variant", void 0), a([F()], B.prototype, "heading", void 0), a([F({
406
+ a([F({ reflect: !0 })], V.prototype, "variant", void 0), a([F()], V.prototype, "heading", void 0), a([F({
358
407
  type: Boolean,
359
408
  reflect: !0
360
- })], B.prototype, "active", void 0), a([F({
409
+ })], V.prototype, "active", void 0), a([F({
361
410
  type: Boolean,
362
411
  reflect: !0
363
- })], B.prototype, "closable", void 0), a([F({
412
+ })], V.prototype, "closable", void 0), a([F({
364
413
  type: Boolean,
365
414
  reflect: !0
366
- })], B.prototype, "zoomable", void 0), a([F({
415
+ })], V.prototype, "zoomable", void 0), a([F({
367
416
  type: Boolean,
368
417
  reflect: !0
369
- })], B.prototype, "movable", void 0), a([F({
418
+ })], V.prototype, "movable", void 0), a([F({
370
419
  type: Boolean,
371
420
  reflect: !0
372
- })], B.prototype, "resizable", void 0), a([F({
421
+ })], V.prototype, "resizable", void 0), a([F({
373
422
  type: Boolean,
374
423
  reflect: !0
375
- })], B.prototype, "flush", void 0), a([F({ reflect: !0 })], B.prototype, "scrollbars", void 0), B = a([e("vf-window")], B);
424
+ })], V.prototype, "flush", void 0), a([F({ reflect: !0 })], V.prototype, "scrollbars", void 0), a([I()], V.prototype, "_hasStatus", void 0), V = a([e("vf-window")], V);
376
425
  //#endregion
377
- export { B as VfWindow };
426
+ export { V as VfWindow };
package/docs/SPEC.md CHANGED
@@ -98,6 +98,7 @@ Every length in this doc is a **system pixel** value; components multiply it by
98
98
  | `--vf-list-max-height` | `200px` | `vf-list` max height before its rail takes over (the host adds the 2px frame) |
99
99
  | `--vf-titlebar-height` | `18px` | window/dialog title bars |
100
100
  | `--vf-titlebar-height-utility` | `12px` | the slim `vf-window[variant="utility"]` (windoid) bar — 11px interior + 1px bottom rule, traced from `Windows/utility-window.png` |
101
+ | `--vf-status-bar-height` | `15px` | `vf-window`'s bottom status strip (`status` slot) — 1px rule + 14px interior, the grow box's own height so the two compose flush |
101
102
  | `--vf-dots-pattern` | *(1-bit tile)* | the windoid bar's dot-grid dither — a 2×2 motif, one black pixel at its origin, on a 30-system-px tile (`vfDots`; override the whole tile like `--vf-desktop-pattern`) |
102
103
  | `--vf-swatch-checker` | *(tile)* | `vf-swatch`'s no-color transparency checker — a 4×4 motif of 2×2 white/`#c0c0c0` checks, on a 60-system-px tile (override the whole tile like `--vf-desktop-pattern`) |
103
104
  | `--vf-menubar-height` | `20px` | `vf-menu-bar` |
@@ -209,13 +210,14 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
209
210
  - 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.
210
211
  - Body: `padding: 12px` (0 if `flush` or `scrollbars`), and `overflow: hidden` — the window being a fixed box, content taller than it is clipped at the frame the way the classic content region was, rather than painting out over the desktop; `scrollbars` is how the user reaches the rest. Two deliberate exemptions: `[scrollbars]` restores `overflow: visible`, because the edge-rail composition below pulls the scroll area one system px *outside* the body on every side and clipping would shave exactly that overhang off (the scroll area does its own clipping); and a control's drop-open panel is not clipped — `vf-select`'s list is `position: fixed` off the control's rect precisely to escape clipping ancestors (§5 vf-select), and it still escapes, because nothing between it and the viewport establishes a containing block for fixed descendants (the grid-snap correction is a `position: relative` left/top offset, never a transform — see §7). A `vf-menu` panel is anchored `position: absolute` and *would* clip, but a menu bar belongs to the desktop, not inside a window body.
211
212
  - 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.
213
+ - Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule over a white interior, `--vf-status-bar-height` (15px) in all, the grow box's own height, so a `resizable` window's grow box sits flush in the strip's right end (its top/left borders take over the rule there; the strip reserves 21px of right padding under `resizable` so text clears the cell). Slotted content rides the body face's native 12px line (`--vf-line-height`), whole-pixel centered in the 14px interior, `padding-inline: 6px`, nowrap + clipped. Takes **no space** until the slot is populated (the dialog-footer pattern), and collapses again when it empties. Composes with `scrollbars`: the edge rails' bottom overhang lands its frame line exactly on the strip's rule, so the two never double up — though the grow box then sits in the strip rather than the rail corner cell.
212
214
  - Edge scroll rails (if `scrollbars`): the body slot renders inside a shadow `vf-scroll-area` (its `axis` = the attribute's value, `label` = the heading, `viewport` part re-exported) carrying the TeachText composition internally — `calc(100% + 2px·scale)` with `margin: -1px·scale`, one system px under the frame on every side, so the area's own frame border repaints the window's border lines and a `resizable` window's grow box (z-index 1) lands exactly over the rail-corner cell. Same caveats as the slotted composition (see vf-scroll-area §5), which remains supported for inset wells.
213
215
  - 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.
214
216
  - **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`.
215
- - **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.
216
- - **Slots:** default (body content).
217
- - **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `body`, `grow-box`, plus `viewport` re-exported from the built-in scroll area when `scrollbars` is set.
218
- - **Events:** `vf-close`, `vf-zoom` (detail `{}`).
217
+ - **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, and 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`.
218
+ - **Slots:** default (body content), `status` (the bottom status strip — see Visual).
219
+ - **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.
220
+ - **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
219
221
 
220
222
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
221
223
  The modal-dialog shell: movable modal by default (see "Format" screenshot, striped title bar over a white body), the dBoxProc modal dialog box with `frame="plain"` (see the Group A recipe table).
@@ -347,13 +349,13 @@ The classic popup menu control ("Macintosh HD ▼").
347
349
  ### Group D — menus, lists, containers
348
350
 
349
351
  #### `vf-menu-bar` (`VfMenuBar`, vf-menu-bar.ts)
350
- - **Attributes/props:** `label: string` — accessible name for the menubar, mirrored as host `aria-label` (guarded: a consumer's own `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected) — draws the System 7 screen-corner mask over the bar's top-left/top-right corners.
352
+ - **Attributes/props:** `label: string` — accessible name for the menubar, mirrored as host `aria-label` (guarded: a consumer's own `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected) — draws the System 7 screen-corner mask over the bar's top-left/top-right corners. `shortcuts: boolean` (reflected) — makes every slotted menu's item `shortcut`s live key equivalents, one declaration for the whole bar the way MenuKey() answered for every menu at once (see vf-menu-item for the claim contract). Off by default: key equivalents are page-global, and only *the* menu bar should own them.
351
353
  - **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` — 19 white system px over the 1px black rule, the Menus.png bar strip exactly — white bg, `border-bottom: 1px solid var(--vf-black, #000)`, children laid out horizontally from left. Adjacent titles are pulled 5px into each other (`margin-inline-start: -5px` on every slotted menu, absorbed by 14px of bar start padding — 9px of bar before the first plate, as System 7 placed the Apple title, plus the first title's 5px share): Menus.png spaces title ink 14px apart while each title's plate runs 10px left / 9px right of its own ink, so neighboring plates *overlap* by 5px, as the originals did. With `rounded`, two 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in `--vf-black` *over* the bar: on the classic screen the rounding was the ROM's black corner mask sitting on top of the menu bar, not a shape of the bar's own, so it is ink over any backdrop rather than a cutout.
352
354
  - **Behavior:** container/controller for slotted `vf-menu` children. Pressing a menu label → opens it (label inverts while open). While any menu is open, hovering another label switches to it (classic behavior). Escape / outside click / item selection closes. `role="menubar"`, behind a first-connect ownership latch so a consumer's own role survives upgrade; the shadow `.bar` is `role="presentation"` and each slotted `vf-menu` host `role="none"`, so the `menubar → menuitem` ownership chain has no generics in it. While a menu is open: ArrowLeft/Right move between menus, ArrowDown/Up walk the open menu's items, Home/End jump to its first/last enabled item, and printable keys run the shared Finder first-letter type-ahead over the items (`src/type-ahead.ts`; Space stays the focused item's activation key, and the prefix resets on menu switch or close). The bar also **owns the press-drag-release gesture** across its menus (`MenuPressController`, `src/menu-press.ts` — see `vf-menu`), since one press may travel over several of them: it binds the opening `pointerdown` and hands the controller its own open/close rules, so the gesture changes *when* a menu opens, never *how*.
353
355
  - **Slots:** default (vf-menu elements). **Parts:** `bar`.
354
356
 
355
357
  #### `vf-menu` (`VfMenu`, vf-menu.ts)
356
- - **Attributes/props:** `label: string` (the menu title in the bar; may contain e.g. an apple glyph), `open: boolean` (reflect, managed by menu-bar or self).
358
+ - **Attributes/props:** `label: string` (the menu title in the bar; may contain e.g. an apple glyph), `open: boolean` (reflect, managed by menu-bar or self), `shortcuts: boolean` (reflect — the per-menu key-equivalent grant, for a standalone menu; in a bar, declare it once on the bar instead. See vf-menu-item).
357
359
  - **Visual:** label: bold, height of menubar, `padding-inline: 9px 8px` — the label box is the title's black plate and hit rect, and Menus.png puts the plate 10px left / 9px right of the title ink (Chicago carries a 1px bearing on each side inside the text box, so 9/8 in layout lands −10/+9 around ink); open → inverted (black bg / white text), inset one system px top and bottom via transparent `border-block` + `background-clip: padding-box` — the hilite is rows 1..18 of the 20px bar, leaving the bar's top row white and its bottom rule showing through, while the hit rect stays the full bar height. The title itself sits in a `.title` box inside that cell, so the focus rule can span the title and not the padding. In a bar the label is `role="menuitem"`; standalone it is `role="button"` — a collapsed standalone dropdown *is* the APG menu-button pattern, and `aria-haspopup` + `aria-expanded` are already right for it (the host itself is `role="none"` in a bar and role-less standalone, behind vf-menu-item's first-connect ownership latch). Panel: `.vf-panel`, `position: absolute` below the label (`top: 100%; left: 0;`), `padding: 0`, `min-width: 100%` — a menu is as wide as its widest row, the way the MDEF sized it (`Menus.png`'s File pulldown is 141px, its cm/inches popup 94), never narrower than its own bar title; `role="menu"`.
358
360
  - **Keyboard focus: no ring** — `vfFocusUnderline` (§4) at `--vf-focus-underline-offset: -2px`, a dashed rule one blank system px row under the `.title` box. That box is `line-height: 1`, i.e. the face's own em, whose bottom edge is the descent line — so the one offset clears a descender *and* a slotted 16px `vf-img` (the Apple menu), where the button's baseline-anchored rule is crossed by both. In `currentColor`, so it inverts with the title on an open menu's black cell.
359
361
  - **Closed only** (`:host(:not([open]))`). A dropped menu inverts its whole cell, which already shows where focus is; the rule marks the state the inversion can't — focused but not yet open — and drawing both would put a second mark (in white, since it is `currentColor`) under the first. The class stays on through the open state, so the rule returns by itself when the menu closes and hands focus back.
@@ -364,9 +366,10 @@ The classic popup menu control ("Macintosh HD ▼").
364
366
  - **Slots:** default (vf-menu-item / vf-separator), `label` (replaces the `label` text in the bar — e.g. the Apple menu's `vf-img` apple; the `label` attribute stays set as the accessible name, mirrored to the bar item's `aria-label`). **Parts:** `label`, `panel`.
365
367
 
366
368
  #### `vf-menu-item` (`VfMenuItem`, vf-menu-item.ts)
367
- - **Attributes/props:** `disabled`, `checked` (shows ✓ in left gutter), `checkable` (declares a toggle up front — see Behavior), `shortcut: string` (e.g. `"⌘H"`, drawn in the left-aligned shortcut column), `value?: string` (defaults to text content), `active` (reflect; the transient press-drag highlight, managed by the menu — mirrors `vf-option[active]`, not an authoring API).
369
+ - **Attributes/props:** `disabled`, `checked` (shows ✓ in left gutter), `checkable` (declares a toggle up front — see Behavior), `shortcut: string` (e.g. `"⌘H"`, drawn in the left-aligned shortcut column; a **live key equivalent** under a `shortcuts` grant — see Behavior), `value?: string` (defaults to text content), `active` (reflect; the transient press-drag highlight, managed by the menu — mirrors `vf-option[active]`, not an authoring API).
368
370
  - **Visual:** height `var(--vf-menu-row-height, 16px)` — `Menus.png` puts every menu row on a 16px pitch (3px above + the 9px glyph + 4px below), so a pulldown row matches a popup row exactly; the line box is locked to the same expression so an inherited line-height can never overflow the panel. `padding: 0 12px 0 var(--vf-select-gutter, 16px)` (left gutter for ✓, shared with `vf-select`/`vf-option` — `Menus.png` puts a pulldown's label ink at the same inset as a popup's; a shortcut row trades the 12 for 1px, the slot being the clearance there). The shortcut is left-aligned in a right-anchored `--vf-menu-shortcut-column` (23px) slot — every ⌘ at the same x, 23px from the right border, the widest letters running to within ~3px of it (`Menus.png`'s File pulldown) — with an 8px label↔shortcut min gap. `color: var(--vf-disabled)` when disabled. Hover, `[active]`, keyboard focus (not disabled): full-width inversion — each with its own `.blink-off` override at matching specificity, so a drag-picked row keeps its flag through the blink and the release reads as the highlight flashing off. A disabled row dims its ✓ along with the label — a **documented deviation** from the §1 "dim the label, chrome glyphs stay black" rule, because authentic System 7 greyed the whole disabled row.
369
- - **Behavior:** `role="menuitem"` — or `role="menuitemcheckbox"` with `aria-checked` when the item is *checkable*: either `checkable` is set, or the item has ever been `checked`. Set `checkable` on a toggle that starts **off**, which would otherwise announce as a plain command until its first flip (a boolean `checked` attribute can't express "checkable but off"). The role is re-derived on every connect, so re-parenting a checkable item keeps it; an author-supplied `role` is left alone. On activation — a click, Enter/Space, or the public `activate()` the menu's press gesture calls for the row a drag was released over (which the row's own `click` never sees, since a press that started on the title dispatches its click above both): classic **blink** (invert toggles 3 times over ~250ms via timer; skipped under `prefers-reduced-motion`, selecting at once), then dispatch `vf-menu-select` detail `{ value, item }` and signal ancestors to close the menu. Disabling an item mid-blink cancels it and drops the pending `vf-menu-select`.
371
+ - **Behavior:** `role="menuitem"` — or `role="menuitemcheckbox"` with `aria-checked` when the item is *checkable*: either `checkable` is set, or the item has ever been `checked`. Set `checkable` on a toggle that starts **off**, which would otherwise announce as a plain command until its first flip (a boolean `checked` attribute can't express "checkable but off"). The role is re-derived on every connect, so re-parenting a checkable item keeps it; an author-supplied `role` is left alone. On activation — a click, Enter/Space, or the public `activate()` the menu's press gesture calls for the row a drag was released over (which the row's own `click` never sees, since a press that started on the title dispatches its click above both): classic **blink** (invert toggles 3 times over ~250ms via timer; skipped under `prefers-reduced-motion`, selecting at once), then dispatch `vf-menu-select` detail `{ value, item }` and signal ancestors to close the menu — the close signal is skipped when the ancestor menu is already closed (the key-equivalent path below), since the close paths return focus to the bar label and would yank it from wherever the user was typing. Disabling an item mid-blink cancels it and drops the pending `vf-menu-select`.
372
+ - **Key equivalents** (the MenuKey() half of `shortcut`): under an ancestor `vf-menu[shortcuts]` / `vf-menu-bar[shortcuts]` grant, a document-level keydown matching the shortcut activates the item from anywhere on the page, menu open or not — File → Save *shows* ⌘S and *answers* it, no page-side plumbing. Matching is exact on modifiers (⌘=Meta, ⌃=Control, ⌥=Alt, ⇧=Shift — the same mapping `aria-keyshortcuts` announces) with `event.key` compared case-insensitively, except that a declared ⌘ is met by Control too — Ctrl standing in for ⌘ off the Mac, vf-icon's own ⌘O reading — while a shortcut spelling ⌃ itself keeps meaning the Control key alone; the grant is checked per event, so toggling it needs no re-wiring. The claim contract: a `defaultPrevented` stroke is already someone's (a page handler that ran first keeps its key); a match claims with `preventDefault()` and no `stopPropagation`, so the first connected item wins a contested key and later page listeners still observe the claimed event; a **disabled** item claims nothing — the stroke falls through, so a grayed Undo leaves ⌘Z to the focused field's own native undo; auto-repeat strokes are claimed but activate once; a shortcut with no ⌘/⌃/⌥ and a single printable key renders and announces but never matches (a bare letter claimed globally would hijack typing — a named key like "F1" needs no modifier). Activation is the normal path — blink, `vf-menu-select` — and a **closed** ancestor menu answers by flashing its bar title on the same cadence (MenuKey's acknowledgment; skipped with the blink under `prefers-reduced-motion`): the panel never opens and focus never moves. `npm run verify:menu-keys`.
370
373
  - **Slots:** default (label). **Parts:** `item`, `check`, `label`, `shortcut`.
371
374
  - **Events:** `vf-menu-select` — menu-specific by design. A plain `vf-select` would collide with the `<vf-select>` popup on any delegated ancestor listener, since both bubble and compose while `<vf-select>` itself commits with `vf-change`. Parallels the existing `vf-menu-*` coordination names.
372
375
 
@@ -460,8 +463,8 @@ A raster image — the reference art's pixel icons — on the kit's grid, treati
460
463
 
461
464
  #### `vf-icon` (`VfIcon`, vf-icon.ts)
462
465
  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.
463
- - **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. `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).
464
- - **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 inverts into a photographic negative rather than the darkening System 7 gave it.
466
+ - **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).
467
+ - **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.
465
468
  - **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.
466
469
  - **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.
467
470
  - **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.