vintage-frames 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/custom-elements.json +177 -13
- package/dist/components/vf-container.d.ts +3 -1
- package/dist/components/vf-container.js +34 -16
- package/dist/components/vf-desktop.d.ts +114 -12
- package/dist/components/vf-desktop.js +119 -58
- package/dist/components/vf-icon-field.d.ts +1 -1
- package/dist/components/vf-icon.js +68 -61
- package/dist/components/vf-menu-bar.d.ts +5 -0
- package/dist/components/vf-menu-bar.js +67 -43
- package/dist/components/vf-window.d.ts +13 -0
- package/dist/components/vf-window.js +10 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +44 -43
- package/dist/pattern-fill.d.ts +11 -8
- package/dist/pattern-fill.js +2 -4
- package/dist/position.d.ts +25 -7
- package/dist/position.js +6 -3
- package/dist/shell/alert.d.ts +32 -0
- package/dist/shell/alert.js +44 -0
- package/dist/shell/catalog.d.ts +168 -0
- package/dist/shell/catalog.js +278 -0
- package/dist/shell/clock.d.ts +16 -0
- package/dist/shell/clock.js +36 -0
- package/dist/shell/filing.d.ts +39 -0
- package/dist/shell/filing.js +68 -0
- package/dist/shell/finder.d.ts +116 -0
- package/dist/shell/finder.js +564 -0
- package/dist/shell/geometry.d.ts +196 -0
- package/dist/shell/geometry.js +241 -0
- package/dist/shell/index.d.ts +29 -0
- package/dist/shell/index.js +11 -0
- package/dist/shell/shell.d.ts +150 -0
- package/dist/shell/shell.js +213 -0
- package/dist/shell/state.d.ts +88 -0
- package/dist/shell/state.js +103 -0
- package/dist/shell/storage.d.ts +14 -0
- package/dist/shell/storage.js +41 -0
- package/dist/shell/windows.d.ts +215 -0
- package/dist/shell/windows.js +343 -0
- package/dist/window-chrome.d.ts +43 -0
- package/dist/window-chrome.js +13 -0
- package/docs/SPEC.md +12 -11
- package/editor/vscode.html-custom-data.json +9 -4
- package/editor/web-types.json +45 -9
- package/package.json +10 -2
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { snapSys as e, systemPxQuantum as t } from "../scale.js";
|
|
2
|
+
import { VfWindow as n } from "../components/vf-window.js";
|
|
3
|
+
import "../index.js";
|
|
4
|
+
import { cascadeFrom as r, cascadedBox as i, frameOf as a, nearBox as o, pinOf as s, pinTo as c, windowOrigin as l } from "./geometry.js";
|
|
5
|
+
//#region src/shell/windows.ts
|
|
6
|
+
var u = (e, t) => e.set(t, (e.get(t) ?? 0) + 1), d = (e, t) => {
|
|
7
|
+
let n = e.get(t) ?? 1;
|
|
8
|
+
n > 1 ? e.set(t, n - 1) : e.delete(t);
|
|
9
|
+
}, f = (e, t, n) => Math.min(Math.max(e, t), n), p = (e) => ({
|
|
10
|
+
left: e.left ?? 0,
|
|
11
|
+
top: e.top ?? 0,
|
|
12
|
+
width: e.width ?? 0,
|
|
13
|
+
height: e.height ?? 0
|
|
14
|
+
});
|
|
15
|
+
function m() {
|
|
16
|
+
let e = document.activeElement;
|
|
17
|
+
for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
|
|
18
|
+
return e;
|
|
19
|
+
}
|
|
20
|
+
var h = (e) => {
|
|
21
|
+
let t = document.activeElement;
|
|
22
|
+
return !!t && (t === e || e.contains(t));
|
|
23
|
+
};
|
|
24
|
+
async function g(e, { when: t } = {}) {
|
|
25
|
+
let n = [...e.querySelectorAll("*")];
|
|
26
|
+
if (await Promise.all([e, ...n].map((e) => e.updateComplete)), !e.isConnected || t && !t()) return !1;
|
|
27
|
+
for (let t of n) if (!(t.closest("[hidden]") || !t.checkVisibility()) && !(t.tabIndex < 0 && !t.localName.startsWith("vf-")) && (t.focus({ preventScroll: !0 }), h(e))) return !0;
|
|
28
|
+
return !1;
|
|
29
|
+
}
|
|
30
|
+
function _(m, _) {
|
|
31
|
+
let { defaultApp: v, savedPin: ee = () => null } = _, y = [], b = (e, t, n) => {
|
|
32
|
+
e.addEventListener(t, n), y.push(() => e.removeEventListener(t, n));
|
|
33
|
+
}, x = () => ({
|
|
34
|
+
width: m.width,
|
|
35
|
+
height: m.height
|
|
36
|
+
}), S = () => m.windowArea, C = /* @__PURE__ */ new Map(), w = /* @__PURE__ */ new Map(), T = /* @__PURE__ */ new Map(), E = /* @__PURE__ */ new Map(), D = /* @__PURE__ */ new Map(), O = /* @__PURE__ */ new WeakMap(), k = {}, A = () => a(S().top, k), j = /* @__PURE__ */ new Set(), M = !1, N = () => {
|
|
37
|
+
if (!M) for (let e of [...j]) e();
|
|
38
|
+
}, P = /* @__PURE__ */ new Set(), F = () => {
|
|
39
|
+
for (let e of [...P]) e();
|
|
40
|
+
}, I = /* @__PURE__ */ new Set(), L = /* @__PURE__ */ new Set(), R = /* @__PURE__ */ new Set(), z = () => null, B = () => !1;
|
|
41
|
+
function V(n, r) {
|
|
42
|
+
let i = S(), a = t(n), o = (e) => Math.floor(e / a) * a, s = Math.ceil(i.top / a) * a, c = i.left + i.width, l = i.top + i.height, { width: u, height: d } = r;
|
|
43
|
+
n.resizable && (u != null && Number.isFinite(u) && (u = Math.min(u, o(i.width))), d != null && Number.isFinite(d) && (d = Math.min(d, o(Math.max(0, l - s)))));
|
|
44
|
+
let p = u ?? n.width ?? 0, m = d ?? n.height ?? 0;
|
|
45
|
+
return {
|
|
46
|
+
left: f(e(r.left ?? i.left, n), i.left, Math.max(i.left, o(c - p))),
|
|
47
|
+
top: f(e(r.top ?? s, n), s, Math.max(s, o(l - m))),
|
|
48
|
+
width: u,
|
|
49
|
+
height: d
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
let H = (t, n) => {
|
|
53
|
+
t.left = e(n.left, t), t.top = e(n.top, t), n.width != null && Number.isFinite(n.width) && (t.width = n.width), n.height != null && Number.isFinite(n.height) && (t.height = n.height), O.delete(t);
|
|
54
|
+
}, U = (e) => {
|
|
55
|
+
let t = C.get(e)?.place;
|
|
56
|
+
return t ? V(e, t(S())) : null;
|
|
57
|
+
}, W = (e, t) => {
|
|
58
|
+
let n = C.get(e)?.policy;
|
|
59
|
+
if (n) return n(t);
|
|
60
|
+
if (!e.resizable) return { size: {
|
|
61
|
+
width: t.width,
|
|
62
|
+
height: t.height
|
|
63
|
+
} };
|
|
64
|
+
let { minWidth: r, minHeight: i } = e.sizeLimits;
|
|
65
|
+
return { min: {
|
|
66
|
+
width: r,
|
|
67
|
+
height: i
|
|
68
|
+
} };
|
|
69
|
+
}, G = (e, t, n = W(e, p(e))) => V(e, c(t, x(), A(), n)), te = (e, t) => {
|
|
70
|
+
let n = p(e);
|
|
71
|
+
return [
|
|
72
|
+
"left",
|
|
73
|
+
"top",
|
|
74
|
+
"width",
|
|
75
|
+
"height"
|
|
76
|
+
].every((e) => {
|
|
77
|
+
let r = t[e];
|
|
78
|
+
return r == null || !Number.isFinite(r) || n[e] === r;
|
|
79
|
+
});
|
|
80
|
+
}, ne = (e) => {
|
|
81
|
+
let t = [...C].filter(([t, n]) => t !== e && !t.hidden && !n.palette).map(([e]) => p(e));
|
|
82
|
+
return r(l(S()), t).slot;
|
|
83
|
+
}, K = v, q = (e) => e instanceof n && C.get(e)?.app || v;
|
|
84
|
+
function J() {
|
|
85
|
+
let e = !1;
|
|
86
|
+
for (let [t, n] of C) {
|
|
87
|
+
if (!n.palette) continue;
|
|
88
|
+
let r = n.app === K && n.palette();
|
|
89
|
+
t.hidden !== !r && (t.hidden = !r, r && m.bringToFront(t), e = !0);
|
|
90
|
+
}
|
|
91
|
+
e && N();
|
|
92
|
+
}
|
|
93
|
+
let re = (e) => {
|
|
94
|
+
let t = document.activeElement?.closest("vf-window");
|
|
95
|
+
!(t instanceof n) || t === e || !C.has(t) || C.get(t).palette || (e ? Y(e) : B(null, null));
|
|
96
|
+
}, Y = (e) => void g(e, { when: () => m.activeWindow === e }), X = (e) => {
|
|
97
|
+
re(e);
|
|
98
|
+
for (let t of [...L]) t(e);
|
|
99
|
+
let t = q(e);
|
|
100
|
+
if (t !== K) {
|
|
101
|
+
K = t, J();
|
|
102
|
+
for (let e of [...R]) e(K);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
b(m, "vf-activate", (e) => X(e.detail.window)), b(m, "vf-close", (e) => {
|
|
106
|
+
let t = e.target;
|
|
107
|
+
t instanceof n && C.has(t) && $.requestClose(t);
|
|
108
|
+
});
|
|
109
|
+
let Z = 0, Q = (e) => {
|
|
110
|
+
!(e.target instanceof n) || !C.has(e.target) || Z || (Z = window.setTimeout(() => {
|
|
111
|
+
Z = 0, N();
|
|
112
|
+
}, 0));
|
|
113
|
+
};
|
|
114
|
+
m.addEventListener("vf-placement-change", Q), y.push(() => m.removeEventListener("vf-placement-change", Q)), b(m, "vf-resize", (e) => {
|
|
115
|
+
e.detail?.commit && N();
|
|
116
|
+
});
|
|
117
|
+
let ie = (e) => {
|
|
118
|
+
let t = C.get(e);
|
|
119
|
+
t && (C.delete(e), O.delete(e), t.palette || F(), N());
|
|
120
|
+
}, ae = (n, r, i, a) => {
|
|
121
|
+
let l = p(n), u = C.get(n);
|
|
122
|
+
if (u?.keep && o(l, u.keep(a))) {
|
|
123
|
+
H(n, V(n, u.keep(S())));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let d = O.get(n), f = !!d && n.resizable && (d.width !== l.width || d.height !== l.height), m = !d || d.left !== l.left || d.top !== l.top || f || !d ? s(l, r, A()) : d.pin, h = c(m, i, A(), W(n, l));
|
|
127
|
+
if (n.left = e(h.left, n), n.top = e(h.top, n), n.resizable) {
|
|
128
|
+
let r = t(n), i = S(), a = Math.ceil(i.top / r) * r, o = Math.floor(i.width / r) * r, s = Math.floor(Math.max(0, i.top + i.height - a) / r) * r;
|
|
129
|
+
n.width = Math.min(e(h.width, n), o), n.height = Math.min(e(h.height, n), s);
|
|
130
|
+
}
|
|
131
|
+
O.set(n, {
|
|
132
|
+
pin: m,
|
|
133
|
+
...p(n)
|
|
134
|
+
});
|
|
135
|
+
}, $ = {
|
|
136
|
+
get area() {
|
|
137
|
+
return S();
|
|
138
|
+
},
|
|
139
|
+
get front() {
|
|
140
|
+
return K;
|
|
141
|
+
},
|
|
142
|
+
adopt(e, t) {
|
|
143
|
+
let n = t.palette;
|
|
144
|
+
if (C.set(e, {
|
|
145
|
+
app: t.app,
|
|
146
|
+
item: t.item ?? null,
|
|
147
|
+
place: t.place ?? null,
|
|
148
|
+
policy: t.policy ?? null,
|
|
149
|
+
keep: t.keep ?? null,
|
|
150
|
+
palette: n ? n === !0 ? () => !0 : n : null,
|
|
151
|
+
close: t.close ?? null
|
|
152
|
+
}), t.pin) H(e, G(e, t.pin));
|
|
153
|
+
else {
|
|
154
|
+
let t = U(e);
|
|
155
|
+
t && H(e, t);
|
|
156
|
+
}
|
|
157
|
+
if (n) {
|
|
158
|
+
let t = C.get(e);
|
|
159
|
+
e.hidden = !(t.app === K && t.palette());
|
|
160
|
+
}
|
|
161
|
+
m.activeWindow === e && X(e), n || F(), N();
|
|
162
|
+
},
|
|
163
|
+
open(e) {
|
|
164
|
+
let t = e.item == null ? null : $.windowFor(e.item);
|
|
165
|
+
if (t) return m.bringToFront(t), Y(t), t;
|
|
166
|
+
let n = e.create(), r = {
|
|
167
|
+
width: n.width ?? 0,
|
|
168
|
+
height: n.height ?? 0
|
|
169
|
+
}, a = ne();
|
|
170
|
+
m.append(n);
|
|
171
|
+
let o = e.item ?? null;
|
|
172
|
+
return $.adopt(n, {
|
|
173
|
+
app: e.app,
|
|
174
|
+
item: o,
|
|
175
|
+
place: e.place ?? ((e) => i(e, r, l(e), a)),
|
|
176
|
+
pin: o == null ? null : T.get(o)?.pin ?? ee(o),
|
|
177
|
+
policy: e.policy ?? null,
|
|
178
|
+
keep: e.keep ?? null,
|
|
179
|
+
close: e.close ?? null
|
|
180
|
+
}), o != null && T.delete(o), m.bringToFront(n), e.from && n.show({ from: e.from }), Y(n), n;
|
|
181
|
+
},
|
|
182
|
+
close(e, { to: t } = {}) {
|
|
183
|
+
let n = C.get(e), r = n?.item ?? null, i = n?.app ?? null, a = t === void 0 ? i == null ? null : z(r, i) : t, o = m.activeWindow === e || h(e);
|
|
184
|
+
r != null && i != null && T.set(r, {
|
|
185
|
+
app: i,
|
|
186
|
+
pin: s(p(e), x(), A())
|
|
187
|
+
});
|
|
188
|
+
let c = e.hide({ to: a });
|
|
189
|
+
r != null && u(E, r), i != null && !n?.palette && u(D, i);
|
|
190
|
+
let l = () => {
|
|
191
|
+
r != null && d(E, r), i != null && !n?.palette && d(D, i), F();
|
|
192
|
+
};
|
|
193
|
+
return c.then(l, l), ie(e), e.remove(), o && queueMicrotask(() => {
|
|
194
|
+
let e = m.activeWindow;
|
|
195
|
+
e ? Y(e) : B(r, i) || B(null, null);
|
|
196
|
+
}), c;
|
|
197
|
+
},
|
|
198
|
+
requestClose(e) {
|
|
199
|
+
let t = C.get(e)?.close;
|
|
200
|
+
t ? t(e) : $.close(e);
|
|
201
|
+
},
|
|
202
|
+
windowFor(e) {
|
|
203
|
+
for (let [t, n] of C) if (n.item === e) return t;
|
|
204
|
+
return null;
|
|
205
|
+
},
|
|
206
|
+
windowsOf(e) {
|
|
207
|
+
return m.stackingOrder.filter((t) => t instanceof n && C.get(t)?.app === e);
|
|
208
|
+
},
|
|
209
|
+
itemOf(e) {
|
|
210
|
+
return e instanceof n ? C.get(e)?.item ?? null : null;
|
|
211
|
+
},
|
|
212
|
+
appOf(e) {
|
|
213
|
+
return e instanceof n ? C.get(e)?.app ?? null : null;
|
|
214
|
+
},
|
|
215
|
+
isPalette(e) {
|
|
216
|
+
return e instanceof n && !!C.get(e)?.palette;
|
|
217
|
+
},
|
|
218
|
+
setItem(e, t) {
|
|
219
|
+
let n = C.get(e);
|
|
220
|
+
!n || n.item === t || (n.item = t, F());
|
|
221
|
+
},
|
|
222
|
+
isOpen(e) {
|
|
223
|
+
return E.has(e) || $.windowFor(e) !== null;
|
|
224
|
+
},
|
|
225
|
+
hasWindows(e) {
|
|
226
|
+
if (D.has(e)) return !0;
|
|
227
|
+
for (let [, t] of C) if (t.app === e && !t.palette) return !0;
|
|
228
|
+
return !1;
|
|
229
|
+
},
|
|
230
|
+
palettesChanged() {
|
|
231
|
+
J();
|
|
232
|
+
},
|
|
233
|
+
write(e, t) {
|
|
234
|
+
H(e, t), N();
|
|
235
|
+
},
|
|
236
|
+
clamped(e, t) {
|
|
237
|
+
return V(e, t);
|
|
238
|
+
},
|
|
239
|
+
placed(e) {
|
|
240
|
+
return U(e);
|
|
241
|
+
},
|
|
242
|
+
pinOf(e) {
|
|
243
|
+
return s(p(e), x(), A());
|
|
244
|
+
},
|
|
245
|
+
fromPin(e, t, n) {
|
|
246
|
+
return G(e, t, n);
|
|
247
|
+
},
|
|
248
|
+
setFrameBands(e) {
|
|
249
|
+
k = { ...e }, O = /* @__PURE__ */ new WeakMap();
|
|
250
|
+
},
|
|
251
|
+
arrangeWith(e, t) {
|
|
252
|
+
return w.set(e, t), N(), () => {
|
|
253
|
+
w.get(e) === t && w.delete(e), N();
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
arrange() {
|
|
257
|
+
M = !0;
|
|
258
|
+
try {
|
|
259
|
+
for (let e of w.values()) e.arrange();
|
|
260
|
+
for (let e of C.keys()) {
|
|
261
|
+
let t = U(e);
|
|
262
|
+
t && H(e, t);
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
M = !1;
|
|
266
|
+
}
|
|
267
|
+
N();
|
|
268
|
+
},
|
|
269
|
+
arranged() {
|
|
270
|
+
for (let e of w.values()) if (!e.arranged()) return !1;
|
|
271
|
+
for (let e of C.keys()) {
|
|
272
|
+
let t = U(e);
|
|
273
|
+
if (t && !e.hidden && !te(e, t)) return !1;
|
|
274
|
+
}
|
|
275
|
+
return !0;
|
|
276
|
+
},
|
|
277
|
+
resized(e) {
|
|
278
|
+
let t = x();
|
|
279
|
+
if (e.width === t.width && e.height === t.height) return;
|
|
280
|
+
let n = S(), r = {
|
|
281
|
+
left: n.left,
|
|
282
|
+
top: n.top,
|
|
283
|
+
width: e.width - n.left,
|
|
284
|
+
height: Math.max(0, e.height - n.top)
|
|
285
|
+
};
|
|
286
|
+
for (let n of C.keys()) ae(n, e, t, r);
|
|
287
|
+
for (let n of [...I]) n(e, t);
|
|
288
|
+
N();
|
|
289
|
+
},
|
|
290
|
+
snapshot() {
|
|
291
|
+
let e = {};
|
|
292
|
+
for (let [t, n] of T) e[t] = { ...n };
|
|
293
|
+
let t = m.stackingOrder;
|
|
294
|
+
for (let [n, r] of C) {
|
|
295
|
+
if (r.item == null) continue;
|
|
296
|
+
let i = s(p(n), x(), A());
|
|
297
|
+
e[r.item] = r.palette ? {
|
|
298
|
+
app: r.app,
|
|
299
|
+
pin: i
|
|
300
|
+
} : {
|
|
301
|
+
app: r.app,
|
|
302
|
+
pin: i,
|
|
303
|
+
z: t.indexOf(n)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return e;
|
|
307
|
+
},
|
|
308
|
+
rememberedPin(e) {
|
|
309
|
+
return T.get(e)?.pin ?? null;
|
|
310
|
+
},
|
|
311
|
+
layoutChanged() {
|
|
312
|
+
N();
|
|
313
|
+
},
|
|
314
|
+
setHome(e, t) {
|
|
315
|
+
return z = e, B = t, () => {
|
|
316
|
+
z = () => null, B = () => !1;
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
onLayout(e) {
|
|
320
|
+
return j.add(e), () => void j.delete(e);
|
|
321
|
+
},
|
|
322
|
+
onWindows(e) {
|
|
323
|
+
return P.add(e), () => void P.delete(e);
|
|
324
|
+
},
|
|
325
|
+
onRaster(e) {
|
|
326
|
+
return I.add(e), () => void I.delete(e);
|
|
327
|
+
},
|
|
328
|
+
beforeFront(e) {
|
|
329
|
+
return L.add(e), () => void L.delete(e);
|
|
330
|
+
},
|
|
331
|
+
onFront(e) {
|
|
332
|
+
return R.add(e), () => void R.delete(e);
|
|
333
|
+
},
|
|
334
|
+
dispose() {
|
|
335
|
+
clearTimeout(Z);
|
|
336
|
+
for (let e of y) e();
|
|
337
|
+
j.clear(), P.clear(), I.clear(), L.clear(), R.clear(), w.clear(), C.clear();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
return K = q(m.activeWindow), $;
|
|
341
|
+
}
|
|
342
|
+
//#endregion
|
|
343
|
+
export { _ as createWindowManager, m as deepActiveElement, g as focusInto };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A window's chrome as numbers: the insets between its outer box and the
|
|
3
|
+
* region its content lays out in, and the floor its grow box stops at.
|
|
4
|
+
*
|
|
5
|
+
* A page sizing a window to its content, or content to its window, needs the
|
|
6
|
+
* chrome between the two — a folder window's field sized to the scrolled
|
|
7
|
+
* plane, a palette sized to hold its cells exactly. Restating `1 + 18 + 20 +
|
|
8
|
+
* 15 + 1` in the page was the alternative, with a note to re-derive it if
|
|
9
|
+
* the kit's chrome ever changed.
|
|
10
|
+
*/
|
|
11
|
+
/** The grow box's default floors, in system px: a window smaller than this can't be worked. */
|
|
12
|
+
export declare const WINDOW_MIN_WIDTH = 80;
|
|
13
|
+
export declare const WINDOW_MIN_HEIGHT = 54;
|
|
14
|
+
/** The declared options that decide a window's chrome — `vf-window`'s own. */
|
|
15
|
+
export interface VfWindowChromeOptions {
|
|
16
|
+
/** `'utility'` for the windoid's 12px bar; anything else is the 18px one. */
|
|
17
|
+
variant?: 'utility' | null;
|
|
18
|
+
/** The edge scroll rails, as on `vf-window`. */
|
|
19
|
+
scrollbars?: 'vertical' | 'horizontal' | 'both' | null;
|
|
20
|
+
/** The header's height, rule included, while the `header` slot is populated. */
|
|
21
|
+
headerHeight?: number | null;
|
|
22
|
+
/** Whether the `status` slot is populated. */
|
|
23
|
+
status?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Four insets in system px. */
|
|
26
|
+
export interface VfInsets {
|
|
27
|
+
top: number;
|
|
28
|
+
right: number;
|
|
29
|
+
bottom: number;
|
|
30
|
+
left: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The insets, in system px, between a window's outer box (its `width` and
|
|
34
|
+
* `height`) and the region its content lays out in: the body, or under
|
|
35
|
+
* `scrollbars` the scrolled plane's viewport. They go both ways — the content
|
|
36
|
+
* of a window `W` wide is `W − left − right`, and a window that holds content
|
|
37
|
+
* `w` wide is `w + left + right`. The frame's 1px stroke on every side, the
|
|
38
|
+
* title bar (18, or 12 for a utility window) and the header above, the status
|
|
39
|
+
* strip (15) and a horizontal rail (15) below, a vertical rail (15) on the
|
|
40
|
+
* right. The numbers are the default tokens': a page that restyles
|
|
41
|
+
* `--vf-titlebar-height` or `--vf-status-bar-height` restates them.
|
|
42
|
+
*/
|
|
43
|
+
export declare function windowChrome({ variant, scrollbars, headerHeight, status, }?: VfWindowChromeOptions): VfInsets;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/window-chrome.ts
|
|
2
|
+
var e = 80, t = 54;
|
|
3
|
+
function n({ variant: e, scrollbars: t, headerHeight: n, status: r = !1 } = {}) {
|
|
4
|
+
let i = t === "vertical" || t === "both", a = t === "horizontal" || t === "both";
|
|
5
|
+
return {
|
|
6
|
+
top: 1 + (e === "utility" ? 12 : 18) + Math.max(0, n ?? 0),
|
|
7
|
+
right: 1 + (i ? 15 : 0),
|
|
8
|
+
bottom: 1 + (r ? 15 : 0) + (a ? 15 : 0),
|
|
9
|
+
left: 1
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { t as WINDOW_MIN_HEIGHT, e as WINDOW_MIN_WIDTH, n as windowChrome };
|
package/docs/SPEC.md
CHANGED
|
@@ -48,7 +48,7 @@ Modern requirements that we deliberately keep (accessibility over purity):
|
|
|
48
48
|
- Components must render nothing surprising outside their box: no margins on `:host` by default.
|
|
49
49
|
- **Explicit placement** (`VfPositioned`, src/position.ts): every component takes `top`/`left` in whole system px — set either and the host is absolutely positioned within its parent (`position: absolute` plus both offsets as live `calc(var(--vf-scale, 1) * Npx)` inline styles; the unset coordinate is 0; `right`/`bottom` released to `auto` and `margin` zeroed). Set neither and the element renders in flow; removing both unwinds every inline declaration. The DITL model: a dialog can be laid out by positioning its items just as validly as by stacking them. **No component is excluded** — the rows a container normally owns (`vf-option`, `vf-menu-item`, `vf-list-item`) and a bar's `vf-menu` take the pair on the same terms, because a consumer may stand one of them up on its own and the kit does not get to rule that out. Placing such a row *inside* its managing parent takes it out of that parent's flow, so the parent stops measuring it (a `vf-select`/`vf-menu` panel is as wide as its widest **flowed** row, and `popup-overflow`'s clamp counts the same set) and the rows after it close the gap — the documented consequence of the placement, not a failure of it; each component's own doc comment states what its container stops doing. Containers are deliberate anchors — the desktop raster, a window's or a dialog's *content region* (the frame's inner edge below any title bar, with no inset of its own — flow content starts there too), a stack's box, a fieldset's border interior, a scroll area's scrolled plane. The style writing rides a ReactiveController (`hostUpdated`), not an `updated()` override — component subclasses routinely skip `super.updated()` — and re-applies only when the property values changed, so an unrelated update never re-asserts a coordinate. **`origin`** (reflected string: one of nine keywords, vertical then horizontal — `top left` the default, `top center`, `top right`, `center left`, `center`, `center right`, `bottom left`, `bottom center`, `bottom right`) names which point of the host's own box the pair places. With a non-default origin the controller measures the host's border box in whole system px (w × h) and writes `left` as `calc(var(--vf-scale, 1) * (L − ⌈w·fx⌉)px)` with `fx` 0, ½ or 1, `top` likewise — the ceiling sends an odd size's leftover half toward the start, the tie rule `vf-stack`'s centering and the title patch use — and keeps the offset current with a ResizeObserver (a relabel, a late font; a zoom step changes CSS px, not system px, so the same offset comes back and nothing is rewritten), each rewrite announced as `vf-placement-change`. Never a transform: a percentage of an odd width is half a pixel, and a transform would make the host the containing block for a `position: fixed` descendant (`vf-select`'s list). `top left` is the plain path — no observer, no measurement, the same inline style. The pair is the origin point throughout: `PlacementController.seed()` returns it (the corner read from flow plus the offset, for a host with no stated pair), `moveTo()` takes the offset off before the clamp and puts it back after, so a gesture writes the point's pair and the clamp holds the box; `placementAt(clientX, clientY, child?)` on the three shadow-anchored containers folds `child`'s offset in; `fixed` folds it into its sticky thresholds. `vf-dialog` has no `origin` — its pair is the viewport's, and unset centers it. An unknown value places as `top left` and warns once per element; on its own the attribute places nothing. **`fixed`** (bare attribute, reflected) holds the placement against the *visible* region of the nearest scrolling ancestor instead of its scrolled plane: `position: sticky` in place of `absolute`, the same live offsets, the flag alone at (0,0). Sticky boxes stay in flow, so the controller erases the footprint — the host is blockified (the outer display change `absolute` makes on its own: `inline-block` → `block`, `inline-flex` → `flex`, …), `max-width: fit-content`, `z-index: 1` (over the plane's placed children), and a negative right/bottom margin equal to its own border box, kept exact by a ResizeObserver — a 0×0 margin box that flow content lays out as if it weren't there. Two engine facts stated as rules: sticky only ever pushes a box *down* from where the flow put it, so a fixed child precedes the flow content in its parent; and a scroll container that never scrolls (a plain window body is `overflow: hidden`) holds it exactly where placement would. Removing the flag returns the host to ordinary placement with the recipe unwound. `npm run verify:position`.
|
|
50
50
|
- `vf-dialog` takes the pair in **viewport** coordinates: `showModal()` puts the box in the top layer, whose containing block is the viewport rather than the nearest positioned ancestor. Unset means centered — recomputed on open and on every box/viewport/scale change, so a modal whose content upgrades after opening is never stranded; `position` is left to the UA and only the four inset/margin declarations are written.
|
|
51
|
-
- **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }
|
|
51
|
+
- **Gestures write through the same properties** (`PlacementController`): a `vf-window`/`vf-dialog` title-bar drag and a `vf-icon` drag or arrow nudge state `top`/`left`, and `vf-window`'s grow box states `width`/`height` (`VfSized`) — all in whole system px, so a moved element is placed the way an authored one is and holds its spot through a zoom. Writing resolved CSS px instead was the bug: `--vf-scale` moved under the constant and every zoom step re-read it as a different number of system px (by the ratio the scale itself moved — visible even at zoom levels where the target does not change, and nothing else on the page does). Values are snapped to `snapSys` at gesture time and never re-snapped afterwards: re-rounding onto each new lattice compounds (62 → 63 → 64), and whole system px is whole device px at every rung regardless. `npm run verify:zoom` group (e). Every placement write the controller makes is announced as `vf-placement-change` (bubbles, `composed: false`, `{ left, top }`, nulls on a return to flow), a public event: a page hears every move through it — a title-bar drag's steps and its release (an `outline-drag` writes once, on the release), an arrow nudge, a drop, a walk's landing, an `origin` re-measure and its own property writes — and so does a `vf-scroll-area`, which re-measures under a child dropped, nudged or set past its viewport with no code on the page. It is dispatched from the host's update with the new pair already written, so a handler that writes the pair again takes effect on the next update. `npm run verify:position` (EVENT), `npm run verify:window` (PLACEMENT EVENT). `npm run verify:icon-drag` (RE-MEASURE).
|
|
52
52
|
- **The movable contract.** A host that moves under a gesture states its own rectangle, and its positioning parent is a box with a size. Both halves are the consumer's — a component can supply neither for itself — and both used to fail quietly, so each is a one-time `console.warn` (`warnMovableContract`, latched per element like `vf-window`'s size warning). `npm run verify:position` group CONTRACT.
|
|
53
53
|
- `vf-window[movable]` states `top`/`left` **and** `width`/`height`; a WIND resource carried the whole rect. `vf-icon[movable]` states `top`/`left` only — it is not `VfSized`, its height is its content's, and `width` is the cell pitch. `vf-dialog` is exempt: `showModal()` anchors it to the viewport, and unset means centered.
|
|
54
54
|
- What the first half actually requires is being **out of flow** when the gesture starts; `top`/`left` are the kit's way and the only one that scales, but a stylesheet's own `position: absolute` satisfies it and `seed()` reads that case from the computed offsets (a faux desktop places its icons this way). A host still in flow has to be taken out of it by its first move, which reflows everything after it and collapses an auto-height parent to whatever is left. **The clamp therefore runs against the box measured once by `seed()`, not a fresh one per move**: re-reading it would hold the rest of the gesture inside a box the host never sat in, walking it to the parent's origin while the user drags the other way.
|
|
@@ -196,22 +196,23 @@ Every recipe also declares its size — `width`, and `height` where the window i
|
|
|
196
196
|
|
|
197
197
|
#### `vf-desktop` (`VfDesktop`, vf-desktop.ts)
|
|
198
198
|
Full-bleed classic desktop container.
|
|
199
|
-
- **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it.
|
|
200
|
-
- **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/ `height` to the largest whole-system-px raster whose host box — bezel included — fits the bound, per the current effective scale, and returns `{ width, height }`. The page's half of the sizing contract: call it on `resize` and `onScaleChange` (zoom and density moves change what a system px costs in CSS px), as a full-screen faux desktop does. `clearActive()` → deactivate the whole document tier (see *Deactivation* under Behavior). `activeWindow: HTMLElement | null` (read-only) → the active document-tier window, or `null` while the tier is deactivated or
|
|
199
|
+
- **Attributes/props:** `width`, `height: number` (system px, defaults 512×342 — the compact Mac's raster) — the screen's own size, the way a WIND resource declared a window's. The host box renders at exactly `width + 2·bezel` per axis (a live `sysLength` written as host inline size on every update), always a whole number of system px. **Pure CSS sizing is not supported**: the inline size wins over any stylesheet, so the page sets the numbers — directly or via `fitWithin` — and positions the explicitly sized desktop with its own CSS, keeping any sub-system-pixel slack in the page's own layout. `bezel: number` (system px, default 0) — the black screen surround, the CRT's unlit margin between raster and case, added onto the declared screen on every side. The screen owns flow, an absolute window's containing block and the drag clip, so a dragged window crops at the raster's edge. Its four corners wear the `SCREEN_CORNER` mask (the bottom pair mirrored vertically — `steppedCornerClip`'s `edge`), painted above everything like the hardware mask — the top pair lands over a slotted menu bar's corners, so the bar needs no `rounded` of its own inside a bezeled desktop. The bezel width is written onto the host as `--vf-desktop-bezel` (self-set geometry, like `--vf-scale` — component-owned, not a theming token). `pattern: string` (default `'gray-50'`) — the desktop pattern, System 7's General Controls setting: a library name (docs/PATTERNS.md) or sixteen hex digits stating a custom 8×8; an unrecognized value warns once and keeps the dither. A `--vf-desktop-pattern` token override wins over it. `window-top: number` (`windowTop`, system px from the screen's top, unset by default) — the least `top` a window may take, for a page that keeps a strip of its own under the menu bar, over the windows: a window dragged on the desktop stops there and `windowArea` starts there; icons still stop at the menu bar; below the bar's bottom it changes nothing.
|
|
200
|
+
- **Methods:** `fitWithin(maxWidth, maxHeight)` (CSS px) → sets `width`/ `height` to the largest whole-system-px raster whose host box — bezel included — fits the bound, per the current effective scale, and returns `{ width, height }`. The page's half of the sizing contract: call it on `resize` and `onScaleChange` (zoom and density moves change what a system px costs in CSS px), as a full-screen faux desktop does. `clearActive()` → deactivate the whole document tier (see *Deactivation* under Behavior). `activeWindow: HTMLElement | null` (read-only) → the active document-tier window, or `null` while the tier is deactivated or has no shown window; utility windows and hidden windows are never the holder. `stackingOrder: HTMLElement[]` (read-only) → the slotted windows in stacking order, bottom-most first — the document tier, then the floating tier, each by its last raise — hidden windows included where they stand; a fresh array per read. The light-DOM order catches up with it only after a gesture, and a raise by keyboard focus moves no node, so a page that saves window depths or picks the window behind another reads this rather than the DOM. `workArea` (read-only) → `{ left, top, width, height }` in system px from the screen's corner, bezel excluded: the screen less a slotted, shown `vf-menu-bar` along its top edge (measured at the call; the whole screen without one). `windowArea` (read-only) → the work area deepened to `window-top` where that is lower — the area windows are dragged in. `placementAt(clientX, clientY, child?)` → a viewport point as a placement on the screen, `{ left, top }` in whole system px on the placement lattice, measured from the raster's corner with the bezel excluded — the pair a child dropped onto the desktop is written with; with `child`, the pair to write to that element, its `origin` offset folded in (`placementIn`, src/position.ts; the anchor sits in shadow, which is why it is a method).
|
|
201
201
|
- **Visual:** `display: block; position: relative;` — the paint lives on an inner screen surface (part `desktop`, `overflow: hidden` — the whole-system-px raster, inset by `bezel` when one is set). Screen surface = the `pattern` (the classic 50% dither, `gray-50`, by default), painted as the screen's own background by the pattern fill (*Patterns* under *Tiled fills*): black ink on the fill's opaque `--vf-white` paper, a repeating tile — the authentic black-on-white dither. A consumer `--vf-desktop-pattern` renders instead as a placed tile grid on the token's 30-system-px tile with no paper, so `var(--vf-desktop, #808080)` beneath shows only under a custom tile with transparent cells (or `none`). Forced colors: flat Canvas.
|
|
202
202
|
- **Slots:** default (menu bar, windows, anything).
|
|
203
203
|
- **Behavior:** manages stacking of slotted `vf-window` children: `pointerdown` *or `focusin`* on a window brings it to front (incrementing z-index counter) and sets its `active` attribute, clearing `active` on the others — the `focusin` half is the keyboard route to activation: Tab landing anywhere in a background window (its undrawn-but-focusable widgets included) raises it. Listens via delegated pointerdown/focusin listeners + `slotchange`. Windows slotted before `vf-window` is defined are re-normalized once `customElements.whenDefined('vf-window')` settles, since the upgrade reflects each window's `active = true` default back out and upgrading a slotted node doesn't re-fire `slotchange`.
|
|
204
|
-
- **DOM order follows z-order** (bottom-most first), so sequential focus order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync runs in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); focus
|
|
205
|
-
- **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant`
|
|
204
|
+
- **DOM order follows z-order** (bottom-most first), so sequential focus order matches the visual stack and Shift+Tab mirrors Tab exactly. The sync runs in a task after a pointer gesture ends, or after a programmatic `bringToFront` — never mid-gesture (a node move would clear the pointer capture a title-bar drag or grow-box resize holds), never between a press's release and its `click` (Chromium drops a click whose mousedown node was re-inserted first, which used to cost a control in a background window its click), and never from a focus-driven raise (moving the window focus just entered would re-order the tab sequence mid-traversal); the move is `Element.moveBefore()` where the browser has it, which keeps the focus inside the moved window — a rename field, a caret — and otherwise a re-insert, after which the focus is restored without re-raising that window (a `vf-icon` rename whose field blurred in the move and has its focus back in the same task commits nothing). Non-window children (a menu bar, page content) keep their positions. `npm run verify:window-a11y`.
|
|
205
|
+
- **Floating tier:** `vf-window[variant="utility"]` children stack in a z band `1_000_000` above the document tier (one shared monotonic counter, so a palette stays above every document window), restack only among themselves, and stand outside the single-active invariant both ways: clicking a palette doesn't deactivate the active document window, and activating a document window never clears a palette's `active` — System 7 windoid behavior while the app is frontmost. The tier test reads the `variant` property of an upgraded window and the attribute of one not yet upgraded, so both land right — and so does a palette made by script and appended at once, which is slotted before its attribute reflects. `npm run verify:desktop-activate` (SCRIPTED).
|
|
206
206
|
- **Menu tier:** a slotted `vf-menu-bar` — or a free-standing `vf-menu` placed on the desktop — is restated at `z-index: 2_000_000` by the desktop's own `::slotted(vf-menu-bar), ::slotted(vf-menu)` rule (an outer-tree `::slotted` declaration beats the bar's inner `:host { z-index: 1000 }`), a band above the floating tier, so a dropped menu paints over palettes and document windows alike — the Menu Manager drew menus over every window. Only the drag outline, a window's opening rects and the screen-corner mask are in front. The full stack inside the screen's isolated context: consumer tile grid (−1) < document windows (counter) < utility windows (counter + 1_000_000) < menu tier (2_000_000) < drag outline and window rects (2_000_001) < corner mask (max). A popup *inside* a window (`vf-select`'s fixed panel) is a different case: it stacks within its window's context, so a palette can still cover it — KNOWN-BUGS.md. `npm run verify:archetypes` (MENU TIER).
|
|
207
207
|
- **The drag surface:** the Finder drew a dragged icon's outline on the *screen*, clipped at its edge and phase-locked to its raster, so the screen renders the layer that outline draws on — `.drag-surface`, a full-bleed child of `.screen` after the slot, `pointer-events: none` (never a hit for the page's `elementsFromPoint`), inside the raster's clip. It is deliberately no stacking context (no z-index of its own): a stacking context is an isolated group, and the outline's `mix-blend-mode` would then composite against the empty layer instead of the windows and dither beneath; the tier goes on its children (`.drag-surface > * { z-index: 2_000_001 }`), which places the outline in `.screen`'s own context, above the menu bar and under the corner mask. An icon reaches it through the `vf-drag-surface-request` handshake (§5 vf-icon): the desktop listens on its host, and the nearest desktop fills a still-null `detail.surface`. A window asks the same way and draws there with the same pen: its zoom rects when it opens from or closes to a rect (§5 vf-window, `show` and `hide`), its outline under `outline-drag`. Not the top layer: stable Safari cannot blend an element there against the page (`XOR_UNAVAILABLE`, src/cursor.ts), and a second kit entry in the top layer would need the re-promotion handshake KNOWN-BUGS.md #1 describes. `npm run verify:icon-drag` (OUTLINE).
|
|
208
|
-
- **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
|
|
208
|
+
- **Deactivation:** on a real System 7 machine clicking the desktop clicked the *Finder* — the frontmost application's windows lost their stripes. `clearActive()` is that gesture's handler: it clears `active` from the whole document tier, and **zero active windows is a legal state**, held until a press or `focusin` re-enters a document window or a new one is slotted (opening a window brings its application forward — a newly slotted window ends a deliberate deactivation, where a mere survivor never does: removing a *background* window while deactivated promotes nothing, while removing the holder outside a deactivation promotes the topmost survivor). The desktop never takes the decision itself — a press on its own bare dither changes nothing; desktop furniture is slotted light DOM, so only the page knows which of its children mean "the Finder". Utility windows keep their `active` through a deactivation (their dots stay drawn). **A hidden window never holds the active state.** A page that closes windows by hiding them (`vf-window.hide()`, or the `hidden` attribute set directly — the desktop watches the attribute on every slotted window) gets no activation from the hidden ones: a window slotted hidden waits to be shown, `bringToFront()` on one gives it its place in the stack and activates nothing, and the holder hiding hands the state to the topmost shown document window, or to none. A window shown into a tier where none is active takes the state, unless the tier was deliberately deactivated. Every change of holder — window to window, window to none, none to window — fires `vf-activate`, once per change; re-asserting the current holder is silent. `npm run verify:desktop-activate`.
|
|
209
|
+
- **The work area:** a slotted `vf-menu-bar` along the screen's top takes its band off the screen (`workArea`), and the gestures on the screen stay below it: a window dragged up stops with its title bar under the bar — or at `window-top`, for windows alone (`windowArea`) — where it can still be grabbed, and an icon dropped, nudged, moved (`moveTo`, `dragTo`) or carried in a group on the desktop lands below the bar. A gesture measures the box it is clamped into once, at its start (`PlacementController.seed()`), by dispatching `vf-placement-bounds-request` (bubbles, non-composed — the drag surface's handshake); the desktop answers for a host placed on its screen (`offsetParent` the desktop: a slotted window, an icon in an unplaced field) with the screen's size, bezel excluded, and the area's top as the floor, rounded up to the host's placement lattice so a snapped landing never rounds above it (21 on a lattice of 3). A host placed in a box of its own — a window body, a placed field — is clamped in that box, from 0. `npm run verify:window` (WORK AREA), `npm run verify:icon-drag` (WORK AREA).
|
|
209
210
|
- **Events:** `vf-activate` (detail `{ window: HTMLElement | null }` — the new holder, `null` on deactivation).
|
|
210
211
|
- **Parts:** `desktop`.
|
|
211
212
|
|
|
212
213
|
#### `vf-window` (`VfWindow`, vf-window.ts)
|
|
213
214
|
The desktop-window shell: the classic document window (see DragThing screenshot), parameterized down to the windoid (see the Group A recipe table).
|
|
214
|
-
- **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default true), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule).
|
|
215
|
+
- **Attributes/props:** `heading: string` (title text), `width: number` / `height: number` (**declare them both** — whole system px, so the window keeps its proportion to the chrome inside it at every density. A window is a fixed box in both axes, the way a WIND resource was: one that grows with its body is one the user can neither predict nor control via the grow box. Each missing dimension falls back to something different — width to block layout, height to the content — and the window names whichever are missing, once, in the console), `active: boolean` (default **true**; reflect), `closable: boolean` (default true), `zoomable: boolean` (default false), `movable: boolean` (default false), `outline-drag: boolean` (`outlineDrag`, default false — the title-bar drag moves an outline and the release moves the window; see Behavior), `resizable: boolean` (default false), `min-width` / `min-height: number` (default 80 / 54) and `max-width` / `max-height: number` (unbounded) — the grow box's sizeRect, whole system px (see Behavior), `variant?: 'utility'` (the slim windoid chrome), `scrollbars?: 'vertical' | 'horizontal' | 'both'` (edge scroll rails), `header-height: number` (`headerHeight` — the header's height in whole system px, rule included; unset, the header is as tall as its content plus the rule). Read-only: `sizeLimits` → `{ minWidth, minHeight, maxWidth, maxHeight }`, the sizeRect as it applies in whole system px — the declared mins or the 80×54 floor, the declared maxes or `Infinity` — for a page that sizes the window itself to stay in the grow box's range. The chrome around the content is `windowChrome()` (§2, src/window-chrome.ts): the insets in system px between the outer box and the body, or under `scrollbars` the scrolled plane's viewport, for a set of declared options — `{ variant, scrollbars, headerHeight, status }` — at the default tokens. `npm run verify:window` (CHROME).
|
|
215
216
|
- **Visual:** `vfChromeFrame` + `vfTitleBar` (§4), plus a full-size flex-column layout on the frame — title bar, header, body, status strip. `display: block`. Sets `--vf-surface: var(--vf-white, #fff)` on itself.
|
|
216
217
|
- Title bar: from `vfTitleBar` — height `var(--vf-titlebar-height, 18px)`, white bg, bottom `1px solid black`, contains `.vf-stripes` layer (only when `active`). `touch-action: none` only when `[movable]`.
|
|
217
218
|
- Title: centered, bold, on a white patch (`padding: 0 6px`; cap band on interior rows 4..12, 7px of white between ink and stripes — see §4 `vfTitleBar` for the traced geometry and the lattice hold) above the stripes, with `--vf-title-inset: 60px` of clearance so it ellipsizes before reaching the widgets. Inactive: no stripes, widgets undrawn (transparent ink — they keep their tab stops; see A11y below), the grow box's nested squares hidden, and every managed scroll rail inside the window blanked (see "always-a-rail" §5 vf-scroll-area) — but the title text stays black (System 7 never grayed the window title).
|
|
@@ -365,7 +366,7 @@ The classic popup menu control ("Macintosh HD ▼").
|
|
|
365
366
|
- **Attributes/props:** `label: string` — accessible name for the menubar, mirrored as host `aria-label` (guarded: a consumer's own `aria-label`/`aria-labelledby` is left alone). `rounded: boolean` (reflected) — draws the System 7 screen-corner mask over the bar's top-left/top-right corners. `shortcuts: boolean` (reflected) — makes every slotted menu's item `shortcut`s live key equivalents, one declaration for the whole bar the way MenuKey() answered for every menu at once (see vf-menu-item for the claim contract). Off by default: key equivalents are page-global, and only *the* menu bar should own them.
|
|
366
367
|
- **Visual:** `display: block/flex`, height `var(--vf-menubar-height, 20px)` — 19 white system px over the 1px black rule, the Menus.png bar strip exactly — white bg, the rule `vfRule`'s `.vf-rule-bottom` on the bar (§4), children laid out horizontally from left. Adjacent titles are pulled 5px into each other (`margin-inline-start: -5px` on every slotted menu, absorbed by 14px of bar start padding — 9px of bar before the first plate, as System 7 placed the Apple title, plus the first title's 5px share): Menus.png spaces title ink 14px apart while each title's plate runs 10px left / 9px right of its own ink, so neighboring plates *overlap* by 5px, as the originals did. With `rounded`, two 5×5 system-px corner overlays paint the traced stair-step mask (per-row runs 5/3/2/1/1 — `SCREEN_CORNER` + `steppedCornerClip` in `pixel-frame.ts`) in `--vf-black` *over* the bar: on the classic screen the rounding was the ROM's black corner mask sitting on top of the menu bar, not a shape of the bar's own, so it is ink over any backdrop rather than a cutout.
|
|
367
368
|
- **Behavior:** container/controller for slotted `vf-menu` children. Pressing a menu label → opens it (label inverts while open). While any menu is open, hovering another label switches to it (classic behavior). Escape / outside click / item selection closes. `role="menubar"`, behind a first-connect ownership latch so a consumer's own role survives upgrade; the shadow `.bar` is `role="presentation"` and each slotted `vf-menu` host `role="none"`, so the `menubar → menuitem` ownership chain has no generics in it. While a menu is open: ArrowLeft/Right move between menus, ArrowDown/Up walk the open menu's items, Home/End jump to its first/last enabled item, and printable keys run the shared Finder first-letter type-ahead over the items (`src/type-ahead.ts`; Space stays the focused item's activation key, and the prefix resets on menu switch or close). The bar also **owns the press-drag-release gesture** across its menus (`MenuPressController`, `src/menu-press.ts` — see `vf-menu`), since one press may travel over several of them: it binds the opening `pointerdown` and hands the controller its own open/close rules, so the gesture changes *when* a menu opens, never *how*.
|
|
368
|
-
- **Slots:** default (vf-menu elements). **Parts:** `bar`.
|
|
369
|
+
- **Slots:** default (vf-menu elements); `end` — the bar's right end, where a clock goes: a flex row of its own pushed to the end with an auto start margin and held 9px in, mirroring the first title's inset, setting `--vf-label-line-height` to the bar's height so a `vf-label` there rides the titles' baseline. Empty, it is `display: none` and takes no part in the row, so a page that pushes its own item to the end keeps the whole of the slack. `npm run verify:archetypes` (END SLOT). **Parts:** `bar`.
|
|
369
370
|
|
|
370
371
|
#### `vf-menu` (`VfMenu`, vf-menu.ts)
|
|
371
372
|
- **Attributes/props:** `label: string` (the menu title in the bar; may contain e.g. an apple glyph), `open: boolean` (reflect, managed by menu-bar or self), `shortcuts: boolean` (reflect — the per-menu key-equivalent grant, for a standalone menu; in a bar, declare it once on the bar instead. See vf-menu-item).
|
|
@@ -442,7 +443,7 @@ The kit's layout primitive: a flexbox whose `gap`, `pad`, `width` and `height` a
|
|
|
442
443
|
A plain sized box: `width`/`height` in whole system px around a bare slot — nothing drawn but what `pattern` and `rule` name, no layout opinion. Explicit placement (src/position.ts) leaves one line of CSS the kit can't write for a consumer: children placed with `top`/`left` need a positioned ancestor, so a region of the consumer's *own* needs `position: relative` in a stylesheet. This component is that region as an element — a DITL's enclosing rectangle with nothing drawn in it.
|
|
443
444
|
- **Attributes/props:** `width` / `height`: number (whole system px, via `VfSized`); the `top` / `left` pair via `VfPositioned` like nearly every component; `pattern?: string` — a library pattern by name or sixteen hex digits (docs/PATTERNS.md), painted as the box's background: unset paints nothing, an unrecognized value paints nothing and warns once; `rule?: string` — edge names separated by spaces (`"bottom"`, `"top bottom"`, up to all four in any order), the 1px rule drawn on those edges inside the declared size: unset draws none, a value naming anything but an edge draws none and warns once. On a **child**: `fill-width` and `fill-height`, bare attributes as in `vf-stack`. That is the whole API.
|
|
444
445
|
- **Not a `vf-stack`.** The stack is a flexbox with opinions — an axis, a cross-axis default, fills compiled into flex. The container has none: in-flow children get normal flow, placed children get a coordinate system. Reach for it when the stack's opinions are the thing in the way — a field of placed icons, a fixed stage for placed art, a composition that brings its own layout with it.
|
|
445
|
-
- **Visual:** none unless `pattern` or `rule` is set — no role, keyboard behavior or selection; what it holds decides what it is. With `pattern`, the shadow box carries the fill as its own background (`vfPatternFill`: the `vf-pattern-fill` class always, `vf-patterned` while a pattern is resolved — *Patterns* under *Tiled fills*), riding the snap correction with the coordinate system; the tile repeats over whatever size the box has, declared or not. With `rule`, the same box wears `vfRule`'s per-edge classes (§4): the rule is a stroke inside the declared size (`box-sizing: border-box`), so a 24px `rule="bottom"` strip is 23 rows of box over one of ink — the menu bar's anatomy. A pattern's paper reaches the rule and its phase anchors inside it (`background-origin: content-box`), so it begins directly inside a top or left rule. `npm run verify:rule`. `:host` is a block with `width: fit-content`, so an undeclared axis shrink-wraps rather than claiming the parent's width (the stack's rule — a layout box must not hand out a size nobody declared; a declared dimension lands on the host's inline style and beats it). The shadow box coincides with the host box (`.vf-snap`, `height: 100%`), and the slot is the box inside its rules (`position: relative`, `display: flow-root`, `height: 100%`) — the coordinate system: the anchor for `top`/`left` children, the component's purpose, and what percentage fills resolve against; flow-root so a slotted margin cannot collapse through the top edge and push the coordinate origin off the corner. The snap correction on the shadow box carries it. Content that outgrows the declared box overflows it rather than growing it.
|
|
446
|
+
- **Visual:** none unless `pattern` or `rule` is set — no role, keyboard behavior or selection; what it holds decides what it is. A page that makes the box focusable (a role and a `tabindex` — a pattern chooser's cells as radios) gets the kit's focus mark rather than the browser's ring: on `:focus-visible` the host's outline is off and the shadow box's `::after` is `vfFocusUnderline` one blank row below the box, spanning it — `vf-swatch`'s placement, since such a box is nothing but fill (`bottom: vfStrokeInset(-3)`, so a ruled box's stroke, a border under forced colors, is counted there). `npm run verify:focus`. With `pattern`, the shadow box carries the fill as its own background (`vfPatternFill`: the `vf-pattern-fill` class always, `vf-patterned` while a pattern is resolved — *Patterns* under *Tiled fills*), riding the snap correction with the coordinate system; the tile repeats over whatever size the box has, declared or not. With `rule`, the same box wears `vfRule`'s per-edge classes (§4): the rule is a stroke inside the declared size (`box-sizing: border-box`), so a 24px `rule="bottom"` strip is 23 rows of box over one of ink — the menu bar's anatomy. A pattern's paper reaches the rule and its phase anchors inside it (`background-origin: content-box`), so it begins directly inside a top or left rule. `npm run verify:rule`. `:host` is a block with `width: fit-content`, so an undeclared axis shrink-wraps rather than claiming the parent's width (the stack's rule — a layout box must not hand out a size nobody declared; a declared dimension lands on the host's inline style and beats it). The shadow box coincides with the host box (`.vf-snap`, `height: 100%`), and the slot is the box inside its rules (`position: relative`, `display: flow-root`, `height: 100%`) — the coordinate system: the anchor for `top`/`left` children, the component's purpose, and what percentage fills resolve against; flow-root so a slotted margin cannot collapse through the top edge and push the coordinate origin off the corner. The snap correction on the shadow box carries it. Content that outgrows the declared box overflows it rather than growing it.
|
|
446
447
|
- **Carries a `GridSnapController`.** A container's box is itself the consumer's coordinate system, including for non-`vf` content that cannot correct itself. The shadow box owns the positioning anchor and the `vf-snap` class *together*, so the correction moves the whole coordinate system and everything placed against it rides along, kit or not. A declared size and whole-px `top`/`left` are on the grid by construction; the controller covers the origin the page contributes. (This is the arrangement `vf-stack` has since adopted too — one shadow box owning anchor and correction together.)
|
|
447
448
|
- **Typographically transparent**, exactly as `vf-stack` and for the same reason: `font`, `-webkit-font-smoothing`, `color`, `user-select` and `text-align` return to `inherit`, so wrapping content in a sized box changes nothing about how that content reads.
|
|
448
449
|
- **`fill-width` / `fill-height`** are read about the host (`width`/`height: 100%`, for a parent that can give the box a size) and compiled for slotted children as the percentage form — normal flow has no flex axes to translate onto. `width: 100%` always binds against the box; `height: 100%` binds only against a declared `height` (percentage-against-auto computes to auto), so a fill with nothing to take is inert, not an error. A light-DOM declaration beats the `::slotted` rule, as everywhere.
|
|
@@ -481,7 +482,7 @@ The Finder icon — pixel art in a reserved cell with its name on a plate below,
|
|
|
481
482
|
- **Attributes/props:** `label`: string (the name; empty draws no plate — that is the "no label" setting, rather than a second attribute that could disagree with it). `size`: `large` (default, 32×32) | `small` (16×16) — picks the slot that paints *and* the cell it paints in. `selectable`, `selected`, `movable`, `editable`: boolean. `color`: boolean — declares the art a **color icon**, so selection darkens instead of inverting (see Visual). `open`: boolean — the icon's window is on screen, and the art paints as the derived open ghost (below). `target`: boolean (reflected) — the destination highlight under a drag: the selected treatment of the *art* alone (`invert(1)`, or `brightness(0.5)` under `color`) with none of `selected`'s semantics — no `vf-select`, no outside listener, no `aria-selected`, the plate untouched; the page sets it on the folder a `vf-drag` reports the pointer over. `width`: number (system px, **even**; the cell/grid pitch, which a longer name overflows rather than being bound by). `maxlength`: number (31).
|
|
482
483
|
- **Visual:** `display: inline-block`. A column: the art cell (`--_cell` system px square, `overflow: hidden`, art centered), then, directly under it (`--vf-icon-gap`, 0 by default), the name plate — body face on a `--vf-icon-label-height` (12px) line box, the Finder's own plate height: the face's 16px em centers in it, 3px above the ascenders and descenders on the bottom edge, exactly as the Finder drew it — 1px horizontal padding, **opaque `--vf-white`** in its own right rather than `--vf-surface`: on the desktop dither the name reads because it sits on a plate, and `--vf-surface` is unset out there (`Example screen (1-bit).png`, the "Macintosh HD" icon). Selected: the plate takes the `--vf-highlight` pair and the art takes `filter: invert(1)` — a System 7 icon is ink and opaque white on a transparent surround, i.e. precisely an image plus its mask, so inverting flips the two and leaves the surround alone. That is the complete classic selected appearance for 1-bit art. Color art would invert into a photographic negative, which System 7 never showed: declare `color` and selection **darkens** instead — Icon Utilities' ttSelected transform, every color blended halfway toward black (`filter: brightness(0.5)`), whites going gray, the surround still untouched. The declaration is the consumer's, since the component cannot tell color art from a mask by looking at a slotted image; it covers whatever the cell shows (the open ghost included, so a color icon has one selected treatment open or closed), and the plate inverts either way — the selected name was inverted whatever the art.
|
|
483
484
|
- **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art redraws as the Finder's open-window ghost — outline held in solid black, interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the slotted art itself, by compositing alone: a silhouette via `source-in`; a 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette shifted one pixel each way (off-canvas composites as transparent, so the raster's own edge erodes too and an interior hole rings itself); the lattice via `createPattern` anchored at the art's top-left, where 32 and 16 being whole multiples of the tile keeps a field of icons in one phase; then the fill composed over the un-eroded silhouette, so the ring the erosion removed reads as the outline. Never `getImageData` — a canvas that drew a cross-origin image is tainted, which forbids *reading* pixels but not drawing or displaying them, so the ghost works for CORS-less sources too — and never `ctx.filter` (Safari's shaky corner; compositing operators are universal). Keeping the interior *opaque white* is what lets a selected open icon invert under the same `filter: invert(1)` with no second treatment. The slot stays in the tree while the ghost paints, hidden via a class gated on a successful derivation — art the pipeline cannot draw (nothing slotted, a failed load, an inline `<svg>`) keeps rendering as itself. The ghost re-derives on slotchange, the art's `load`/`error`, and `open`/`size` changes, keyed by `currentSrc` so a refresh that changed nothing is a string compare; the source's `alt` is carried onto the ghost (`role="img"` + `aria-label`) so a graphic that names its icon keeps doing so while its slot is hidden. The ghost displays on `vf-img`'s terms: natural raster size in system px, `image-rendering: pixelated`, one image pixel per system pixel.
|
|
484
|
-
- **Behavior:** clicking selects, Shift/⌘ toggles, and a press
|
|
485
|
+
- **Behavior:** clicking selects, Shift/⌘ toggles, and a press elsewhere clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. For an icon alone, elsewhere is anywhere outside it. For an icon in a field, only a press on another icon or on a field (`vf-icon-field`, or a `role="listbox"` element of the page's own) clears it; a press on the menu bar, a menu, a dialog, a window's title bar or another window leaves the selection, so a menu command still has its icons to act on and a selection survives an application switch. Either way the press calls off a pending rename and a half-finished tap pair. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). **The instrument never changes what the icon means**, so a finger or a pen opens it too, on a *tap pair* the icon classifies from its own pointer events rather than from `dblclick`: two taps on the icon, the second pressed within `TAP_PAIR_MS` (500) of the first and no further than `TAP_PAIR_SLOP_PX` (24 CSS px) from it, `vf-open` firing on the second release. A platform reserves the double-tap for its zoom and may deliver no `dblclick` for one, and a movable icon's press is `preventDefault()`ed for the drag, which can suppress it as well; the pair depends on neither. A tap is a press released where it landed — one that became a drag, or that the platform cancelled, is not half of a pair — and a pair does not chain. The mouse keeps the platform's `dblclick`, which follows the user's own double-click setting; each press takes exactly one of the two routes, so a platform that also delivers a `dblclick` for a double-tap opens the icon once. The frame sets `touch-action: manipulation`, which takes the double-tap zoom off the icon and leaves panning and pinching to the page. Both are additions in the kit's idiom — System 7 had the mouse alone. Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape (the frame is the handle and sets `touch-action: none` while movable, as the title bars do), plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
|
|
485
486
|
- **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step past the **drag slop** — `DRAG_SLOP_PX` (4 CSS px) of travel from the press for the mouse, `DRAG_SLOP_COARSE_PX` (10) for a finger or pen (`dragSlop(event)`, `src/motion.ts`). Inside it the press is a click or a tap: the icon stays on its spot, a rename the press armed stays armed, and no drag event fires, so a click never fires a drop and a tap never nudges an icon off its cell. The delta is measured from the press all the same, so the outline begins under the pointer. CSS px rather than system px, because the slop measures the hand and not the art; `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press; in a box that *scrolls* (a `vf-scroll-area`'s plane, which a window body is under `scrollbars`) only the origin holds, since the rails reach anything placed past it and the Finder put an icon below a window's fold and scrolled to it — `#anchorScrolls`, a flat-tree walk from the icon toward its positioning parent through the slots it is assigned to, true when a scroll area is met before the parent — and whose `preventDefault()` writes nothing, the page re-parenting or placing the icon itself; `vf-drag-cancel` `{}` on Escape or a `pointercancel`, nothing written. `left`/`top` in the detail are the proposal *unclamped* (the origin plus the lattice-snapped delta); only the default action clamps, so a page that cancels gets the raw gesture. The drop is dispatched with the outline gone, the drag state cleared and the pointer capture released, so re-parenting inside the handler is safe (Chromium drops the `click` of a press whose node was re-inserted at `pointerup`; the icon listens for no click and times its double-click on presses).
|
|
486
487
|
- **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the silhouette clipped to the cell as the cell clips the art, the one-pixel erosion by eight `destination-in` draws on a layer the outline's size (the layer is transparent around the art, so its edge erodes as the off-canvas edge did) — the 3×3 box, the region inset `FrameRgn` painted a frame from, rather than the ghost's four-neighbor plus: a 45° edge comes out two pixels wide with both parities of the checker, where a one-pixel diagonal is all one parity and the pen would keep all of it or none, and over the dither "none" is the dither's own ink diagonal — the plate's rectangle filled solid into the same silhouette before the erosion (the Finder's drag region was the mask united with the name's rect, framed once — where the two abut, the outline wraps around both and no edge runs between them; apart, each is ringed on its own), the ring the erosion removes via `destination-out`; compositing only, so cross-origin art derives too. Art the pipeline cannot draw falls back to the cell's rectangle — never a blank. One canvas the size of the frame widened to the plate — a name wider than a declared `width` overhangs the frame on both sides — at one image px per system px; the ring is derived once per drag, and each step re-dots it against a 2×2 checker (`destination-in`) at the pen's phase for where it lands on the *screen* (`penPhase`): ink where `x + y` is odd in screen coordinates, the diagonal the desktop dither (`gray-50`, ink at even) leaves white, so the dots fall between the dither's ink wherever the outline lands, as QuickDraw's screen-anchored pattern pen did — over a white body a dotted black line, over the dither a black line, each dot flipping a white pixel with the dither's own ink filling in between. Painted with the cursor's XOR recipe, `filter: invert(1)` under `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`, sized and placed as live `sysLength()`s so a scale change mid-gesture cannot smear it; its origin is that box's whole-system-px offset from the surface plus the delta. **The surface is the desktop's, not the top layer:** the icon dispatches `vf-drag-surface-request` (bubbles, `composed: false`, `detail.surface` null) on the first step, and the nearest `vf-desktop` on its light-DOM path fills it with its drag surface (§5 vf-desktop); a non-composed event from a slotted icon still travels through the slots it is assigned to, so the handshake works from inside a window body. Without a desktop the canvas is appended to the icon's own `.frame` (the vf-snap box, `position: relative`), translated by the delta alone and clipped by whatever clips the icon — the honest fallback, with the same gesture and events. Not `position: fixed` inside the icon: a `vf-window` is a stacking context, so a fixed outline inside it would paint at the window's z, under every other window (KNOWN-BUGS.md #1 in another costume). One drag, no opt-in: the Finder never moved the icon itself, the drop lands where it always did, and the kit ships no taste knobs. Escape is heard by a document `keydown` listener scoped to the gesture (`DocumentListenersController`, capture phase, the key stopped there so an enclosing `vf-dialog` never reads it as a dismissal) rather than the icon's own handler, because a `movable`-only icon never takes focus — an addition in the kit's idiom (§1), not a claim about the Finder. `DragController` gained the two trailing parameters this needs (`onDrag(x, y, event)`, `onDragEnd(event, cancelled)`) and a `cancel()`; `vf-window` and `vf-dialog` ignore them. A selected icon re-parented at the drop keeps its outside-press listener: it re-attaches on connect, since the controller detaches on disconnect and `updated()` does not re-run for an unchanged property. `npm run verify:icon-drag`.
|
|
487
488
|
- **The selection travels.** A drag that begins on a *selected* icon carries every other selected, movable icon of its field (`closest('vf-icon-field, [role="listbox"]')`, document order) — the Finder outlined the whole selection under a drag on any member. Each follower is joined through `vf-icon.followDrag(surface)` (internal to the group drag): it seeds its own origin, builds its own outline on the surface the leader's handshake found (or in its own frame), and hands back a handle `{ icon, origin, size, move(dx, dy), drop(dx, dy), end() }`. The leader moves every outline by its lattice delta on each step; every event's detail carries the set as `icons`, the leader first (`VfIconDragDetail`). The drop's default action clamps the delta **once for the whole group** — per axis, the tightest of every member's room to its edges, so the arrangement is kept and the group stops when its outermost icon meets the container (a group no edge can hold is held off the near edge only, the single-icon rule; `PlacementController.bounds` is the box, measured at the press) — then writes each member's origin plus that delta through its own `moveTo`. A cancel, or a cancelled drop, takes every outline down and writes nothing for anyone. **The press keeps the selection:** an icon's outside-press listener leaves the selection alone when a plain press lands on an icon already selected in the same field (document capture runs before the pressed icon's own handler, so `selected` reads as the press found it); a click on such an icon, released with no drag, collapses the selection to it on the `pointerup` — so a click still single-selects, and the same press could have dragged them all. A press on an unselected icon makes it the selection first, so it drags alone; a Shift press that deselects the pressed icon drags it alone too; a selected icon in another field, or one that cannot move, stays. `npm run verify:icon-drag` (GROUP).
|
|
@@ -512,7 +513,7 @@ The container a field of `vf-icon`s sits in — a desktop's icons, a folder wind
|
|
|
512
513
|
|
|
513
514
|
## 7. The faux desktop (moved out)
|
|
514
515
|
|
|
515
|
-
The full-viewport System 7 desktop that used to close this spec — menu bar, nine windows, the composed alert, the utility palette, the Finder icons — is no longer part of this repo. It lives in **[aportilla/
|
|
516
|
+
The full-viewport System 7 desktop that used to close this spec — menu bar, nine windows, the composed alert, the utility palette, the Finder icons — is no longer part of this repo. It lives in **[aportilla/system-online](https://github.com/aportilla/system-online)** and consumes `vintage-frames` from npm, so it exercises the same published API a consumer gets rather than reaching into `src/`. Its clause-by-clause spec moved with it, to that repo's `docs/SPEC.md`.
|
|
516
517
|
|
|
517
518
|
What the kit keeps as its own demo surface is `index.html`, the component reference (every element, its API, a live specimen of each state). See [DEVELOPING.md](DEVELOPING.md).
|
|
518
519
|
|