vintage-frames 0.9.0 → 0.10.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/custom-elements.json +211 -5
- package/dist/components/vf-icon-field.d.ts +29 -1
- package/dist/components/vf-icon-field.js +93 -34
- package/dist/components/vf-icon.d.ts +48 -1
- package/dist/components/vf-icon.js +204 -161
- package/dist/components/vf-list.js +2 -13
- package/dist/components/vf-scroll-area.d.ts +4 -4
- package/dist/components/vf-scroll-area.js +3 -7
- package/dist/components/vf-text-area.js +2 -8
- package/dist/grid-snap.d.ts +11 -4
- package/dist/grid-snap.js +6 -8
- package/dist/index.d.ts +3 -2
- package/dist/index.js +42 -42
- package/dist/motion.d.ts +31 -0
- package/dist/motion.js +15 -1
- package/dist/position.d.ts +10 -0
- package/dist/position.js +8 -1
- package/dist/styles/recipes/rule.d.ts +0 -5
- package/docs/SPEC.md +12 -10
- package/editor/vscode.html-custom-data.json +2 -2
- package/editor/web-types.json +3 -3
- package/package.json +1 -1
|
@@ -44,19 +44,8 @@ var x = (e, t) => e.length === t.length && e.every((e, n) => e === t[n]), S = cl
|
|
|
44
44
|
max-height: calc(var(--vf-scale, 1) * var(--vf-list-max-height, 200px));
|
|
45
45
|
/* Native scrolling; the bar itself is hidden by the recipe
|
|
46
46
|
(.vf-scroll) and the reservation is the rail element, not a native
|
|
47
|
-
gutter. The rows run to the rail on the right
|
|
48
|
-
|
|
49
|
-
The mod() padding is border-floor compensation: engines floor a
|
|
50
|
-
fractional border-width to whole CSS px (1.5px renders 1px), which
|
|
51
|
-
would put the rows a fraction of a system px inside the frame at
|
|
52
|
-
scale 1.5 or 4/3 — and the rows are LIGHT-DOM components, so their
|
|
53
|
-
origins are the page's device-pixel-grid contract, not just ours.
|
|
54
|
-
Padding is stored exactly, so border + mod(border's fraction)
|
|
55
|
-
restores the exact 1-system-px inset the borderless-scroller
|
|
56
|
-
construction used to give them, at every scale (mod is 0 at whole
|
|
57
|
-
ones). */
|
|
58
|
-
padding: mod(calc(var(--vf-scale, 1) * 1px), 1px);
|
|
59
|
-
padding-right: 0;
|
|
47
|
+
gutter. The rows run to the rail on the right and start at the
|
|
48
|
+
frame's inner edge. */
|
|
60
49
|
overflow-y: auto;
|
|
61
50
|
}
|
|
62
51
|
.vf-rail--vertical {
|
|
@@ -88,10 +88,10 @@ export declare class VfScrollArea extends VfScrollArea_base {
|
|
|
88
88
|
* A viewport point (CSS px) as a placement on the scrolled plane —
|
|
89
89
|
* `{ left, top }` in whole system px on the placement lattice, the pair a
|
|
90
90
|
* child dropped here is written with. The plane travels with the scroll,
|
|
91
|
-
* so its box already carries the scroll offset
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
91
|
+
* so its box already carries the scroll offset; nothing is left for the
|
|
92
|
+
* caller to add. With `child`, the pair to write to that element: the
|
|
93
|
+
* offset its `origin` adds is folded in, so its box's corner lands on the
|
|
94
|
+
* point.
|
|
95
95
|
*/
|
|
96
96
|
placementAt(clientX: number, clientY: number, child?: Element): {
|
|
97
97
|
left: number;
|
|
@@ -74,16 +74,12 @@ var y = class extends r(f) {
|
|
|
74
74
|
recipe (.vf-scroll) and the reservation is the rail element, not a
|
|
75
75
|
native gutter. The unreserved axis still scrolls, railless.
|
|
76
76
|
|
|
77
|
-
No inset of its own
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
the (0,0) of placed children) sits exactly one system px from the
|
|
81
|
-
frame box at every scale (mod is 0 at whole scales). An inset is
|
|
82
|
-
the content's (a vf-stack pad). */
|
|
77
|
+
No inset of its own: slotted content (and the (0,0) of placed
|
|
78
|
+
children) starts at the frame's inner edge, one system px from the
|
|
79
|
+
frame box. An inset is the content's (a vf-stack pad). */
|
|
83
80
|
overflow: auto;
|
|
84
81
|
min-width: 0;
|
|
85
82
|
min-height: 0;
|
|
86
|
-
padding: mod(var(--vf-scale, 1) * 1px, 1px);
|
|
87
83
|
}
|
|
88
84
|
/* A single-axis rail with a reserved corner: the viewport spans the
|
|
89
85
|
track the missing rail would have taken, up to the corner cell, so
|
|
@@ -57,15 +57,9 @@ var g = class extends n(l) {
|
|
|
57
57
|
min-width: 0;
|
|
58
58
|
/* Borderless — the frame is the well's (above). vf-text-field's own
|
|
59
59
|
3px/6px padding plus that border holds the text exactly where the
|
|
60
|
-
bordered field puts it
|
|
61
|
-
compensation (engines floor the fractional border to whole CSS px;
|
|
62
|
-
this is exactly what they floored away), keeping the text on the
|
|
63
|
-
4px/7px system-px inset from the frame box at every scale. */
|
|
60
|
+
bordered field puts it, 4px/7px from the frame box. */
|
|
64
61
|
border: 0;
|
|
65
|
-
padding: calc(
|
|
66
|
-
var(--vf-scale, 1) * 3px + mod(var(--vf-scale, 1) * 1px, 1px)
|
|
67
|
-
)
|
|
68
|
-
calc(var(--vf-scale, 1) * 6px + mod(var(--vf-scale, 1) * 1px, 1px));
|
|
62
|
+
padding: calc(var(--vf-scale, 1) * 3px) calc(var(--vf-scale, 1) * 6px);
|
|
69
63
|
/* Wrapped entry copy on the display face's native line (editable
|
|
70
64
|
text is display type) — the same face token the static-text
|
|
71
65
|
components read, so a display retheme moves this well too. The
|
package/dist/grid-snap.d.ts
CHANGED
|
@@ -34,10 +34,17 @@ export declare class GridSnapController implements ReactiveController {
|
|
|
34
34
|
* roots consuming the variables sit at *authored* offsets inside the host —
|
|
35
35
|
* including deliberate half-pixel ones like a toggle's centered box — and
|
|
36
36
|
* measuring one of them would make the controller "correct" the component's
|
|
37
|
-
* own design.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* own design.
|
|
38
|
+
*
|
|
39
|
+
* The correction is a function of the host's position alone: the offset
|
|
40
|
+
* from its origin to the nearest whole device pixel. The host never moves
|
|
41
|
+
* from its own correction, so the correction already applied only decides
|
|
42
|
+
* whether a write is needed. Derived from the painted position instead —
|
|
43
|
+
* host plus the applied offset — every move would be rounded from wherever
|
|
44
|
+
* the previous correction left the paint, keeping each move's rounding
|
|
45
|
+
* error: a host carried along by an animation, or shifted half a pixel a
|
|
46
|
+
* few times, painted whole device pixels outside its box. An ancestor's
|
|
47
|
+
* correction, which does move this host, is picked up the normal way.
|
|
41
48
|
*/
|
|
42
49
|
correct(dpr: number): void;
|
|
43
50
|
/** Drop the correction: delete the two variables and forget everything. */
|
package/dist/grid-snap.js
CHANGED
|
@@ -73,15 +73,13 @@ var n = .05, r = 1 / 64, i = () => e() || 1, a = (e, t) => {
|
|
|
73
73
|
x: "",
|
|
74
74
|
y: ""
|
|
75
75
|
});
|
|
76
|
-
let s =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
x: o(this.applied.x - s / e),
|
|
80
|
-
y: o(this.applied.y - c / e)
|
|
76
|
+
let s = {
|
|
77
|
+
x: o(-a(r.left, e) / e),
|
|
78
|
+
y: o(-a(r.top, e) / e)
|
|
81
79
|
};
|
|
82
|
-
(
|
|
83
|
-
x: `${
|
|
84
|
-
y: `${
|
|
80
|
+
Math.abs(s.x - this.applied.x) * e <= n && Math.abs(s.y - this.applied.y) * e <= n || (this.applied = s, this.written = {
|
|
81
|
+
x: `${s.x}px`,
|
|
82
|
+
y: `${s.y}px`
|
|
85
83
|
}, i.setProperty("--vf-snap-dx", this.written.x), i.setProperty("--vf-snap-dy", this.written.y));
|
|
86
84
|
}
|
|
87
85
|
reset() {
|
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export { VfImg } from './components/vf-img.js';
|
|
|
33
33
|
export { VfIcon } from './components/vf-icon.js';
|
|
34
34
|
export type { VfIconSize, VfIconDragDetail, VfIconDragFollower, } from './components/vf-icon.js';
|
|
35
35
|
export { VfIconField } from './components/vf-icon-field.js';
|
|
36
|
+
export type { VfIconMove } from './components/vf-icon-field.js';
|
|
36
37
|
export { VfMenuBar } from './components/vf-menu-bar.js';
|
|
37
38
|
export { VfMenu } from './components/vf-menu.js';
|
|
38
39
|
export { VfMenuItem } from './components/vf-menu-item.js';
|
|
@@ -141,8 +142,8 @@ export type { DocumentListenerSpec } from './document-listeners.js';
|
|
|
141
142
|
export { focusModality, trackFocusModality, FocusRuleController } from './focus-modality.js';
|
|
142
143
|
export type { FocusModality } from './focus-modality.js';
|
|
143
144
|
export { emit } from './events.js';
|
|
144
|
-
export { prefersReducedMotion, runSelectionBlink, BLINK_INTERVAL_MS, BLINK_FLIPS, PRESS_HOLD_MS, RENAME_DELAY_MS, } from './motion.js';
|
|
145
|
-
export type { BlinkHandle } from './motion.js';
|
|
145
|
+
export { prefersReducedMotion, runSelectionBlink, runOutlineTravel, BLINK_INTERVAL_MS, BLINK_FLIPS, PRESS_HOLD_MS, RENAME_DELAY_MS, WALK_STEP_PX, WALK_STEP_MS, WALK_BEAT_MS, } from './motion.js';
|
|
146
|
+
export type { BlinkHandle, TravelHandle } from './motion.js';
|
|
146
147
|
/**
|
|
147
148
|
* Element registration. `vfElement` is the kit's `@customElement`: it registers
|
|
148
149
|
* through `defineElement`, which skips (and warns) rather than throwing when a
|
package/dist/index.js
CHANGED
|
@@ -31,45 +31,45 @@ import { VfDesktop as Fe } from "./components/vf-desktop.js";
|
|
|
31
31
|
import { VfSized as Ie } from "./size.js";
|
|
32
32
|
import { DragController as Le } from "./drag.js";
|
|
33
33
|
import { ScrollStateController as Re } from "./scroll-state.js";
|
|
34
|
-
import { BLINK_FLIPS as ze, BLINK_INTERVAL_MS as Be, PRESS_HOLD_MS as Ve, RENAME_DELAY_MS as He,
|
|
35
|
-
import { CARET_DOWN as
|
|
36
|
-
import { ScrollRailController as
|
|
37
|
-
import { VfScrollArea as
|
|
38
|
-
import { VfWindow as
|
|
39
|
-
import { VfModalDialog as
|
|
40
|
-
import { VfDialog as
|
|
41
|
-
import { VfSeparator as
|
|
42
|
-
import { VfFormControl as
|
|
43
|
-
import { VfButton as
|
|
44
|
-
import { VfButtonGroup as
|
|
45
|
-
import { VfSwatch as
|
|
46
|
-
import { VfToggleControl as
|
|
47
|
-
import { VfCheckbox as
|
|
48
|
-
import { VfRadio as
|
|
49
|
-
import { VfRadioGroup as
|
|
50
|
-
import { FocusRuleController as
|
|
51
|
-
import { VfTextControlBase as
|
|
52
|
-
import { VfTextField as
|
|
53
|
-
import { VfTextArea as
|
|
54
|
-
import { VfNumberField as
|
|
55
|
-
import { VfOption as
|
|
56
|
-
import { VfSelect as
|
|
57
|
-
import { VfProgressBar as
|
|
58
|
-
import { VfSlider as
|
|
59
|
-
import { VfLabel as
|
|
60
|
-
import { VfParagraph as
|
|
61
|
-
import { VfImg as
|
|
62
|
-
import { VfIcon as
|
|
63
|
-
import { VfIconField as
|
|
64
|
-
import { VfMenuBar as
|
|
65
|
-
import { VfMenu as
|
|
66
|
-
import { VfMenuItem as
|
|
67
|
-
import { VfList as
|
|
68
|
-
import { VfListItem as
|
|
69
|
-
import { VfFieldset as
|
|
70
|
-
import { VfGrid as
|
|
71
|
-
import { VfStack as
|
|
72
|
-
import { VfContainer as
|
|
73
|
-
import { CURSOR_ARROW as
|
|
74
|
-
import { applyCursor as
|
|
75
|
-
export { ze as BLINK_FLIPS, Be as BLINK_INTERVAL_MS, Ee as BUTTON_FACE, De as BUTTON_FRAME,
|
|
34
|
+
import { BLINK_FLIPS as ze, BLINK_INTERVAL_MS as Be, PRESS_HOLD_MS as Ve, RENAME_DELAY_MS as He, WALK_BEAT_MS as Ue, WALK_STEP_MS as We, WALK_STEP_PX as Ge, prefersReducedMotion as Ke, runOutlineTravel as qe, runSelectionBlink as Je } from "./motion.js";
|
|
35
|
+
import { CARET_DOWN as Ye, CHECKBOX_X as Xe, CHECKMARK as Ze, RADIO_DOT as Qe, RADIO_FACE as $e, RADIO_RING as et, RADIO_RING_PRESSED as tt, SLIDER_THUMB as nt, SLIDER_THUMB_FACE as rt, STEPPER as it, STEPPER_DOWN_FILL as at, STEPPER_UP_FILL as ot, glyphSvg as st } from "./glyphs.js";
|
|
36
|
+
import { ScrollRailController as ct, renderScrollRail as lt } from "./scroll-rail.js";
|
|
37
|
+
import { VfScrollArea as ut } from "./components/vf-scroll-area.js";
|
|
38
|
+
import { VfWindow as dt } from "./components/vf-window.js";
|
|
39
|
+
import { VfModalDialog as ft, modalDialogStyles as pt } from "./modal-dialog.js";
|
|
40
|
+
import { VfDialog as mt } from "./components/vf-dialog.js";
|
|
41
|
+
import { VfSeparator as ht } from "./components/vf-separator.js";
|
|
42
|
+
import { VfFormControl as gt } from "./form-control.js";
|
|
43
|
+
import { VfButton as _t } from "./components/vf-button.js";
|
|
44
|
+
import { VfButtonGroup as vt } from "./components/vf-button-group.js";
|
|
45
|
+
import { VfSwatch as yt } from "./components/vf-swatch.js";
|
|
46
|
+
import { VfToggleControl as bt } from "./toggle-control.js";
|
|
47
|
+
import { VfCheckbox as xt } from "./components/vf-checkbox.js";
|
|
48
|
+
import { VfRadio as St } from "./components/vf-radio.js";
|
|
49
|
+
import { VfRadioGroup as Ct } from "./components/vf-radio-group.js";
|
|
50
|
+
import { FocusRuleController as wt, focusModality as Tt, trackFocusModality as Et } from "./focus-modality.js";
|
|
51
|
+
import { VfTextControlBase as Dt } from "./text-control.js";
|
|
52
|
+
import { VfTextField as Ot } from "./components/vf-text-field.js";
|
|
53
|
+
import { VfTextArea as kt } from "./components/vf-text-area.js";
|
|
54
|
+
import { VfNumberField as At } from "./components/vf-number-field.js";
|
|
55
|
+
import { VfOption as jt } from "./components/vf-option.js";
|
|
56
|
+
import { VfSelect as Mt } from "./components/vf-select.js";
|
|
57
|
+
import { VfProgressBar as Nt } from "./components/vf-progress-bar.js";
|
|
58
|
+
import { VfSlider as Pt } from "./components/vf-slider.js";
|
|
59
|
+
import { VfLabel as Ft } from "./components/vf-label.js";
|
|
60
|
+
import { VfParagraph as It } from "./components/vf-paragraph.js";
|
|
61
|
+
import { VfImg as Lt } from "./components/vf-img.js";
|
|
62
|
+
import { VfIcon as Rt } from "./components/vf-icon.js";
|
|
63
|
+
import { VfIconField as zt } from "./components/vf-icon-field.js";
|
|
64
|
+
import { VfMenuBar as Bt } from "./components/vf-menu-bar.js";
|
|
65
|
+
import { VfMenu as Vt } from "./components/vf-menu.js";
|
|
66
|
+
import { VfMenuItem as Ht } from "./components/vf-menu-item.js";
|
|
67
|
+
import { VfList as Ut } from "./components/vf-list.js";
|
|
68
|
+
import { VfListItem as Wt } from "./components/vf-list-item.js";
|
|
69
|
+
import { VfFieldset as Gt } from "./components/vf-fieldset.js";
|
|
70
|
+
import { VfGrid as Kt } from "./components/vf-grid.js";
|
|
71
|
+
import { VfStack as qt } from "./components/vf-stack.js";
|
|
72
|
+
import { VfContainer as Jt } from "./components/vf-container.js";
|
|
73
|
+
import { CURSOR_ARROW as Yt, CURSOR_CROSSHAIR as Xt, CURSOR_I_BEAM as Zt, CURSOR_WAIT as Qt } from "./cursor-art.js";
|
|
74
|
+
import { applyCursor as $t } from "./cursor.js";
|
|
75
|
+
export { ze as BLINK_FLIPS, Be as BLINK_INTERVAL_MS, Ee as BUTTON_FACE, De as BUTTON_FRAME, Ye as CARET_DOWN, Xe as CHECKBOX_X, Ze as CHECKMARK, r as CLASSIC_DPI, i as CSS_REFERENCE_DPI, Yt as CURSOR_ARROW, Xt as CURSOR_CROSSHAIR, Zt as CURSOR_I_BEAM, Qt as CURSOR_WAIT, Te as DocumentListenersController, Le as DragController, wt as FocusRuleController, Ce as GridSnapController, G as PATTERNS, K as PATTERN_NAMES, q as PATTERN_SIZE, D as PIXEL_GRID_METRICS, Ve as PRESS_HOLD_MS, xe as PatternFillController, T as PlacementController, Qe as RADIO_DOT, $e as RADIO_FACE, et as RADIO_RING, tt as RADIO_RING_PRESSED, He as RENAME_DELAY_MS, Oe as RING_FRAME, ke as RING_HOLE, Ae as RING_INSET, ie as RULE_EDGES, je as SCREEN_CORNER, nt as SLIDER_THUMB, rt as SLIDER_THUMB_FACE, it as STEPPER, at as STEPPER_DOWN_FILL, ot as STEPPER_UP_FILL, a as SYSTEM_PX_IN_CSS_PX, d as ScaleController, ct as ScrollRailController, Re as ScrollStateController, R as TILE_LATTICE, ge as TileRasterCache, be as TrackWidthController, k as VF_BODY_FAMILY, M as VF_DISPLAY_FAMILY, _t as VfButton, vt as VfButtonGroup, xt as VfCheckbox, Jt as VfContainer, Fe as VfDesktop, mt as VfDialog, Gt as VfFieldset, gt as VfFormControl, Kt as VfGrid, Rt as VfIcon, zt as VfIconField, Lt as VfImg, Ft as VfLabel, Ut as VfList, Wt as VfListItem, Vt as VfMenu, Bt as VfMenuBar, Ht as VfMenuItem, ft as VfModalDialog, At as VfNumberField, jt as VfOption, It as VfParagraph, E as VfPositioned, Nt as VfProgressBar, St as VfRadio, Ct as VfRadioGroup, ut as VfScrollArea, Mt as VfSelect, ht as VfSeparator, Ie as VfSized, Pt as VfSlider, qt as VfStack, yt as VfSwatch, kt as VfTextArea, Dt as VfTextControlBase, Ot as VfTextField, bt as VfToggleControl, dt as VfWindow, Ue as WALK_BEAT_MS, We as WALK_STEP_MS, Ge as WALK_STEP_PX, $t as applyCursor, f as applyScale, t as defineElement, o as devicePxPerSystemPx, p as effectiveScale, e as emit, Tt as focusModality, m as getScale, s as getZoom, st as glyphSvg, pt as modalDialogStyles, h as onScaleChange, c as onZoomChange, J as parsePattern, ae as parseRule, Y as patternHex, X as patternMotif, _e as patternOverride, Z as patternRects, Ke as prefersReducedMotion, A as registerBodyFace, N as registerDisplayFace, O as registerEmbeddedFont, lt as renderScrollRail, we as requestGridSnap, l as resetZoomBaseline, oe as ruleClasses, qe as runOutlineTravel, Je as runSelectionBlink, g as snapSys, _ as snapToDevicePx, v as snapToSystemPx, Me as steppedCornerClip, Ne as steppedRectClip, Pe as steppedRingClip, y as sys, b as sysLength, x as sysLengths, S as systemPxQuantum, ve as tileGrid, z as tileImage, B as tileRaster, V as tileRects, H as tileSpan, C as toSys, w as toSysExact, Et as trackFocusModality, u as truePixelRatio, I as vfBase, j as vfBodyDecls, te as vfChromeFrame, P as vfDisplay, F as vfDisplayDecls, Q as vfDots, n as vfElement, me as vfField, ue as vfFocus, de as vfFocusRing, fe as vfFocusUnderline, ee as vfHardShadowDecls, ne as vfModalFrame, re as vfPanel, Se as vfPatternFill, se as vfRule, he as vfScrollRail, L as vfStaticText, $ as vfStripes, ye as vfTileGrid, U as vfTileMaskSize, W as vfTileSize, ce as vfTitleBar, pe as vfToggle, le as vfWindowWidgets };
|
package/dist/motion.d.ts
CHANGED
|
@@ -70,3 +70,34 @@ export interface BlinkHandle {
|
|
|
70
70
|
* handle. `setPhase` is never called after `onDone()`.
|
|
71
71
|
*/
|
|
72
72
|
export declare function runSelectionBlink(setPhase: (on: boolean) => void, onDone: () => void): BlinkHandle;
|
|
73
|
+
/**
|
|
74
|
+
* The walk: an icon's drag outline travelling to where a page sends it
|
|
75
|
+
* (`vf-icon.dragTo`), the Finder's own Clean Up. System px the outline
|
|
76
|
+
* advances per step, milliseconds between steps, and the beat after one
|
|
77
|
+
* icon lands before the next of a field sets off (`vf-icon-field.dragIcons`).
|
|
78
|
+
*
|
|
79
|
+
* Constant speed rather than a fixed frame count: the walk is the drag's
|
|
80
|
+
* outline moved by the kit instead of the hand, and most of a Clean Up's
|
|
81
|
+
* moves are short — a fixed count would crawl a one-pixel correction across
|
|
82
|
+
* as many frames as a trip across the screen. Set by eye; nothing in the
|
|
83
|
+
* verify suite asserts them.
|
|
84
|
+
*/
|
|
85
|
+
export declare const WALK_STEP_PX = 16;
|
|
86
|
+
export declare const WALK_STEP_MS = 25;
|
|
87
|
+
export declare const WALK_BEAT_MS = 50;
|
|
88
|
+
/** Handle for an in-flight {@link runOutlineTravel}; {@link finish} lands it now. */
|
|
89
|
+
export interface TravelHandle {
|
|
90
|
+
finish(): void;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The walk's cadence. `setProgress(t)` is called once per step with the
|
|
94
|
+
* fraction of `distance` (system px) covered — whole steps of
|
|
95
|
+
* {@link WALK_STEP_PX}, the last exactly 1 — and one interval after the last
|
|
96
|
+
* step `onDone()`, so the outline rests where it arrived before the icon
|
|
97
|
+
* lands. A timer chain at {@link WALK_STEP_MS}, not `requestAnimationFrame`,
|
|
98
|
+
* so a 120 Hz display does not double the speed. `finish()` clears the
|
|
99
|
+
* timer and calls `onDone()` at once — a walk is finished, never cancelled.
|
|
100
|
+
* `onDone` runs exactly once, and never before this returns except under
|
|
101
|
+
* `prefers-reduced-motion`, where it runs at once and the handle is a no-op.
|
|
102
|
+
*/
|
|
103
|
+
export declare function runOutlineTravel(distance: number, setProgress: (t: number) => void, onDone: () => void): TravelHandle;
|
package/dist/motion.js
CHANGED
|
@@ -18,5 +18,19 @@ function a(t, n) {
|
|
|
18
18
|
window.clearInterval(i);
|
|
19
19
|
} };
|
|
20
20
|
}
|
|
21
|
+
var o = 16, s = 25, c = 50;
|
|
22
|
+
function l(t, n, r) {
|
|
23
|
+
if (e()) return r(), { finish() {} };
|
|
24
|
+
let i = Math.max(1, Math.ceil(t / 16)), a = 0, o = !1, s = 0, c = () => {
|
|
25
|
+
o || (o = !0, window.clearTimeout(s), r());
|
|
26
|
+
}, l = () => {
|
|
27
|
+
if (a >= i) {
|
|
28
|
+
c();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
a += 1, n(a / i), s = window.setTimeout(l, 25);
|
|
32
|
+
};
|
|
33
|
+
return s = window.setTimeout(l, 25), { finish: c };
|
|
34
|
+
}
|
|
21
35
|
//#endregion
|
|
22
|
-
export { i as BLINK_FLIPS, r as BLINK_INTERVAL_MS, t as PRESS_HOLD_MS, n as RENAME_DELAY_MS, e as prefersReducedMotion, a as runSelectionBlink };
|
|
36
|
+
export { i as BLINK_FLIPS, r as BLINK_INTERVAL_MS, t as PRESS_HOLD_MS, n as RENAME_DELAY_MS, c as WALK_BEAT_MS, s as WALK_STEP_MS, o as WALK_STEP_PX, e as prefersReducedMotion, l as runOutlineTravel, a as runSelectionBlink };
|
package/dist/position.d.ts
CHANGED
|
@@ -272,6 +272,16 @@ export declare class PlacementController {
|
|
|
272
272
|
* and a dragged title stays centered on wherever it was dropped.
|
|
273
273
|
*/
|
|
274
274
|
moveTo(x: number, y: number): void;
|
|
275
|
+
/**
|
|
276
|
+
* The pair {@link moveTo} would write for a requested origin — clamped by
|
|
277
|
+
* the host's own rule against the box {@link seed} measured and snapped
|
|
278
|
+
* onto the lattice — without writing it. A move that shows where it is
|
|
279
|
+
* going before it goes (`vf-icon.dragTo`) reads its landing here first.
|
|
280
|
+
*/
|
|
281
|
+
resolve(x: number, y: number): {
|
|
282
|
+
x: number;
|
|
283
|
+
y: number;
|
|
284
|
+
};
|
|
275
285
|
}
|
|
276
286
|
/**
|
|
277
287
|
* The movable contract, as a one-time console warning.
|
package/dist/position.js
CHANGED
|
@@ -182,8 +182,15 @@ var _ = class {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
moveTo(e, t) {
|
|
185
|
+
let { x: n, y: r } = this.resolve(e, t);
|
|
186
|
+
this.#e.left = n, this.#e.top = r, this.#n = !0;
|
|
187
|
+
}
|
|
188
|
+
resolve(e, t) {
|
|
185
189
|
let r = this.#e, { x: i, y: a } = this.offset, o = this.#t(e - i, t - a, this.#r ?? this.#i());
|
|
186
|
-
|
|
190
|
+
return {
|
|
191
|
+
x: n(o.x + i, r),
|
|
192
|
+
y: n(o.y + a, r)
|
|
193
|
+
};
|
|
187
194
|
}
|
|
188
195
|
#i() {
|
|
189
196
|
let e = this.#e, t = e.offsetParent, n = t && t.clientWidth > 0 && t.clientHeight > 0 ? t : null;
|
|
@@ -19,11 +19,6 @@ export type RuleEdge = (typeof RULE_EDGES)[number];
|
|
|
19
19
|
* One declaration per edge, so the kit cannot grow two rules — the
|
|
20
20
|
* {@link vfHardShadowDecls} principle, applied to the line.
|
|
21
21
|
*
|
|
22
|
-
* Being a border, it shares the kit's border-floor residual
|
|
23
|
-
* (docs/THREE-X-DISPLAYS.md): Chromium floors a fractional border-width to
|
|
24
|
-
* whole CSS px, so above 1× the line paints thinner than a system px (2
|
|
25
|
-
* device px of 3 at 2×) and the padding box begins that much inside — the
|
|
26
|
-
* same as every kit frame, and the same line the menu bar always drew.
|
|
27
22
|
* `npm run verify:rule` asserts a container's rule against the menu bar's.
|
|
28
23
|
*/
|
|
29
24
|
export declare const vfRule: import("lit").CSSResult;
|
package/docs/SPEC.md
CHANGED
|
@@ -13,7 +13,7 @@ The System 7 look, distilled:
|
|
|
13
13
|
- **Racing-stripe title bars** — 6 horizontal 1px black pinstripes on the white title bar; the title text and window widgets sit on solid white patches that interrupt the stripes.
|
|
14
14
|
- **Chicago-style type** — bold, dark, tight. One size for almost everything.
|
|
15
15
|
- **1-bit monochrome palette** — black and white only, plus two grays: light gray (`#C0C0C0`, the kit's dim tone) for dimmed/disabled chrome, and mid gray (`#808080`) as the base tone under the desktop's 50% dither. Scroll troughs use a looser 25% black-on-white dot dither. Surfaces are flat solid white — no bevels.
|
|
16
|
-
- **No gradients, no border-radius, no CSS transitions** — interactions are instant. The
|
|
16
|
+
- **No gradients, no border-radius, no CSS transitions** — interactions are instant. The sanctioned animations are the classic menu-item "blink" on selection, the indeterminate progress stripes, and the walk — an icon's drag outline travelling to where a page sends it (`vf-icon.dragTo`, `vf-icon-field.dragIcons`), the Finder's own Clean Up. All three are suppressed under `prefers-reduced-motion: reduce` (the blink selects immediately, the walk lands immediately). Even the button's rounded corners are not `border-radius` arcs: they are stepped `clip-path` silhouettes traced pixel-for-pixel from the reference sheet (`src/pixel-frame.ts`), so the 1-bit staircase renders with no antialiasing.
|
|
17
17
|
- **Selection inverts** — selected/active states are white-on-black inversion, not tinted highlights.
|
|
18
18
|
|
|
19
19
|
Modern requirements that we deliberately keep (accessibility over purity):
|
|
@@ -148,7 +148,7 @@ The span construction stays load-bearing for the CSS-repeated underlays beneath
|
|
|
148
148
|
- `vfHardShadowDecls` — the hard 1-bit drop shadow on its own, for composing into a surface that supplies its own border: `box-shadow: var(--vf-shadow-offset, 2px) var(--vf-shadow-offset, 2px) 0 0 var(--vf-black, #000)`. No blur, no spread — System 7's only depth cue. Every raised surface in the kit composes this one declaration (`vfPanel`, `vfChromeFrame`; `vf-swatch` composes it without either surface class).
|
|
149
149
|
- `vfPanel` — a `.vf-panel` class for menus/popups: white bg, `border: 1px solid var(--vf-black, #000)`, `vfHardShadowDecls`.
|
|
150
150
|
- `vfChromeFrame` — a `.vf-frame` class for the desktop window (`vf-window`): the same white face, 1px black border and hard offset shadow as `vfPanel`, no bevels. Skin only — the component supplies its own layout (a full-size flex column). `vf-dialog` does not use it: both of its chromes are `vfModalFrame`.
|
|
151
|
-
- `vfRule` — the 1px rule on one edge of a box, one class per edge: `.vf-rule-top`, `.vf-rule-right`, `.vf-rule-bottom`, `.vf-rule-left`, each `border-<edge>: 1px solid var(--vf-black, #000)` scaled by `--vf-scale`. A border, so it sits inside the box's `border-box` size and insets the padding box — content, percentage fills and children placed against the box begin inside the rule, as a rectangle's interior begins inside FrameRect's line. One declaration per edge, the `vfHardShadowDecls` principle applied to the line: `vf-menu-bar`'s floor and `vf-window`'s status-strip ceiling are these classes, and `vf-container rule="…"` takes the edges by name. The attribute grammar ships beside it (`RULE_EDGES`, `parseRule`, `ruleClasses`): edge names separated by whitespace in any order, repeats dropped, returned in shorthand order; a blank or unset value is no edges; any other token refuses the whole value, as `parsePattern` refuses an unrecognized pattern.
|
|
151
|
+
- `vfRule` — the 1px rule on one edge of a box, one class per edge: `.vf-rule-top`, `.vf-rule-right`, `.vf-rule-bottom`, `.vf-rule-left`, each `border-<edge>: 1px solid var(--vf-black, #000)` scaled by `--vf-scale`. A border, so it sits inside the box's `border-box` size and insets the padding box — content, percentage fills and children placed against the box begin inside the rule, as a rectangle's interior begins inside FrameRect's line. One declaration per edge, the `vfHardShadowDecls` principle applied to the line: `vf-menu-bar`'s floor and `vf-window`'s status-strip ceiling are these classes, and `vf-container rule="…"` takes the edges by name. The attribute grammar ships beside it (`RULE_EDGES`, `parseRule`, `ruleClasses`): edge names separated by whitespace in any order, repeats dropped, returned in shorthand order; a blank or unset value is no edges; any other token refuses the whole value, as `parsePattern` refuses an unrecognized pattern. `npm run verify:rule` asserts a container's rule against the menu bar's own at four densities.
|
|
152
152
|
- `vfModalFrame` — the dBoxProc modal-dialog frame, both `vf-dialog` chromes: `.vf-modal-frame` (white face, 1px black border, **no shadow**) around `.vf-modal-frame-inner` (`margin: 2px; border: 2px solid`). **The movable modal is this frame with the striped bar set into it** (traced from a 2× System 7 capture): a `.vf-title-bar` as the frame's first child sits directly under the outer rule, `margin: 0 2px` so its stripes — flush to the bar's own edge here, `inset: 3px 0` — keep the same 2px of white from the outer rule the band keeps; the inner box that follows it drops its top margin and thins its top border to 1px, so the bar's own 1px rule plus that border make the 2px band the bar's floor, continuing down the sides and along the bottom. System 7's alert box drew the mirror trace — 2px outer, 2px gap, 1px inner rule, *with* the hard shadow (`Windows/modal dialog.png` vs the alert reference) — a different chrome the kit does not ship: an alert box is a consumer composition over this frame, not a component.
|
|
153
153
|
- `vfWindowWidgets` — the title-bar window widgets (`.box` base, `.close` left / `.zoom` right at 11×11 with the 1px white patch ring, the pressed go-away sunburst, the nested zoom square), shared by `vf-window` and a `closable` `vf-dialog` so the two components' widgets match by construction. The templates that pair with it (`closeBox`/`zoomBox`/`widgetLabel`) live in `src/chrome.ts` with `chromeTitleBar`. Geometry is the standard bar's; `vf-window`'s utility variant overrides sizes under its own selector.
|
|
154
154
|
- `vfTitleBar` — the striped title bar shared by `vf-window` and `vf-dialog`: a `.vf-title-bar` row (`height: var(--vf-titlebar-height, 18px)`, 1px bottom rule, `overflow: hidden`) and the `.vf-title` patch inside it (display face, white bg, `padding: 0 6px`, `z-index: 1` over the stripes, `nowrap` + ellipsis). Put a `.vf-stripes` layer in as the bar's first child.
|
|
@@ -219,11 +219,11 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
|
|
|
219
219
|
- 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.
|
|
220
220
|
- Header (`header` slot): a band between the title bar and the body, the full width of the window — the Finder window's header line — a white interior over a 1px black rule (`vfRule`'s `.vf-rule-bottom`), part `header` (a `<div>`, never a `<header>` element, for the banner-landmark reason the title bar is a `<div>`). Like the body it carries no inset and is a positioning anchor: (0,0) is the header's own corner, flow content starts there, an inset is the content's (a `vf-stack pad`). As tall as its content unless `header-height` states it (rule included, the way every kit bar counts its rule). Clipped like the body; a drop-open panel still escapes. Takes **no space** until the slot is populated, and collapses again when it empties. Composes with `scrollbars`: the edge rails sit in the body below it, the area's top overhang landing its frame line exactly on the header's rule, so the header spans the vertical rail's column and the rail's top arrow begins under it.
|
|
221
221
|
- Status bar (`status` slot): the classic bottom readout strip ("40px x 40px") — a 1px black rule (`vfRule`'s `.vf-rule-top`) over a white interior, `--vf-status-bar-height` (15px) in all, the grow box's own height, so a `resizable` window's grow box sits flush in the strip's right end (its top/left borders take over the rule there; the strip reserves 21px of right padding under `resizable` so text clears the cell). Slotted content rides the body face's native 12px line (`--vf-line-height`), whole-pixel centered in the 14px interior, `padding-inline: 6px`, nowrap + clipped. Takes **no space** until the slot is populated, 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.
|
|
222
|
-
- Edge scroll rails (if `scrollbars`): the body slot renders inside a shadow `vf-scroll-area` (its `axis` = the attribute's value, `label` = the heading, `viewport` part re-exported) carrying the TeachText composition internally — `calc(100% + 2px·scale)` with `margin: -1px·scale`, one system px under the frame on every side, so the area's own frame border repaints the window's border lines and a `resizable` window's grow box (z-index 1) lands exactly over the rail-corner cell. the area's viewport carries no inset
|
|
222
|
+
- Edge scroll rails (if `scrollbars`): the body slot renders inside a shadow `vf-scroll-area` (its `axis` = the attribute's value, `label` = the heading, `viewport` part re-exported) carrying the TeachText composition internally — `calc(100% + 2px·scale)` with `margin: -1px·scale`, one system px under the frame on every side, so the area's own frame border repaints the window's border lines and a `resizable` window's grow box (z-index 1) lands exactly over the rail-corner cell. the area's viewport carries no inset, so content and the (0,0) of placed children sit at the content region's corner; a `resizable` window sets the area's `corner`, so a single-axis rail stops at the corner cell too — unless the status strip is populated, when the grow box sits in the strip and the rail runs edge to edge onto the strip's rule. Same caveats as the slotted composition (see vf-scroll-area §5).
|
|
223
223
|
- Utility variant (`variant="utility"`): the slim windoid bar traced from `Windows/utility-window.png` — `--vf-titlebar-height-utility` (12px = 11px interior + 1px rule), the `vfDots` dither instead of stripes (flush to the side borders — see §4 vfDots), 7×7 widgets (`top: 2px`; close `left: 7px`, zoom `right: 8px` — the art is asymmetric by that pixel) with a 2px patch ring where the striped bar's is 1px (`--vf-widget-ring`, internal geometry: the windoid sheet clears two px of dither beside its widgets), and the nested zoom square shrunk so its edges land at sprite col/row 3. No title patch: the display face's 16px line box can't sit in an 11px interior, so `.vf-title` is `display: none` under the variant (a retheming consumer can re-show it via `::part(title)`) and the heading names the widgets. A pressed windoid widget inverts whole — black interior under a white (invisible) borderline — rather than flashing the big bar's 9×9 sunburst, which can't land on a 5×5 interior.
|
|
224
224
|
- **A11y:** the close/zoom `aria-label`s are qualified by the title when there is one (`Close ${heading}` / `Zoom ${heading}`, falling back to bare `Close` / `Zoom`) — several windows are open at once by design, so a bare repeated "Close" gives an AT user no way to tell which window a widget belongs to. The frame is `role="group"`, named by the title patch via `aria-labelledby` when there is a heading (the utility variant's hidden patch still names it — AccName resolves hidden labelledby targets) — `group` rather than `region` deliberately, so a desktop of windows doesn't pollute landmark navigation; the title bar is a `<div>`, never a `<header>`, which would map to an unnamed `banner` landmark even inside the shadow root. An inactive window's widgets stay in the tree and the tab order but paint no ink (transparent border/background/patch ring — the bare System 7 bar): a background window whose body holds nothing focusable is still reachable, activated by `vf-desktop`'s `focusin` raise the moment Tab lands on a widget, and never drops focus to `<body>` when it deactivates. `npm run verify:window-a11y`.
|
|
225
225
|
- **Behavior:** close box click → `vf-close` (does NOT remove itself; consumer decides). Zoom box click → `vf-zoom`. If `movable`: dragging the title bar moves the window — the drag seeds its origin from the current offset position (once, converting to system px) and then states `left`/`top` via pointer capture, in whole system px like any authored placement. If `resizable`: dragging the grow box states `width`/`height` the same way, each axis clamped into the sizeRect (`min-width`/`max-width`, `min-height`/`max-height` — GrowWindow's: the app stated the rectangle of sizes and the Window Manager clamped the drag to it; the mins default to the 80×54 floor a window can still be worked at, the maxes are unbounded, and a min equal to its max locks the axis — the strip that scrolls sideways and never grows taller). The clamp runs after the lattice snap, so a bound lands exactly the way an authored size does even off the lattice (an odd height at 2×); where a min and max cross the max wins, so a window authored under the floor and held there never jumps to it. The rect bounds the gesture only: a size authored outside it renders as declared, and the first grow-box move brings it inside. The drag fires `vf-resize` (detail `{ width, height, commit }`, sizes in whole system px) — one event per size the drag actually writes (`commit: false`), fired *after* the new box is applied so a handler that measures reads the resized layout, then a final `commit: true` as the gesture settles (release or cancel), only when it changed the size. Window content can follow the grow box from this event alone, no ResizeObserver. Fired by the gesture only: a programmatic `width`/`height` write fires nothing, the way a value set fires no `vf-change`.
|
|
226
|
-
- **Methods:** `placementAt(clientX, clientY, child?)` → a viewport point as a placement in the content region — `{ left, top }` in whole system px on the placement lattice: the body's corner, or under `scrollbars` the scrolled plane with the scroll offset
|
|
226
|
+
- **Methods:** `placementAt(clientX, clientY, child?)` → a viewport point as a placement in the content region — `{ left, top }` in whole system px on the placement lattice: the body's corner, or under `scrollbars` the scrolled plane with the scroll offset already in it (the built-in area's own `placementAt`). The pair a child dropped into the window is written with; with `child`, the pair to write to that element, its `origin` offset folded in. `measure()` → the built-in scroll area's `measure()`, a no-op without `scrollbars`; the area re-measures by itself on its content's box, its slot and every placement write (`vf-placement-change`, §2), so this is the escape hatch for a range that changes with none of those.
|
|
227
227
|
- **Slots:** default (body content), `header` (the header strip between the title bar and the body), `status` (the bottom status strip — see Visual).
|
|
228
228
|
- **Parts:** `frame`, `title-bar`, `title`, `close-box`, `zoom-box`, `header`, `body`, `status-bar`, `grow-box`, plus `viewport` re-exported from the built-in scroll area when `scrollbars` is set.
|
|
229
229
|
- **Events:** `vf-close`, `vf-zoom` (detail `{}`), `vf-resize` (detail `{ width, height, commit }`).
|
|
@@ -315,7 +315,7 @@ The color-swatch button: a well of solid color — a palette cell.
|
|
|
315
315
|
- **Events:** `vf-input` detail `{ value }` on every keystroke; `vf-change` detail `{ value }` on commit (native change). Plus the native pair per §2: the inner input's own composed `input` crosses the boundary itself; `change` is re-dispatched from the host.
|
|
316
316
|
|
|
317
317
|
#### `vf-text-area` (`VfTextArea`, vf-text-area.ts)
|
|
318
|
-
Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding
|
|
318
|
+
Same as vf-text-field but wrapping `<textarea>`; extra prop `rows: number` (default 4). No resize grip (`resize: none`) — System 7 fields don't resize. Wrapped entry text sits on the display face's native line (`--vf-line-height-display`, 16px — Chicago 12's; `rows` buys one line each) — the single-line well's 20px box is control geometry, the 22px field trace, and stays; a multi-line well is typesetting. Reserves a permanent System 7 vertical scroll rail (the shared "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the text overflows, then the dither and thumb fill in. The rail is the drawn `vfScrollRail` subtree (§4), a shadow sibling of the `<textarea>` synced to its native scrolling by `ScrollRailController`; the textarea carries the `vf-scroll` class (native bar hidden), and both controllers re-measure on each keystroke (a textarea's scrollHeight grows without a box resize). Unlike vf-text-field, the 1px frame sits on the `.vf-field-well` wrapper rather than the field itself — the rail has to live inside the frame beside the text — with the borderless `<textarea>` keeping vf-text-field's own `3px/6px` padding, so the text and the outer box sit exactly where the bordered field puts them. The well is the same wrapper vf-text-field uses; the focus rule spans the full frame, scroll rail included (the `-3px` bordered-carrier offset, §4 vfFocusUnderline). Forwards the same input-behavior attributes as vf-text-field, minus `pattern` (only an `<input>` takes it). Parts: `textarea`. Events: `vf-input`, `vf-change`, plus the native pair (§2).
|
|
319
319
|
|
|
320
320
|
#### `vf-number-field` (`VfNumberField`, vf-number-field.ts)
|
|
321
321
|
A numeric text field paired with the classic "little arrows" stepper.
|
|
@@ -386,19 +386,19 @@ The classic popup menu control ("Macintosh HD ▼").
|
|
|
386
386
|
Classic list box.
|
|
387
387
|
- **Attributes/props:** `multiple: boolean`, `value: string` / `values: string[]` (multiple), `disabled`, `label: string` (accessible name → host `aria-label`).
|
|
388
388
|
- **Children:** `<vf-list-item value="...">` (`VfListItem`, vf-list-item.ts): props `value`, `selected` (reflect), `disabled`; height 20px, `padding: 0 6px`; selected = inverted row (full width). Slots: default (the row text, in a `text` part that ellipsizes), `icon` (a leading graphic — usually a 16×16 `vf-img` small icon; flex-centered in the row with the reference art's 4px gap to the text, no text contributed so type-ahead still reads the words; keep row-height − icon-height even so the centering offset is a whole pixel).
|
|
389
|
-
- **Visual (list):** white bg inside a real 1px black frame on the snapped wrapper, a `[rows | rail]` grid: the scrolling row viewport (class `vf-scroll`, native bar hidden) beside the drawn `vfScrollRail` subtree (§4), synced by `ScrollRailController`. The rows sit exactly 1 system px inside the frame lines
|
|
389
|
+
- **Visual (list):** white bg inside a real 1px black frame on the snapped wrapper, a `[rows | rail]` grid: the scrolling row viewport (class `vf-scroll`, native bar hidden) beside the drawn `vfScrollRail` subtree (§4), synced by `ScrollRailController`. The rows sit exactly 1 system px inside the frame lines; default `max-height: 200px` overridable via `--vf-list-max-height` (the frame adds its 2px on top). Reserves a permanent vertical scroll rail (the "always-a-rail" behavior — see vf-scroll-area): arrows on an empty white channel until the rows overflow.
|
|
390
390
|
- **Behavior:** `role="listbox"` (+`aria-multiselectable`, + `aria-label` from `label`), items `role="option"`. A disabled list pushes `aria-disabled` down onto every row (as `vf-radio-group` does), so AT is never shown enabled-looking options inside a disabled listbox; the flag is tracked separately from each row's own `disabled`, so re-enabling the list leaves individually disabled rows disabled. Click selects (Shift/Cmd extend when `multiple`). Roving tabindex; Arrow keys move the selection, Space toggles in multiple mode. The full multiple-mode model: Shift+Arrow extends from the anchor, Ctrl+Arrow moves the cursor without touching the selection, plain Home/End and type-ahead jumps are cursor-only moves too (a jump that rewrote a hand-built selection would destroy it — Space is how the reached row joins it; in single mode they select, since there the selection *is* the cursor), Shift(+Ctrl)+ Home/End extend through to the ends, Shift+Space selects the contiguous run from the anchor to the cursor, and Ctrl/Cmd+A selects every enabled row. Printable keys drive classic Finder **first-letter type-ahead** (`src/type-ahead.ts`, the shared buffer `vf-select` and the menus also run): keystrokes accumulate into a prefix matched against each row's text, jumping to the next match, wrapping and skipping disabled rows. The prefix resets after 1s of silence; repeating a single character cycles the rows starting with it. Modified keys are left to the consumer, and Space stays the multiple-mode toggle, so neither joins the prefix. A **disabled** list keeps its rows rendered and readable (System 7 dims a list, it doesn't hide it) — when they overflow the box, the viewport itself becomes the Tab stop so the dimmed rows stay reachable by keyboard scroll.
|
|
391
391
|
- **Parts:** `list`. **Events:** `vf-change` detail `{ value, values }`.
|
|
392
392
|
|
|
393
393
|
#### `vf-scroll-area` (`VfScrollArea`, vf-scroll-area.ts)
|
|
394
394
|
A container whose scrollbars look like System 7.
|
|
395
395
|
- **Attributes/props:** `axis: 'vertical' | 'horizontal' | 'both'` (default `'vertical'`, reflected) — which scroll rails to reserve as permanent placeholders (see "always-a-rail" below); `corner: boolean` (default false, reflected) — reserves the bottom-right corner cell on a single-axis rail (the rail stops 15px short of the frame, the viewport spans the rest), for a grow box to land in; inert on `both`, which always has the cell; `label: string` — accessible name for the viewport (`aria-label` on the viewport, since an `aria-label` on the host cannot reach into the shadow DOM). The viewport is a **Tab stop only while its content actually overflows** — the same state `ScrollStateController` measures for the rails; a fitting scroll area used to be a focusable stop with `role: generic` and no name, a dead Tab press. Whenever it is a stop it carries a role: `role="region"` when `label` names it (a named landmark), `role="group"` when not (an unnamed region is inert, so that role is reserved for the labelled case).
|
|
396
|
-
- **Visual:** `display: block`; the snapped wrapper carries a real 1px black frame and a grid reserving each rail as its own edge column/row — `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the white inner viewport carrying no inset of its own
|
|
396
|
+
- **Visual:** `display: block`; the snapped wrapper carries a real 1px black frame and a grid reserving each rail as its own edge column/row — `[viewport | vertical rail]` over `[horizontal rail | corner]` — with the white inner viewport carrying no inset of its own, so slotted content and the (0,0) of placed children sit exactly 1 system px from the frame box at every scale; an inset is the content's (a `vf-stack pad`). Consumer sets width/height on host. The scrolled plane — the wrapper the slot renders into, and the positioned anchor for placed children — is `width: fit-content; min-width: 100%`: never narrower than the viewport, as wide as content that cannot wrap, so the controllers' ResizeObserver on it sees a row growing sideways the way it sees copy growing down (a block wrapper's auto width was the viewport's whatever the row did, and a row gaining a cell left the thumb and the overflow state stale until something else re-measured); copy wraps as before, and a `position: sticky; left: 0` child holds across the scroll, the plane being its containing block. `min-height: 100%` likewise: a `fixed` child (§2 Explicit placement) is a sticky box the plane contains, and a plane shorter than the child's stated `top` would clamp it to the plane's bottom edge; content that fits still fits. In the overflowing case a percentage-width or centered block child resolves against the grown plane, not the viewport. A `slotchange` re-measures; `measure()` covers a scroll range that changes with no box changing. The rails are the drawn `vfScrollRail` subtree (§4 — the classic 16px cell counting the frame line: divider, 14px channel, 15px arrow cells, the fixed 16px thumb, the `tileRaster` dot-dither trough), rendered as later siblings of the viewport and synced to its native scrolling by `ScrollRailController`; the viewport carries the `vf-scroll` class, which hides the native bar without touching the native scrolling. When both rails are reserved, the corner cell joins them, supplying the interior dividers the adjacent arrow cells leave to it; `corner` reserves the same 15×15 cell beside a single rail, its other divider continuing the rail's line across the cell — DrawGrowIcon's form, for the grow box. Every engine renders the same rail; the old `::-webkit-scrollbar` skin, its `.vf-scroll-frame` overlay contortion (WebKit quantized native scrollbar rects to whole CSS px) and the Firefox `scrollbar-color` fallback are all retired.
|
|
397
397
|
- **Always-a-rail behavior:** each *reserved* axis (per `axis`) renders its rail element as a permanent placeholder — arrow buttons on an empty white channel (dither off, no thumb; System 7 drew the arrows on any bar in an active window, and a scroller outside a window always counts as active) — until the content overflows that axis, when the dither and thumb fill in and the arrows go live (an idle axis's arrows are drawn but inert — the press guard in `ScrollRailController` skips them). `ScrollStateController` (`src/scroll-state.ts`) measures both axes and writes `data-overflow-x` / `data-overflow-y` (`"true"` / `"false"`) on the scroll element; the recipe keys the dither and thumb off those attributes. The unreserved axis still scrolls natively (wheel, keyboard) but draws no rail. Shared by vf-list and vf-text-area; a future `@container scroll-state(scrollable)` query could replace the JS for slotted-content components.
|
|
398
398
|
- **Inactive-window blanking:** the HIG's non-frontmost window must not display interactive scroll UX, so the controller also finds the nearest `vf-window` up the composed tree (light-DOM ancestor for a slotted scroller, shadow ancestor for `vf-window[scrollbars]`'s own edge rails), watches its reflected `active` attribute, and toggles a presence-only `data-window-inactive` on the scroll element. While present, the recipe empties dither/thumb/arrows on BOTH axes regardless of overflow — the bare channel, arrows included (unlike the idle rail, which keeps them), exactly as System 7 blanked a deactivated window's bars (its List Manager/TextEdit deactivated in-window scrollbars too). No `vf-window` ancestor → the attribute never appears: dialogs have no inactive state and a bare scroll component always draws live. Like the overflow half, this signal could one day go declarative — a custom property cascaded under `vf-window:not([active])` gating the recipe via an `@container style()` query; the rails being ordinary DOM, that migration is a plain selector swap (see the FUTURE note in scroll-state.ts).
|
|
399
399
|
- **Document-window (TeachText) composition:** to put the rails on a window's edge rather than inset in its body, slot the scroll area into a `vf-window` sized `calc(100% + 2px·scale)` with `margin: -1px·scale` — one system pixel under the window frame on every side. Its own frame border then repaints the window's border lines exactly (no doubled frame), and a resizable window's grow box lands exactly over the rail's corner cell, giving the classic System 7 document window. `vf-window[scrollbars]` renders exactly this composition from its own shadow tree (forwarding `corner` for a resizable window without a status strip), so the one-liner and the slotted form are geometrically identical; slot it yourself when the well should sit inside the body instead of on its edge (the installer's read-me well, placed with `top`/`left`).
|
|
400
400
|
- **Re-measures under a moved child.** A placed child moved through `top`/`left` changes the scroll range with no box changing anywhere the ResizeObserver can see — the plane's box is never grown by an absolutely positioned child — so the placement controller announces every write as `vf-placement-change` (§2) and the area re-measures on it, heard on the host: the event is non-composed, but from a slotted child it still travels through the slot it is assigned to, which is how the window's built-in area (this element, in the window's shadow) hears an icon slotted into the window body. A drop, an arrow nudge and a page's own `icon.left = …` all go through the one funnel.
|
|
401
|
-
- **Methods:** `measure()` → re-measure overflow and re-sync the rails, for a scroll range that changes with no box, no slot and no placement write — a transform — the way the kit's own fields re-measure on input. `placementAt(clientX, clientY, child?)` → a viewport point as a placement on the scrolled plane, `{ left, top }` in whole system px on the placement lattice; the plane travels with the scroll, so its box already carries the offset
|
|
401
|
+
- **Methods:** `measure()` → re-measure overflow and re-sync the rails, for a scroll range that changes with no box, no slot and no placement write — a transform — the way the kit's own fields re-measure on input. `placementAt(clientX, clientY, child?)` → a viewport point as a placement on the scrolled plane, `{ left, top }` in whole system px on the placement lattice; the plane travels with the scroll, so its box already carries the offset. With `child`, the pair to write to that element, its `origin` offset folded in.
|
|
402
402
|
- **Slots:** default. **Parts:** `viewport`.
|
|
403
403
|
|
|
404
404
|
#### `vf-fieldset` (`VfFieldset`, vf-fieldset.ts)
|
|
@@ -478,16 +478,17 @@ The Finder icon — pixel art in a reserved cell with its name on a plate below,
|
|
|
478
478
|
- **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.
|
|
479
479
|
- **The open ghost is derived, not shipped** (`open`; `open-art.ts`). The art redraws as the Finder's open-window ghost — outline held in solid black, interior re-filled with the scrollbar trough's 4×2 dot lattice (a dot at (0,0) and (2,1), 25%) on opaque white, surround untouched — derived from the slotted art itself, by compositing alone: a silhouette via `source-in`; a 1px 4-neighborhood erosion via four `destination-in` draws of the silhouette shifted one pixel each way (off-canvas composites as transparent, so the raster's own edge erodes too and an interior hole rings itself); the lattice via `createPattern` anchored at the art's top-left, where 32 and 16 being whole multiples of the tile keeps a field of icons in one phase; then the fill composed over the un-eroded silhouette, so the ring the erosion removed reads as the outline. Never `getImageData` — a canvas that drew a cross-origin image is tainted, which forbids *reading* pixels but not drawing or displaying them, so the ghost works for CORS-less sources too — and never `ctx.filter` (Safari's shaky corner; compositing operators are universal). Keeping the interior *opaque white* is what lets a selected open icon invert under the same `filter: invert(1)` with no second treatment. The slot stays in the tree while the ghost paints, hidden via a class gated on a successful derivation — art the pipeline cannot draw (nothing slotted, a failed load, an inline `<svg>`) keeps rendering as itself. The ghost re-derives on slotchange, the art's `load`/`error`, and `open`/`size` changes, keyed by `currentSrc` so a refresh that changed nothing is a string compare; the source's `alt` is carried onto the ghost (`role="img"` + `aria-label`) so a graphic that names its icon keeps doing so while its slot is hidden. The ghost displays on `vf-img`'s terms: natural raster size in system px, `image-rendering: pixelated`, one image pixel per system pixel.
|
|
480
480
|
- **Behavior:** clicking selects, Shift/⌘ toggles, and a press outside every icon clears — a `DocumentListenersController` capture-phase listener attached only while selectable *and* selected, which is what makes single-selection work with no container owning the set. Double-click fires `vf-open`, and its keyboard route is ⌘O / ⌘↓ (Ctrl off the Mac) — the System 7 Open shortcuts, added because a double-click is a pointer-only gesture (§1). Return is deliberately not one: the Finder's Return renamed, never opened, so it starts the edit on an editable icon and does nothing on a non-editable one. A double-click opens the icon **wherever it lands, the name included** — the handler is on the frame, and the name is as much the icon as the art is. `editable`: a press on the plate of an ALREADY-selected icon opens a rename field overlaying it (the press that does the selecting never does); the whole name starts selected, Return commits, Escape reverts, and the hidden plate keeps rendering the draft so the box widens as you type. **The two pointer gestures on a name are the same press**, and only the second one tells them apart, so the rename *waits*: the press arms a field that opens `RENAME_DELAY_MS` (800ms, `src/motion.ts`) later, and a press landing inside that window — wherever in the icon it falls — calls it off, leaving the double-click to open with no rename flashing up behind it. The window is long deliberately: no API reports what the browser counts as a double-click (it follows a user-movable platform setting), and reading a lone click as a pair costs only a wait, while reading a pair as a lone click renames when the user meant to open. A press that travels into a drag, a press outside, and any key call it off too — so a name drags like the art does, and Return opens the field at once, having no second half to wait for. `movable` — **not `draggable`**, which is a global HTML attribute and an `HTMLElement` accessor (the same trap as `align`, §5 Group G) — drags via `DragController` on the `vf-window` delegate shape (the frame is the handle and sets `touch-action: none` while movable, as the title bars do), plus arrow-key nudging (1 system px, 8 with Shift) because a pointer-only gesture is the kind of gap the kit closes (§1). Focus is the dashed rule below the plate, gated on `FocusRuleController` rather than `:focus-visible`: the host focuses itself so the press-drag can own the pointer.
|
|
481
|
-
- **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step away from the press (the jitter test: a stationary press snaps back onto the same system px and never starts a drag, so a plain click never fires a drop); `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press — and whose `preventDefault()` writes nothing, the page re-parenting or placing the icon itself; `vf-drag-cancel` `{}` on Escape or a `pointercancel`, nothing written. `left`/`top` in the detail are the proposal *unclamped* (the origin plus the lattice-snapped delta); only the default action clamps, so a page that cancels gets the raw gesture. The drop is dispatched with the outline gone, the drag state cleared and the pointer capture released, so re-parenting inside the handler is safe (Chromium drops the `click` of a press whose node was re-inserted at `pointerup`; the icon listens for no click and times its double-click on presses).
|
|
481
|
+
- **The drag reports, the page files.** The kit ships no filing semantics — folders are the page's own catalog — so a drag is a stream of events on the icon and a cancelable commit, all bubbling and composed: `vf-drag-start` `{ left, top }` on the first lattice step away from the press (the jitter test: a stationary press snaps back onto the same system px and never starts a drag, so a plain click never fires a drop); `vf-drag` `{ clientX, clientY, left, top, x, y }` on every step that changed the snapped proposal — the pointer in viewport CSS px for the page's hit test, the proposal in the icon's own container in whole system px on the lattice, and the frame's box at the press translated by the delta in viewport CSS px, so a drop that lands in another container converts with that container's `placementAt` and lands where it was let go, the press offset already inside it; `vf-drop`, the same shape at the release, **cancelable**, whose default action is the move — `PlacementController.moveTo` with the proposal, clamped whole by `#keepWhole` against the box measured at the press; 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).
|
|
482
482
|
- **The drag is an outline; the icon stays put** (`deriveDragOutline` / `dotOutline`, `open-art.ts`). The Finder dragged an icon as a dotted outline — the mask's boundary and the name's rectangle — drawn over everything with the XOR pen, the icon itself unmoved until the drop. Derived from the slotted art like the open ghost: the silhouette from the art's alpha (drawn at the box the art paints in, so magnified art outlines at its magnified size), the 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`.
|
|
483
483
|
- **The selection travels.** A drag that begins on a *selected* icon carries every other selected, movable icon of its field (`closest('vf-icon-field, [role="listbox"]')`, document order) — the Finder outlined the whole selection under a drag on any member. Each follower is joined through `vf-icon.followDrag(surface)` (internal to the group drag): it seeds its own origin, builds its own outline on the surface the leader's handshake found (or in its own frame), and hands back a handle `{ icon, origin, size, move(dx, dy), drop(dx, dy), end() }`. The leader moves every outline by its lattice delta on each step; every event's detail carries the set as `icons`, the leader first (`VfIconDragDetail`). The drop's default action clamps the delta **once for the whole group** — per axis, the tightest of every member's room to its edges, so the arrangement is kept and the group stops when its outermost icon meets the container (a group no edge can hold is held off the near edge only, the single-icon rule; `PlacementController.bounds` is the box, measured at the press) — then writes each member's origin plus that delta through its own `moveTo`. A cancel, or a cancelled drop, takes every outline down and writes nothing for anyone. **The press keeps the selection:** an icon's outside-press listener leaves the selection alone when a plain press lands on an icon already selected in the same field (document capture runs before the pressed icon's own handler, so `selected` reads as the press found it); a click on such an icon, released with no drag, collapses the selection to it on the `pointerup` — so a click still single-selects, and the same press could have dragged them all. A press on an unselected icon makes it the selection first, so it drags alone; a Shift press that deselects the pressed icon drags it alone too; a selected icon in another field, or one that cannot move, stays. `npm run verify:icon-drag` (GROUP).
|
|
484
|
+
- **The page drags it too** (`moveTo`, `dragTo`; `vf-icon-field.dragIcons` below). The Finder's Clean Up walked a container's icons one at a time, each icon's outline travelling from where it sat to its cell and the icon landing when the outline arrived — the drag's vocabulary, with the Finder for a hand. `dragTo(left, top)` → `Promise<boolean>`: the landing is resolved first (`PlacementController.resolve` — the pair `moveTo` would write, clamped whole and snapped, without writing it — so the outline goes where the icon will land, not where a pair past the edge pointed), the outline built and placed as a drag's is (`#buildOutline`, on the desktop's surface through the handshake or in the frame), then stepped from the origin to the landing by `runOutlineTravel` (`src/motion.ts`): whole steps of `WALK_STEP_PX` (16) every `WALK_STEP_MS` (25), each re-dotted at the screen's phase through `#placeOutlineAt`, the last step exactly the landing, and one interval later the outline down and the icon written through `PlacementController.moveTo` — one `vf-placement-change`, no drag event (the page is the mover and already knows). Constant speed rather than a fixed frame count: most of a Clean Up's moves are short, and a fixed count would crawl a one-pixel correction across as many frames as a trip across the screen. A timer chain, not `requestAnimationFrame`, so a 120 Hz display does not double the speed. Resolves to whether the outline travelled — false when the icon landed at once: already at its landing, `prefers-reduced-motion` (the blink's posture), nothing to draw with, mid-rename or under the hand's own press, or not in the DOM (nothing written then). Not gated on `movable`, which says the hand may drag; the page may always place its icon. **Finished, never cancelled:** a document `pointerdown` or Escape (capture, scoped to the travel, the key stopped so a dialog never dismisses) lands the icon at once — the user wants the machine back, and the move was the page's; so does a second `dragTo`, a `moveTo`, or disconnecting (outline down, nothing written). `moveTo(left, top)` is the landing alone — the drop's write: the container measured now (`seed()`), clamped whole, snapped, announced once; a `left`/`top` write stays the authored, unclamped pair (the `selected` / `setSelected()` line). The three cadence numbers are set by eye against the Finder and asserted nowhere. `npm run verify:icon-drag` (WALK).
|
|
484
485
|
- **An icon alone is a picture; an icon in a field is an option.** `option` is invalid without a `listbox` that owns it — written unconditionally the browser drops the role *and* `aria-selected`, which is how a `selectable` icon reached AT as a bare generic in every configuration the kit shipped. So the role follows the container: `role="option"` + `aria-selected` when a `vf-icon-field` or a `[role="listbox"]` ancestor claims it (`closest('vf-icon-field, [role="listbox"]')` — the field's role lives in internals and never lands as an attribute), otherwise `role="img"` named from `label` (else the art's `alt`), since `img` is not a name-from-content role. Not `button` — that promises Enter/Space activate, while here Return renames and the open route is ⌘O / ⌘↓. Re-derived on every connect, so re-parenting between the two contexts re-grades the icon. **The container for a field of icons is `vf-icon-field`** (below); a consumer's own element carrying `role="listbox"` still owns an icon the same way. `vf-desktop` cannot be it, since it also holds windows and a menu bar and a non-`option` child of a listbox is invalid the same way. Divergence from APG, recorded: its listbox options share one roving tab stop, the kit's stay one stop each.
|
|
485
486
|
- **`selectable` is what makes an icon focusable** — `movable` and `editable` require it, as the Finder did (you cannot move or rename what you have not selected; the rename already opens only on an ALREADY-selected icon). A `movable`-only icon is draggable but not a tab stop, rather than a tab stop that announces nothing.
|
|
486
487
|
- **A name is never abbreviated, and never folded:** no ellipsis, no clipping, no wrapping — `white-space: nowrap`, one line always. System 7 solved the long-name problem at the other end — HFS capped a filename at 31 characters — so the Finder always drew the name in full. Wider than its cell, it overflows it, centered. `width` is therefore the cell (the grid pitch), not a bound on the name.
|
|
487
488
|
- **Every centered offset is whole, by parity.** The frame centers two things over one axis, and a centered child sits at `(box − child) / 2` — whole exactly when box and child share a parity. Half a system pixel is what fringes 1-bit art: glyph stems smear across two device columns and go gray while the plate behind them stays sharp, since backgrounds are pixel-snapped by the compositor and glyphs are not (hence a crisp plate under a gray name). So the component makes the parities agree rather than correcting afterwards. The cell is 32 or 16; `#measurePlate()` measures the text with a `Range` over the plate's own contents — the plate already carries the width this last computed, so reading the element back would only return it — and sizes the plate (an `inline-block`) to that plus its 1px padding, rounded UP to a whole **even** number of system px, so it never clips the run it was measured from. The measurement is in system px and therefore scale-invariant, which is why nothing re-runs on a density change; it re-runs on `label`, `_draft`, `_editing`, and once on `document.fonts.ready`, since a name measured against the fallback face is a different width. The text is NOT centered inside its own plate (`text-align: left` against the block's `center`) — that would re-introduce the half pixel one level down; it starts at the 1px padding edge, so its x is the plate's plus one whole pixel, and the rounding slack lands after it as a hairline of extra plate. The rename field puts its run exactly where the plate's is, so the name does not shift when editing starts; its 1px border and 1px white well are a wrapper around the input rather than the input's own border and padding, because the selection behind a highlighted name paints at the face's full 16px em and is clipped only at the input's own edge — split, the input stands the plate's 12px line box (clipping the selection to it) and the well stays white, for an edit box 16 system px tall in all, not the em plus trim. Staying on one line is part of the same argument: one run, one measured width, one parity. Nothing is snapped and nothing leans on the rasterizer — the kit's normal antialiasing stays on, and `verify:icon` asserts both the geometry and zero gray pixels across a field of names at dpr 1/2/3, declared width and auto, with a control that forces an odd plate and requires the fringe to return. The one thing the component cannot round is a `width` the consumer chose, so that must be even.
|
|
488
489
|
- **The 31-char cap** is `maxlength` on the rename field, and exceeding it fires `vf-name-too-long` rather than dropping characters silently. Detected in `beforeinput`, not `input`: with `maxlength` set, a blocked keystroke fires `beforeinput` and then *no* `input` at all, while an overshooting paste reaches `input` already trimmed — only the "about to change" event still knows what was attempted. `input` carries a trimming backstop for any route that skips it. The cap bounds typing only; a `label` set from consumer code is displayed as given and fires nothing (the name is the consumer's data).
|
|
489
490
|
- **An empty name is refused, not applied.** A file has to be called something, and System 7 would not commit a nameless one: a rename committed empty — or as nothing but spaces, which would read as blank either way — drops the edit and restores the previous name, firing `vf-name-rejected` and deliberately no `vf-change`. An empty `label` remains a valid *starting* state (a freshly made icon has no name yet): it draws no plate but stays selectable, focusable and renameable. The caption element is therefore rendered whenever there is a name **or** an edit is open — without that an unnamed icon has nowhere to put the field and could never be named at all. The plate hugs its text in BOTH states, which is what keeps a commit from moving the name: it is the same box either side, so the glyphs do not shift. The single exception is an edit with no text in it at all, which reserves the cell width so the field is usable rather than a 2px sliver, and goes back to hugging on the first character. That floor is the cell, which is even, so the parity rule holds.
|
|
490
|
-
- **Methods:** `startEditing()`, `commitEditing()`, `cancelEditing()` — the rename field's three routes. `setSelected(next)` → select or deselect as a press would, `vf-select` included (a `selected` write from code is silent, the way a value set fires no `vf-change`); the route a container's gesture takes — `vf-icon-field`'s rubber band. `touches(box)` → whether a viewport box (CSS px `left/top/right/bottom`) reaches the icon's art cell or its name plate — the Finder's selection-rectangle test, in which the empty cell around narrower art and the frame's margin beside the plate count for nothing; the rubber band reads it.
|
|
491
|
+
- **Methods:** `startEditing()`, `commitEditing()`, `cancelEditing()` — the rename field's three routes. `setSelected(next)` → select or deselect as a press would, `vf-select` included (a `selected` write from code is silent, the way a value set fires no `vf-change`); the route a container's gesture takes — `vf-icon-field`'s rubber band. `touches(box)` → whether a viewport box (CSS px `left/top/right/bottom`) reaches the icon's art cell or its name plate — the Finder's selection-rectangle test, in which the empty cell around narrower art and the frame's margin beside the plate count for nothing; the rubber band reads it. `moveTo(left, top)` → place as a drop would: clamped whole in the container, snapped, one `vf-placement-change`. `dragTo(left, top)` → the outline travels and the icon lands, a promise — the walk, above.
|
|
491
492
|
- **Slots:** `large` (32×32 art, normally a `vf-img`), `small` (16×16). **Parts:** `frame`, `icon`, `label`, `plate`, `input`.
|
|
492
493
|
- **Events:** `vf-select` `{ selected }`, `vf-change` `{ label, previous }`, `vf-open` `{}`, `vf-name-too-long` `{ attempted, accepted, limit }`, `vf-name-rejected` `{ attempted, kept, reason }`, `vf-drag-start` `{ left, top, icons }`, `vf-drag` and `vf-drop` `{ clientX, clientY, left, top, x, y, icons }` (`vf-drop` cancelable), `vf-drag-cancel` `{}`.
|
|
493
494
|
|
|
@@ -497,6 +498,7 @@ The container a field of `vf-icon`s sits in — a desktop's icons, a folder wind
|
|
|
497
498
|
- **Semantics through internals:** `role="listbox"` and `aria-multiselectable="true"` always — the Finder was always multi-select, so there is no attribute for it — so a consumer's own `role`/`aria-*` on the tag wins (SPEC §2). A `selectable` icon inside is an `option` carrying `aria-selected`.
|
|
498
499
|
- **Visual:** none. `display: block; width: fit-content` — an in-flow block with no size, no inset and no position of its own, painting nothing (the `vf-stack` posture, with its typographic-transparency reset). A field whose icons are all placed is a zero-height block, so the icons keep anchoring to the desktop's raster or the window's plane, stored coordinates mean what they meant, and a press on the bare dither still reaches the desktop. Stated `top`/`left` and `width`/`height` make it a box and the anchor its icons place against — the placement working, as documented for everything. No `GridSnapController`: it paints nothing and its icons correct their own origins. Carries a `ScaleController` for a declared size on a plain page (the `vf-stack` reasoning).
|
|
499
500
|
- **The rubber band** — the kit's first piece of a container-level selection model. A `pointerdown` on the host whose `target` is the host itself (nothing slotted was hit; a press on an icon arrives with the icon as target and is the icon's own gesture), button 0, on a field with a box: the pointer is captured on the host, the press's default suppressed (no text selection across the page), and the **anchor** read — the icons selected *as the press found them*; the icons' outside-press listeners are document capture, so they have already run: a plain press cleared everything (an empty anchor), Shift or ⌘ kept the selection. The first move whose point (whole system px from the field's box) differs from the press starts the band; each changed rectangle — press to point, both held inside the band's *reach* — is repainted (`paintSelectionRect`, `open-art.ts`: the drag outline's dotted one-pixel ring at the rectangle's size, a degenerate axis as a one-pixel line, phased on the rectangle's corner on the *screen* — the field's offset from the desktop's drag surface, read at the press through the same `vf-drag-surface-request` handshake the outline uses, plus the corner in the field; the field's own box with no desktop — so over the dither the band reads as a black line whatever the field's offset, `penPhase`) and the icons re-read by the Finder's one rule: **a `selectable` icon is selected exactly when it is in the anchor XOR the rectangle touches it** — so a plain band selects what it touches and releases what it leaves, and a Shift band *toggles*: a selected icon it covers deselects while covered and comes back when it leaves, an unselected one selects. "Touches" is the icon's own reading (`vf-icon.touches(box)`: its art cell or its name plate, not the empty cell around narrower art or the frame's margin beside the plate, so a rectangle through the gap between two icons selects neither). Each change goes through `vf-icon.setSelected()` so it fires `vf-select` as a press would; the release keeps the result, a cancel restores the anchor. **The reach** is the field's own box intersected with every ancestor that clips (`overflow` other than `visible` on either axis — a window body, a scroll viewport, the desktop's raster), found by walking the flat tree through assigned slots and shadow hosts, rounded inward to whole system px; so the band never paints outside the region the icons are visible in. **Where it draws:** a canvas in the field's own shadow, `position: fixed` against the viewport (a layout-neutral field has no positioned box to anchor to; the field's box sits on the device grid, so whole system px from it land on the grid too), carrying the drag outline's XOR pen inline (`filter: invert(1)`, `mix-blend-mode: difference`, `image-rendering: pixelated`, `pointer-events: none`). That is the field's own stacking level, which is where the Finder drew its marquee: on the desktop, under the windows (the field precedes them in flow and takes no z-index) and over the dither and icons; in a window body, inside that window's stacking context — over its content, under any window above it — not the desktop's drag surface, which would put a window's marquee over every other window. Escape (a gesture-scoped document capture listener, stopped so a dialog never dismisses) and `pointercancel` cancel: the canvas goes and the selection is put back to the kept set; a release keeps what the band made. `touch-action` is deliberately left alone: a touch drag on a scrolling window's background pans it as before, and the `pointercancel` the browser then sends cancels the band. A field with no box (unplaced, unfilled) receives no press and draws none. `npm run verify:icon-drag` (BAND).
|
|
501
|
+
- **The walk** — `dragIcons(moves: { icon, left, top }[])` → `Promise<void>`: the Finder's Clean Up, the icons walked one at a time in the array's order — the order is the page's, a statement about its lattice, which the kit does not ship; the cadence is the kit's — each through `vf-icon.dragTo`, then `WALK_BEAT_MS` (50, `src/motion.ts`) after every landing that showed; a landing that showed nothing (the icon already there, reduced motion, gone from the DOM) takes no beat, so a tidy field resolves at once. Resolves when the last has landed. One walk per field: a second call finishes the first. Finished, never cancelled: a document `pointerdown` or Escape (capture, scoped to the walk, the key stopped) lands every icon still to go at its target through `moveTo` — the one travelling included, whose `moveTo` finishes its own travel — cuts the beat and resolves; the field disconnecting does the same; an icon gone from the DOM by its turn is skipped. The icons are the page's list, not checked against the field's own: the walk is a set gesture the container runs on its icons' behalf, where the band already lives, and "one at a time" is a claim about the container — the Finder cleaned up one window, or the desktop. `npm run verify:icon-drag` (WALK).
|
|
500
502
|
- **The desktop uses it and renders none.** Desktop icons sit in a field slotted into `vf-desktop` beside the windows, written by the page; a folder window's icons in one slotted into the window body. `vf-desktop` builds no field of its own: its furniture is slotted light DOM and the page decides what it is (the `clearActive` rule), a desktop with no icons should carry no empty listbox, and a field inside the desktop's shadow is one `closest()` could not reach.
|
|
501
503
|
- **Slots:** default (the icons). `npm run verify:icon-drag` (FIELD); `verify:position`'s UNIVERSAL group counts it as the 32nd element.
|
|
502
504
|
|