vintage-frames 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One of the kit's bitmap faces, as data: each glyph's advance, placement
3
+ * and ink, from the glyph manifest the face's woff2 is built from
4
+ * (fonts/manifest-to-font.py writes both). Everything is in whole design
5
+ * px, and one design px is one system px.
6
+ */
7
+ export interface Strike {
8
+ /** The family it is registered under: `VF Display` or `VF Body`. */
9
+ family: string;
10
+ /** The em box: `ascent` px above the baseline, `descent` below. */
11
+ ascent: number;
12
+ descent: number;
13
+ /** Each character the face draws, by the character itself. */
14
+ glyphs: Readonly<Record<string, StrikeGlyph>>;
15
+ }
16
+ /** One glyph of a {@link Strike}. */
17
+ export interface StrikeGlyph {
18
+ /** The pen advance. */
19
+ advance: number;
20
+ /**
21
+ * The ink field's left and bottom edges from the pen origin. The baseline
22
+ * is y = 0, and y grows upward.
23
+ */
24
+ x0: number;
25
+ y0: number;
26
+ width: number;
27
+ height: number;
28
+ /** The ink, top row first: `#` ink, `.` blank. Empty for a glyph with no ink. */
29
+ rows: readonly string[];
30
+ }
package/docs/SPEC.md CHANGED
@@ -234,7 +234,7 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
234
234
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
235
235
  The modal-dialog shell: movable modal by default (the dBoxProc double frame with the striped title bar set into it — traced from a 2× System 7 capture of a movable modal), the modal dialog box with `frame="plain"` (the bare double frame; see the Group A recipe table).
236
236
  - **Attributes/props:** `open: boolean` (reflect), `heading: string`, `top: number` / `left: number` (whole system px, in **viewport** coordinates — see §1 Explicit placement; unset means centered), `width: number` / `height: number` (**declare them both** — whole system px, the same fixed box `vf-window` is. A native `<dialog>` is `width: fit-content` measured against the space left beside its own offsets, and stating an offset is how the movable modal is positioned, so an undeclared dialog squeezes itself and reflows as it is dragged toward an edge. The two fall back differently — width to 260 system px, height to the content — and it names whichever are missing, once, on the open that first shows it), `label: string` (accessible name for a dialog with no `heading`), `closable: boolean` (default **false** — the bare movable-modal bar; the close box is opt-in because the HIG's Chapter 6 text denies a movable modal one while its Figure 5-1 grants it — the parameter enables either reading), `light-dismiss: boolean` (`lightDismiss`; default **false** — a click outside the frame closes the dialog with `vf-close` reason `'outside'`. Off by default because the classic modal ignored an outside click, and a dialog that asks a question should keep ignoring it; opt in for the About box and the splash, which the classic Mac did dismiss on a click. Reflected, and read at the release, so it can be toggled on an open dialog), `frame?: 'plain'`, `returnValue: string` (property only, as on `<dialog>`: the dialog's answer, cleared on each open).
237
- - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap). `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both directions. Drag the title bar to move it (shared `DragController` with `vf-window`), stating `top`/`left` in system px; drags starting on the close widget are ignored (same composedPath guard as `vf-window`). Escape → close + `vf-close` detail `{ reason: 'escape' }`; close box/programmatic/close() → `{ reason: 'close' }`; a click outside under `light-dismiss` → `{ reason: 'outside' }`. **Answers** (`VfModalDialog`): `close(value)` closes with `value` as `returnValue`, and a `<form method="dialog">` inside the dialog closes it on submit with the submitter's `value`, a submitter's `formmethod="dialog"` too. The platform's own rule looks for a `<dialog>` around the form, and this one is in the shadow root, so the base listens for `submit` on the host and closes at the end of the submit's path (`deferActivation`), not at all if anything cancelled it. `vf-button`'s submit proxy carries its `value` with or without a `name`. `vf-close`'s detail carries `returnValue`: the value that close carried, or null (Escape, a click outside, the close box, `close()` alone, removal). Return in a text field runs the form's implicit submission through the ringed `variant="default"` submit button when the form has one, ahead of the first in tree order, so a Cancel/OK row answers OK (`requestImplicitSubmit`). `npm run verify:dialog`, ANSWERS. No backdrop dimming: `::backdrop { background: transparent; }`. **Light dismiss is target identity, not geometry** (`VfModalDialog`): the `::backdrop` hit-tests as its `<dialog>`, and the frame fills the dialog's box, so the dialog is a pointer event's *target* exactly when the pointer was outside the frame — the listeners sit on the `<dialog>` itself, attached while open. It takes both halves of the click: a `pointerdown` on the backdrop arms (any other press disarms, so a right-click macOS never releases can't leave a stale arm), and a `pointerup` on the backdrop with the same `pointerId` closes — the platform's own `closedby="any"` two-step, so a press that starts on a control and slides off, or a title-bar drag released past the frame, leaves the dialog open. Deliberately not the `click` event: UI Events dispatches a press-drag-release click at the common ancestor of the two targets, which for a press on the frame released outside is the dialog itself. The platform's `closedby` attribute is not used because its dismissal arrives as `cancel`, which the base already reads as Escape. The press is consumed either way — a modal's backdrop lets nothing beneath it see the click — which is what lets a page dismiss its About box without also selecting the icon under the pointer. `npm run verify:dialog`. **Keyboard: the Dialog Manager's two rules** (`VfModalDialog`). *Initial focus:* right after `showModal()`'s own focusing steps, focus is handed to `initialFocusTarget` — the first slotted control carrying `autofocus` or `autoselect`, else the first enabled text-entry control (`vf-text-field`, `vf-number-field`, `vf-text-area`, or a native text input/textarea), else the default button (`defaultButton`: the first enabled `vf-button[variant="default"]`, looked for among the slotted content and then the shadow tree), else nothing, leaving the browser's pick. A field marked `autoselect` has its whole text selected on every open, right after `vf-show` and once the field has rendered, so a value set before `show()` or in a `vf-show` handler is the text selected. Otherwise the modal selects no text: whether a field opens selected is the page's call. The classic dialog put the insertion point in its first text item and gave nothing else focus; the web needs a focused control, and the default button is the one that makes Return and Space work without a Tab. Left alone, `showModal()` focuses the first focusable thing in flat-tree order — Cancel in a Cancel/OK row, or a link in the body — so Return pressed the wrong thing. Both getters are `protected`, for a consumer's own modal to override. *Return/Enter:* a `keydown` listener on the `<dialog>`, bubble phase, attached while open with the light-dismiss trio. An uncancelled, unmodified Enter activates `defaultButton` (its `click()`, so a `type="submit"` default runs its proxy path) and cancels the keydown, which is what keeps a focused Cancel from firing itself — Space presses the focused control, as on the classic Mac. Anything that took Enter for itself cancelled the keydown first and is left alone: `vf-select`'s list, `vf-list`, an icon's rename, and a text field whose form ran its implicit submission (`requestImplicitSubmit` now reports whether a form owner took the press, and the fields cancel on true — so the submission runs once, not once per layer). A link keeps its own Enter. A multi-line editor (`<textarea>`, contenteditable) keeps Return for the newline; only `code === 'NumpadEnter'` fires the button there — the split a TextEdit item drew between the two keys. With no enabled default button the press is left native. Escape is unchanged (the `cancel` path above). **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`): HTML's dialog removing steps skip the close algorithm entirely, which is exactly what a framework unmount does — the teardown routes through the same native-`close` funnel (`vf-close` fires on the removed element; nothing bubbles, it left the tree), `open` and the written origin reconcile so a re-append mounts closed, and focus returns to the element focused at open time. **The placement re-settles while open** whenever the dialog's own box resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving under zoom — the viewport resizes, or the scale changes. What survives depends on how the modal got where it is: an unplaced one re-centers (it never claimed a spot, and the stranded case is exactly this one), while a dragged or authored origin is kept and only re-clamped on screen.
237
+ - **Implementation:** wraps a native `<dialog>` (for top-layer + focus trap). `show()` → `showModal()`; `close()` closes. Keep `open` attr in sync both directions. Drag the title bar to move it (shared `DragController` with `vf-window`), stating `top`/`left` in system px; drags starting on the close widget are ignored (same composedPath guard as `vf-window`). Escape → close + `vf-close` detail `{ reason: 'escape' }`; close box/programmatic/close() → `{ reason: 'close' }`; a click outside under `light-dismiss` → `{ reason: 'outside' }`. **Answers** (`VfModalDialog`): `close(value)` closes with `value` as `returnValue`, and a `<form method="dialog">` inside the dialog closes it on submit with the submitter's `value`, a submitter's `formmethod="dialog"` too. The platform's own rule looks for a `<dialog>` around the form, and this one is in the shadow root, so the base listens for `submit` on the host and closes at the end of the submit's path (`deferActivation`), not at all if anything cancelled it. `vf-button`'s submit proxy carries its `value` with or without a `name`. `vf-close`'s detail carries `returnValue`: the value that close carried, or null (Escape, a click outside, the close box, `close()` alone, removal). Return in a text field runs the form's implicit submission through the ringed `variant="default"` submit button when the form has one, ahead of the first in tree order, so a Cancel/OK row answers OK (`requestImplicitSubmit`). `npm run verify:dialog`, ANSWERS. No backdrop dimming: `::backdrop { background: transparent; }`. **Light dismiss is target identity, not geometry** (`VfModalDialog`): the `::backdrop` hit-tests as its `<dialog>`, and the frame fills the dialog's box, so the dialog is a pointer event's *target* exactly when the pointer was outside the frame — the listeners sit on the `<dialog>` itself, attached while open. It takes both halves of the click: a `pointerdown` on the backdrop arms (any other press disarms, so a right-click macOS never releases can't leave a stale arm), and a `pointerup` on the backdrop with the same `pointerId` closes — the platform's own `closedby="any"` two-step, so a press that starts on a control and slides off, or a title-bar drag released past the frame, leaves the dialog open. Deliberately not the `click` event: UI Events dispatches a press-drag-release click at the common ancestor of the two targets, which for a press on the frame released outside is the dialog itself. The platform's `closedby` attribute is not used because its dismissal arrives as `cancel`, which the base already reads as Escape. The press is consumed either way — a modal's backdrop lets nothing beneath it see the click — which is what lets a page dismiss its About box without also selecting the icon under the pointer. `npm run verify:dialog`. **Keyboard: the Dialog Manager's two rules** (`VfModalDialog`). *Initial focus:* right after `showModal()`'s own focusing steps, focus is handed to `initialFocusTarget` — the first slotted control carrying `autofocus` or `autoselect`, else the first enabled text-entry control (`vf-text-field`, `vf-number-field`, `vf-text-area`, or a native text input/textarea), else the default button (`defaultButton`: the first enabled `vf-button[variant="default"]`, looked for among the slotted content and then the shadow tree), else nothing, leaving the browser's pick. A field marked `autoselect` has its whole text selected on every open, right after `vf-show` and once the field has rendered, so a value set before `show()` or in a `vf-show` handler is the text selected. Otherwise the modal selects no text: whether a field opens selected is the page's call. The classic dialog put the insertion point in its first text item and gave nothing else focus; the web needs a focused control, and the default button is the one that makes Return and Space work without a Tab. Left alone, `showModal()` focuses the first focusable thing in flat-tree order — Cancel in a Cancel/OK row, or a link in the body — so Return pressed the wrong thing. Both getters are `protected`, for a consumer's own modal to override. *Return/Enter:* a `keydown` listener on the `<dialog>`, bubble phase, attached while open with the light-dismiss trio. An uncancelled, unmodified Enter activates `defaultButton` (its `click()`, so a `type="submit"` default runs its proxy path) and cancels the keydown, which is what keeps a focused Cancel from firing itself — Space presses the focused control, as on the classic Mac. Anything that took Enter for itself cancelled the keydown first and is left alone: `vf-select`'s list, `vf-list`, an icon's rename, and a text field whose form ran its implicit submission (`requestImplicitSubmit` now reports whether a form owner took the press, and the fields cancel on true — so the submission runs once, not once per layer). A link keeps its own Enter. A multi-line editor (`<textarea>`, contenteditable) keeps Return for the newline; only `code === 'NumpadEnter'` fires the button there — the split a TextEdit item drew between the two keys. With no enabled default button the press is left native. Escape is unchanged (the `cancel` path above). **Removal while open is a close path** (`VfModalDialog.disconnectedCallback`): HTML's dialog removing steps skip the close algorithm entirely, which is exactly what a framework unmount does — the teardown routes through the same native-`close` funnel (`vf-close` fires on the removed element; nothing bubbles, it left the tree), `open` and the written origin reconcile so a re-append mounts closed, and focus returns to the element focused at open time. **Opened again before a close's `close` event comes** — the native event is queued, so a `close()` and a `show()` in one turn do it — the open runs the funnel for that close first: its `vf-close` comes before the open's `vf-show`, and the late event finds the modal open and changes nothing, where it used to close the dialog that had just opened. That `vf-close` fires inside `show()`, so a one-time listener for the open's own answer goes on after `show()` (the shell's `ask()` does). The open drops any close value, reason or keys still pending, so a close asked for in that `vf-close` leaves nothing for a later one. `npm run verify:dialog`, REOPEN. **The placement re-settles while open** whenever the dialog's own box resizes — slotted content upgrading after `showModal()`, `--vf-scale` moving under zoom — the viewport resizes, or the scale changes. What survives depends on how the modal got where it is: an unplaced one re-centers (it never claimed a spot, and the stranded case is exactly this one), while a dragged or authored origin is kept and only re-clamped on screen.
238
238
  - **Visual (default chrome):** `vfModalFrame` with `vfTitleBar` set into it (§4) — the bar is literally the recipe `vf-window` uses, so it is identical by construction (stripes + centered title) rather than by matching copies; the frame is the modal double frame, **not** the window's shadowed `vfChromeFrame`. Outside → in: 1px outer rule; the 18px bar directly under it, 2px in at either end with its stripes starting 2px from the rule; the 2px band as the bar's floor (the bar's rule + the inner box's 1px top border); 2px gap + 2px band down the sides and along the bottom; no shadow. The body begins 20px below the dialog's top and 5px inside its other three edges. The bar takes the default `--vf-title-inset` (16px) — 60px when `closable`, the same clearance as `vf-window`, since the centered title needs symmetric room for the widget — and sets `touch-action: none` unconditionally, having no immovable state. `closable` renders the shared close box (`vfWindowWidgets` + `closeBox()` — byte-identical to `vf-window`'s, per the `moveable modal dialog.png` reference). Body is WHITE (`--vf-surface: #fff`) and is the content region, with no inset of its own: the positioning anchor, so a child placed with `top`/`left` measures from the frame's inner edge, and flow content starts at the same corner. Both chromes are full-height flex columns and the body takes the slack, for the same reason `vf-window`'s frame is: the declared `height` lands on the `<dialog>` (see `dialogSize`), and the recipes are skin only. The frame is the flex child of the `<dialog>` itself (`dialog[open]` is a flex column in `modalDialogStyles` — `[open]`-scoped, or it would out-cascade the UA's `dialog:not([open]) { display: none }`), not a `height: 100%` block: a percentage can't resolve against the undeclared-height dialog that only the UA's `dialog:modal` max-height caps, and that spill was how a viewport-tall modal stranded its buttons off-screen. **The box never grows, but over-stuffed content scrolls instead of clipping**: the body holds a `.content` scroll region (the default slot; part `content`). Placed children anchor to the body, outside that scroller, so they hold still while it scrolls. While the content fits, nothing matches. Once it overflows (`ScrollStateController`, the always-a-rail machinery), the region reserves the rail's 15px as its own right padding, wears the drawn `vfScrollRail` rail (§4) flush against the frame's inner band, which supplies the cell's outer line, and becomes a keyboard stop (`tabindex="0"`, `role="group"`, the kit's dotted ring) so the copy is scrollable without a pointer. The rail rides the body as an overlay pinned to its right edge, deliberately out of the layout flow: a rail *column*'s two fixed 15px arrow cells would hand the region a 32px minimum height, and a short dialog would then measure as fitting with the rail shown and overflowing without it — flip-flopping forever. The drop-open exemption is unchanged.
239
239
  - **The CSS and the controller state the same thing.** `.content` is `overflow-y: hidden` until the controller flags overflow, then `overflow-y: scroll`. It is deliberately never `auto`: the controller ignores the body face's negative half-leading (`LEADING_SPILL_SYS`, src/scroll-state.ts — `vf-paragraph` sets a 12-system-px line box under a 16-system-px em, so the inline box spills 2 inkless system px past the block box and `scrollHeight` counts it), and `auto` has no way to know that. With `auto` the two disagreed, and a fixed info dialog whose copy ends in a `vf-paragraph` rubber-banded under the wheel with no rail shown — 6 CSS px at scale 3. `hidden` still scrolls programmatically, so `scrollIntoView` on a focused control is unaffected. `npm run verify:contract`, OVERFLOW group.
240
240
  - **Visual (`frame="plain"`):** the same `vfModalFrame` (§4 — 1px outer, 2px gap, 2px inner band, no shadow, per `Windows/modal dialog.png`) with no title bar, and immovable like the original dBoxProc dialog (nothing renders a drag handle). The body begins 5px inside every edge. With no bar, `heading` only names the dialog and `closable` is ignored; a title in the body is the composer's, placed like any other item.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/JetBrains/web-types/master/schema/web-types.json",
3
3
  "name": "vintage-frames",
4
- "version": "0.16.2",
4
+ "version": "0.18.0",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vintage-frames",
3
- "version": "0.16.2",
3
+ "version": "0.18.0",
4
4
  "description": "Lit web components that emulate the look and feel of classic Mac OS System 7",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,6 +44,13 @@
44
44
  "types": "./dist/shell/pure.d.ts",
45
45
  "import": "./dist/shell/pure.js"
46
46
  },
47
+ "./build": {
48
+ "types": "./dist/build/index.d.ts",
49
+ "import": "./dist/build/index.js"
50
+ },
51
+ "./build/client": {
52
+ "types": "./dist/build/client.d.ts"
53
+ },
47
54
  "./package.json": "./package.json"
48
55
  },
49
56
  "files": [
@@ -67,12 +74,13 @@
67
74
  "build:pages": "vite build --config vite.pages.config.ts",
68
75
  "preview:pages": "vite preview --config vite.pages.config.ts",
69
76
  "prepack": "npm run build && npm run verify:manifest",
70
- "version": "npm run analyze && git add custom-elements.json editor",
77
+ "version": "npm run analyze && node scripts/write-version.mjs && git add custom-elements.json editor src/shell/version.ts",
71
78
  "analyze": "cem analyze",
72
79
  "verify:manifest": "node scripts/verify-manifest.mjs",
73
80
  "typecheck": "tsc --noEmit",
74
81
  "preview": "vite preview",
75
82
  "embed:cursors": "node scripts/embed-cursor-art.mjs",
83
+ "embed:frame": "node scripts/embed-frame.mjs",
76
84
  "verify:buttons": "tsc src/pixel-frame.ts --outDir scripts/.tmp --target es2022 --module esnext --moduleResolution bundler --skipLibCheck && node scripts/emit-clips.mjs && python3 scripts/verify-button-clips.py",
77
85
  "shot:buttons": "npx playwright screenshot --wait-for-timeout=2000 http://localhost:5173/pixel-test.html shots/buttons.png",
78
86
  "shot:verify": "python3 scripts/verify-button-screenshot.py shots/buttons.png",
@@ -125,8 +133,17 @@
125
133
  "dependencies": {
126
134
  "lit": "^3.2.0"
127
135
  },
136
+ "peerDependencies": {
137
+ "vite": ">=8.0.0"
138
+ },
139
+ "peerDependenciesMeta": {
140
+ "vite": {
141
+ "optional": true
142
+ }
143
+ },
128
144
  "devDependencies": {
129
145
  "@custom-elements-manifest/analyzer": "^0.11.0",
146
+ "@types/node": "^24.19.1",
130
147
  "custom-element-jet-brains-integration": "^1.7.0",
131
148
  "custom-element-vs-code-integration": "^1.5.0",
132
149
  "playwright": "^1.62.1",