vintage-frames 0.5.2 → 0.5.3

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.
@@ -2845,7 +2845,7 @@
2845
2845
  "declarations": [
2846
2846
  {
2847
2847
  "kind": "class",
2848
- "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`.",
2848
+ "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`. With `light-dismiss`, a click outside the frame\ncloses it too, with `{ reason: 'outside' }` — for the About box; off by\ndefault, since the classic modal ignored an outside click.",
2849
2849
  "name": "VfDialog",
2850
2850
  "cssProperties": [
2851
2851
  {
@@ -3129,6 +3129,21 @@
3129
3129
  "module": "src/modal-dialog.ts"
3130
3130
  }
3131
3131
  },
3132
+ {
3133
+ "kind": "field",
3134
+ "name": "lightDismiss",
3135
+ "type": {
3136
+ "text": "boolean"
3137
+ },
3138
+ "default": "false",
3139
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
3140
+ "attribute": "light-dismiss",
3141
+ "reflects": true,
3142
+ "inheritedFrom": {
3143
+ "name": "VfModalDialog",
3144
+ "module": "src/modal-dialog.ts"
3145
+ }
3146
+ },
3132
3147
  {
3133
3148
  "kind": "field",
3134
3149
  "name": "_dialog",
@@ -3198,6 +3213,61 @@
3198
3213
  "module": "src/modal-dialog.ts"
3199
3214
  }
3200
3215
  },
3216
+ {
3217
+ "kind": "field",
3218
+ "name": "#outsidePress",
3219
+ "privacy": "private",
3220
+ "type": {
3221
+ "text": "number | null"
3222
+ },
3223
+ "default": "null",
3224
+ "description": "The `pointerId` of a press that landed on the backdrop, held until its\nrelease decides whether the click was outside; null while nothing is\npending. See lightDismiss.",
3225
+ "inheritedFrom": {
3226
+ "name": "VfModalDialog",
3227
+ "module": "src/modal-dialog.ts"
3228
+ }
3229
+ },
3230
+ {
3231
+ "kind": "field",
3232
+ "name": "#dismissListeners",
3233
+ "privacy": "private",
3234
+ "readonly": true,
3235
+ "default": "new DocumentListenersController(this, () => [ [this._dialog, 'pointerdown', this.#onDialogPointerDown], [this._dialog, 'pointerup', this.#onDialogPointerUp], [this._dialog, 'pointercancel', this.#onDialogPointerCancel], ])",
3236
+ "description": "The light-dismiss listeners, on the `<dialog>` itself and only while it is\nopen. The `::backdrop` hit-tests as its dialog, and the frame the subclass\nrenders fills the dialog's box, so the dialog is an event's *target* only\nwhen the pointer was outside the frame — no rect arithmetic, no\ncomposedPath. Attached whether or not lightDismiss is set: the\nhandlers read the property at the release, so it can be toggled on an\nopen dialog.",
3237
+ "inheritedFrom": {
3238
+ "name": "VfModalDialog",
3239
+ "module": "src/modal-dialog.ts"
3240
+ }
3241
+ },
3242
+ {
3243
+ "kind": "field",
3244
+ "name": "#onDialogPointerDown",
3245
+ "privacy": "private",
3246
+ "description": "A press on the backdrop arms the dismissal. A press anywhere else — the\nframe, a control, the title bar — disarms it, so a stale arm (a press the\nplatform never released to us, a right-click's on macOS) can't survive to\nthe next release.",
3247
+ "inheritedFrom": {
3248
+ "name": "VfModalDialog",
3249
+ "module": "src/modal-dialog.ts"
3250
+ }
3251
+ },
3252
+ {
3253
+ "kind": "field",
3254
+ "name": "#onDialogPointerUp",
3255
+ "privacy": "private",
3256
+ "description": "The release completes it — the same press *and* release on the backdrop.\nDeliberately not the `click` event: UI Events dispatches a\npress-drag-release click at the common ancestor of the two targets, which\nfor a press on the frame released outside is the dialog itself, and that\nwould dismiss on exactly the gesture the two-step rule exists to ignore.",
3257
+ "inheritedFrom": {
3258
+ "name": "VfModalDialog",
3259
+ "module": "src/modal-dialog.ts"
3260
+ }
3261
+ },
3262
+ {
3263
+ "kind": "field",
3264
+ "name": "#onDialogPointerCancel",
3265
+ "privacy": "private",
3266
+ "inheritedFrom": {
3267
+ "name": "VfModalDialog",
3268
+ "module": "src/modal-dialog.ts"
3269
+ }
3270
+ },
3201
3271
  {
3202
3272
  "kind": "field",
3203
3273
  "name": "#invoker",
@@ -3477,7 +3547,7 @@
3477
3547
  ],
3478
3548
  "events": [
3479
3549
  {
3480
- "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' }`.",
3550
+ "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' | 'outside' }` — `'outside'` only under `light-dismiss`.",
3481
3551
  "name": "vf-close",
3482
3552
  "inheritedFrom": {
3483
3553
  "name": "VfModalDialog",
@@ -3581,6 +3651,19 @@
3581
3651
  "name": "VfModalDialog",
3582
3652
  "module": "src/modal-dialog.ts"
3583
3653
  }
3654
+ },
3655
+ {
3656
+ "name": "light-dismiss",
3657
+ "type": {
3658
+ "text": "boolean"
3659
+ },
3660
+ "default": "false",
3661
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
3662
+ "fieldName": "lightDismiss",
3663
+ "inheritedFrom": {
3664
+ "name": "VfModalDialog",
3665
+ "module": "src/modal-dialog.ts"
3666
+ }
3584
3667
  }
3585
3668
  ],
3586
3669
  "superclass": {
@@ -19702,7 +19785,7 @@
19702
19785
  },
19703
19786
  {
19704
19787
  "kind": "class",
19705
- "description": "Base class for the modal shell `vf-dialog` — and for a consumer's own\nmodal (an alert box, say) authored against the kit.\n\nOwns the native `<dialog>` lifecycle every modal shares: `open` sync, `show()` /\n`close()`, the top/left placement (stated or centered) and the\nsingle `close` funnel that drops the written origin and fires `vf-close` with\nthe reason. Because every close path — Escape, `close()`, backdrop — routes\nthrough the native `close` event, an Escape-close no longer leaves a stale\norigin behind, so the next open re-derives it.\n\nRemoving an open modal from the DOM is a close path too. HTML's dialog\n*removing steps* take the element out of the top layer **without** running\nthe close algorithm — no `close` event, no focus restoration — which is\nexactly what the standard framework pattern of unmounting a dialog instead\nof calling `close()` does. `disconnectedCallback` routes that path through\nthe same funnel: the removed element still fires `vf-close` (heard by\nlisteners on the element itself — it has left the tree, so nothing\nbubbles), `open` and the pinned margins reconcile so a re-append mounts it\nclosed and re-centered, and focus returns to the element that was focused\nwhen the modal opened.\n\nSubclasses supply only the frame chrome: a `render()` returning\n`<dialog",
19788
+ "description": "Base class for the modal shell `vf-dialog` — and for a consumer's own\nmodal (an alert box, say) authored against the kit.\n\nOwns the native `<dialog>` lifecycle every modal shares: `open` sync, `show()` /\n`close()`, the top/left placement (stated or centered) and the\nsingle `close` funnel that drops the written origin and fires `vf-close` with\nthe reason. Because every close path — Escape, `close()`, the opt-in\nlightDismiss click outside — routes through the native `close`\nevent, an Escape-close no longer leaves a stale origin behind, so the next\nopen re-derives it.\n\nRemoving an open modal from the DOM is a close path too. HTML's dialog\n*removing steps* take the element out of the top layer **without** running\nthe close algorithm — no `close` event, no focus restoration — which is\nexactly what the standard framework pattern of unmounting a dialog instead\nof calling `close()` does. `disconnectedCallback` routes that path through\nthe same funnel: the removed element still fires `vf-close` (heard by\nlisteners on the element itself — it has left the tree, so nothing\nbubbles), `open` and the pinned margins reconcile so a re-append mounts it\nclosed and re-centered, and focus returns to the element that was focused\nwhen the modal opened.\n\nSubclasses supply only the frame chrome: a `render()` returning\n`<dialog",
19706
19789
  "name": "VfModalDialog",
19707
19790
  "members": [
19708
19791
  {
@@ -19760,6 +19843,17 @@
19760
19843
  "description": "Offset from the left of the viewport, in whole system px. See top.",
19761
19844
  "attribute": "left"
19762
19845
  },
19846
+ {
19847
+ "kind": "field",
19848
+ "name": "lightDismiss",
19849
+ "type": {
19850
+ "text": "boolean"
19851
+ },
19852
+ "default": "false",
19853
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
19854
+ "attribute": "light-dismiss",
19855
+ "reflects": true
19856
+ },
19763
19857
  {
19764
19858
  "kind": "field",
19765
19859
  "name": "_dialog",
@@ -19809,6 +19903,41 @@
19809
19903
  "default": "null",
19810
19904
  "description": "Close reason pending for the next native `close` event."
19811
19905
  },
19906
+ {
19907
+ "kind": "field",
19908
+ "name": "#outsidePress",
19909
+ "privacy": "private",
19910
+ "type": {
19911
+ "text": "number | null"
19912
+ },
19913
+ "default": "null",
19914
+ "description": "The `pointerId` of a press that landed on the backdrop, held until its\nrelease decides whether the click was outside; null while nothing is\npending. See lightDismiss."
19915
+ },
19916
+ {
19917
+ "kind": "field",
19918
+ "name": "#dismissListeners",
19919
+ "privacy": "private",
19920
+ "readonly": true,
19921
+ "default": "new DocumentListenersController(this, () => [ [this._dialog, 'pointerdown', this.#onDialogPointerDown], [this._dialog, 'pointerup', this.#onDialogPointerUp], [this._dialog, 'pointercancel', this.#onDialogPointerCancel], ])",
19922
+ "description": "The light-dismiss listeners, on the `<dialog>` itself and only while it is\nopen. The `::backdrop` hit-tests as its dialog, and the frame the subclass\nrenders fills the dialog's box, so the dialog is an event's *target* only\nwhen the pointer was outside the frame — no rect arithmetic, no\ncomposedPath. Attached whether or not lightDismiss is set: the\nhandlers read the property at the release, so it can be toggled on an\nopen dialog."
19923
+ },
19924
+ {
19925
+ "kind": "field",
19926
+ "name": "#onDialogPointerDown",
19927
+ "privacy": "private",
19928
+ "description": "A press on the backdrop arms the dismissal. A press anywhere else — the\nframe, a control, the title bar — disarms it, so a stale arm (a press the\nplatform never released to us, a right-click's on macOS) can't survive to\nthe next release."
19929
+ },
19930
+ {
19931
+ "kind": "field",
19932
+ "name": "#onDialogPointerUp",
19933
+ "privacy": "private",
19934
+ "description": "The release completes it — the same press *and* release on the backdrop.\nDeliberately not the `click` event: UI Events dispatches a\npress-drag-release click at the common ancestor of the two targets, which\nfor a press on the frame released outside is the dialog itself, and that\nwould dismiss on exactly the gesture the two-step rule exists to ignore."
19935
+ },
19936
+ {
19937
+ "kind": "field",
19938
+ "name": "#onDialogPointerCancel",
19939
+ "privacy": "private"
19940
+ },
19812
19941
  {
19813
19942
  "kind": "field",
19814
19943
  "name": "#invoker",
@@ -20020,7 +20149,7 @@
20020
20149
  ],
20021
20150
  "events": [
20022
20151
  {
20023
- "description": "The modal closed. `detail: { reason: 'escape' | 'close' }`.",
20152
+ "description": "The modal closed. `detail: { reason: 'escape' | 'close' | 'outside' }`.",
20024
20153
  "name": "vf-close"
20025
20154
  }
20026
20155
  ],
@@ -20065,6 +20194,15 @@
20065
20194
  },
20066
20195
  "description": "Offset from the left of the viewport, in whole system px. See top.",
20067
20196
  "fieldName": "left"
20197
+ },
20198
+ {
20199
+ "name": "light-dismiss",
20200
+ "type": {
20201
+ "text": "boolean"
20202
+ },
20203
+ "default": "false",
20204
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
20205
+ "fieldName": "lightDismiss"
20068
20206
  }
20069
20207
  ],
20070
20208
  "superclass": {
@@ -23,7 +23,9 @@ import './vf-button-group.js';
23
23
  * Open it with `show()` (or set the `open` attribute/property); close with
24
24
  * `close()`. Escape closes it and fires `vf-close` with
25
25
  * `{ reason: 'escape' }`; the close box and programmatic closing fire
26
- * `{ reason: 'close' }`.
26
+ * `{ reason: 'close' }`. With `light-dismiss`, a click outside the frame
27
+ * closes it too, with `{ reason: 'outside' }` — for the About box; off by
28
+ * default, since the classic modal ignored an outside click.
27
29
  *
28
30
  * @slot - Default slot: dialog body content.
29
31
  * @slot buttons - Optional action buttons. Rendered as a bottom-right
@@ -40,7 +42,8 @@ import './vf-button-group.js';
40
42
  * scrolls under a System 7 rail and becomes a keyboard stop.
41
43
  * @csspart footer - The action row wrapping the buttons.
42
44
  * @csspart buttons - The button group inside the footer.
43
- * @fires vf-close - Dialog closed. Detail `{ reason: 'escape' | 'close' }`.
45
+ * @fires vf-close - Dialog closed. Detail `{ reason: 'escape' | 'close' |
46
+ * 'outside' }` — `'outside'` only under `light-dismiss`.
44
47
  * @cssprop --vf-dots-pattern - the windoid bar's dot-grid dither — a 2×2 tile,
45
48
  * one black pixel at the origin (`vfDots`; override the whole pattern like
46
49
  * `--vf-desktop-pattern`)
@@ -1,8 +1,12 @@
1
1
  import { LitElement } from 'lit';
2
2
  import type { PropertyValues } from 'lit';
3
3
  import { ScaleController } from './scale.js';
4
- /** Reason a modal closed, carried by the `vf-close` event's detail. */
5
- export type VfCloseReason = 'escape' | 'close';
4
+ /**
5
+ * Reason a modal closed, carried by the `vf-close` event's detail: the Escape
6
+ * key, the close box or `close()`, or — with {@link VfModalDialog.lightDismiss}
7
+ * — a click outside the frame.
8
+ */
9
+ export type VfCloseReason = 'escape' | 'close' | 'outside';
6
10
  /**
7
11
  * The width an undeclared modal falls back to, in system px — a classic
8
12
  * dialog's own measure, wide enough for a line of chrome text and a button row.
@@ -23,9 +27,10 @@ export declare const modalDialogStyles: import("lit").CSSResult;
23
27
  * Owns the native `<dialog>` lifecycle every modal shares: `open` sync, `show()` /
24
28
  * `close()`, the {@link top}/{@link left} placement (stated or centered) and the
25
29
  * single `close` funnel that drops the written origin and fires `vf-close` with
26
- * the reason. Because every close path — Escape, `close()`, backdrop — routes
27
- * through the native `close` event, an Escape-close no longer leaves a stale
28
- * origin behind, so the next open re-derives it.
30
+ * the reason. Because every close path — Escape, `close()`, the opt-in
31
+ * {@link lightDismiss} click outside — routes through the native `close`
32
+ * event, an Escape-close no longer leaves a stale origin behind, so the next
33
+ * open re-derives it.
29
34
  *
30
35
  * Removing an open modal from the DOM is a close path too. HTML's dialog
31
36
  * *removing steps* take the element out of the top layer **without** running
@@ -42,7 +47,8 @@ export declare const modalDialogStyles: import("lit").CSSResult;
42
47
  * `<dialog @cancel=${this._onNativeCancel} @close=${this._onNativeClose}>` with
43
48
  * their role/ARIA and body, and {@link modalDialogStyles} in `static styles`.
44
49
  *
45
- * @fires vf-close - The modal closed. `detail: { reason: 'escape' | 'close' }`.
50
+ * @fires vf-close - The modal closed. `detail: { reason: 'escape' | 'close' |
51
+ * 'outside' }`.
46
52
  */
47
53
  export declare class VfModalDialog extends LitElement {
48
54
  #private;
@@ -94,6 +100,22 @@ export declare class VfModalDialog extends LitElement {
94
100
  top?: number | null;
95
101
  /** Offset from the left of the viewport, in whole system px. See {@link top}. */
96
102
  left?: number | null;
103
+ /**
104
+ * Close on a click outside the frame — a press on the backdrop — and fire
105
+ * `vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal
106
+ * ignored an outside click (and beeped), and a dialog that asks a question
107
+ * should keep ignoring it. Opt in for the About box and the splash, the
108
+ * dialogs the classic Mac did dismiss on a click.
109
+ *
110
+ * Both halves of the click have to land outside — the two-step the
111
+ * platform's own `closedby="any"` light dismiss uses — so a press that
112
+ * starts on a control and slides off the frame, or a title-bar drag
113
+ * released past it, leaves the dialog open. The press is consumed either
114
+ * way: a modal's backdrop lets nothing beneath it see the click, so
115
+ * dismissing the About box does not also select whatever was under the
116
+ * pointer. Escape closes the dialog regardless, as it always has.
117
+ */
118
+ lightDismiss: boolean;
97
119
  protected _dialog: HTMLDialogElement;
98
120
  /**
99
121
  * The declared box as inline styles for the subclass's `<dialog>`:
@@ -1,9 +1,10 @@
1
1
  import { emit as e } from "./events.js";
2
2
  import { ScaleController as t, onScaleChange as n, snapSys as r, sysLength as i, toSysExact as a } from "./scale.js";
3
3
  import o from "./_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js";
4
- import { LitElement as s, css as c } from "lit";
5
- import { property as l, query as u } from "lit/decorators.js";
6
- var d = 24, f = c`
4
+ import { DocumentListenersController as s } from "./document-listeners.js";
5
+ import { LitElement as c, css as l } from "lit";
6
+ import { property as u, query as d } from "lit/decorators.js";
7
+ var f = 24, p = l`
7
8
  dialog {
8
9
  padding: 0;
9
10
  margin: auto;
@@ -42,9 +43,32 @@ var d = 24, f = c`
42
43
  the fallback is the arrow it always showed. */
43
44
  cursor: var(--vf-cursor, default);
44
45
  }
45
- `, p = class extends s {
46
+ `, m = class extends c {
46
47
  constructor(...e) {
47
- super(...e), this.scale = new t(this), this.open = !1, this.#e = !1, this.#n = null, this.#r = null, this.#l = () => this.settle();
48
+ super(...e), this.scale = new t(this), this.open = !1, this.lightDismiss = !1, this.#e = !1, this.#n = null, this.#r = null, this.#i = new s(this, () => [
49
+ [
50
+ this._dialog,
51
+ "pointerdown",
52
+ this.#a
53
+ ],
54
+ [
55
+ this._dialog,
56
+ "pointerup",
57
+ this.#o
58
+ ],
59
+ [
60
+ this._dialog,
61
+ "pointercancel",
62
+ this.#s
63
+ ]
64
+ ]), this.#a = (e) => {
65
+ this.#r = e.target === this._dialog ? e.pointerId : null;
66
+ }, this.#o = (e) => {
67
+ let t = this.#r === e.pointerId;
68
+ this.#r = null, !(!t || e.target !== this._dialog || !this.lightDismiss) && (this.#n = "outside", this.close());
69
+ }, this.#s = () => {
70
+ this.#r = null;
71
+ }, this.#c = null, this.#m = () => this.settle();
48
72
  }
49
73
  get dialogSize() {
50
74
  return {
@@ -63,38 +87,43 @@ var d = 24, f = c`
63
87
  }
64
88
  #n;
65
89
  #r;
90
+ #i;
91
+ #a;
92
+ #o;
93
+ #s;
94
+ #c;
66
95
  show() {
67
- this.open = !0, this.hasUpdated && this.#c();
96
+ this.open = !0, this.hasUpdated && this.#p();
68
97
  }
69
98
  close() {
70
- this.open = !1, this.hasUpdated && this.#c();
99
+ this.open = !1, this.hasUpdated && this.#p();
71
100
  }
72
101
  updated(e) {
73
- e.has("open") && this.#c(), (e.has("top") || e.has("left")) && this.settle(), this.#t();
102
+ e.has("open") && this.#p(), (e.has("top") || e.has("left")) && this.settle(), this.#t();
74
103
  }
75
- #i() {
104
+ #l() {
76
105
  let e = document.documentElement;
77
106
  return {
78
107
  width: a(e.clientWidth || window.innerWidth, this),
79
108
  height: a(e.clientHeight || window.innerHeight, this)
80
109
  };
81
110
  }
82
- #a(e, t) {
83
- let n = this.#i(), r = a(this._dialog?.offsetWidth ?? 0, this);
111
+ #u(e, t) {
112
+ let n = this.#l(), r = a(this._dialog?.offsetWidth ?? 0, this);
84
113
  return {
85
- x: Math.min(Math.max(e, d - r), n.width - d),
86
- y: Math.min(Math.max(t, 0), Math.max(0, n.height - d))
114
+ x: Math.min(Math.max(e, f - r), n.width - f),
115
+ y: Math.min(Math.max(t, 0), Math.max(0, n.height - f))
87
116
  };
88
117
  }
89
- #o() {
90
- let e = this.#i(), t = this._dialog?.getBoundingClientRect();
118
+ #d() {
119
+ let e = this.#l(), t = this._dialog?.getBoundingClientRect();
91
120
  return {
92
121
  x: (e.width - a(t?.width ?? 0, this)) / 2,
93
122
  y: (e.height - a(t?.height ?? 0, this)) / 2
94
123
  };
95
124
  }
96
125
  placeAt(e, t) {
97
- let n = this.#a(e, t);
126
+ let n = this.#u(e, t);
98
127
  this.left = r(n.x, this), this.top = r(n.y, this);
99
128
  }
100
129
  settle() {
@@ -103,10 +132,10 @@ var d = 24, f = c`
103
132
  let t = this.left != null || this.top != null ? {
104
133
  x: this.left ?? 0,
105
134
  y: this.top ?? 0
106
- } : this.#o(), n = this.#a(t.x, t.y);
135
+ } : this.#d(), n = this.#u(t.x, t.y);
107
136
  e.style.left = i(r(n.x, this)), e.style.top = i(r(n.y, this)), e.style.right = "auto", e.style.bottom = "auto", e.style.margin = "0";
108
137
  }
109
- #s() {
138
+ #f() {
110
139
  let e = this._dialog?.style;
111
140
  if (e) for (let t of [
112
141
  "left",
@@ -116,40 +145,44 @@ var d = 24, f = c`
116
145
  "margin"
117
146
  ]) e.removeProperty(t);
118
147
  }
119
- #c() {
148
+ #p() {
120
149
  let e = this._dialog;
121
- e && (this.open && !e.open ? (this.#r = document.activeElement, e.showModal(), this.settle(), this.#f(e)) : !this.open && e.open && e.close());
150
+ e && (this.open && !e.open ? (this.#c = document.activeElement, e.showModal(), this.settle(), this.#_(e), this.#i.attach()) : !this.open && e.open && e.close());
122
151
  }
123
- #l;
124
- #u;
125
- #d;
126
- #f(e) {
127
- typeof ResizeObserver < "u" && !this.#u && (this.#u = new ResizeObserver(this.#l), this.#u.observe(e)), window.addEventListener("resize", this.#l), this.#d ??= n(this.#l);
152
+ #m;
153
+ #h;
154
+ #g;
155
+ #_(e) {
156
+ typeof ResizeObserver < "u" && !this.#h && (this.#h = new ResizeObserver(this.#m), this.#h.observe(e)), window.addEventListener("resize", this.#m), this.#g ??= n(this.#m);
128
157
  }
129
- #p() {
130
- this.#u?.disconnect(), this.#u = void 0, window.removeEventListener("resize", this.#l), this.#d?.(), this.#d = void 0;
158
+ #v() {
159
+ this.#h?.disconnect(), this.#h = void 0, window.removeEventListener("resize", this.#m), this.#g?.(), this.#g = void 0;
131
160
  }
132
161
  disconnectedCallback() {
133
- super.disconnectedCallback(), this.#p();
162
+ super.disconnectedCallback(), this.#v();
134
163
  let e = this._dialog;
135
164
  if (e?.open) {
136
165
  e.close();
137
- let t = this.#r, n = document.activeElement;
166
+ let t = this.#c, n = document.activeElement;
138
167
  t instanceof HTMLElement && t.isConnected && (n === null || n === document.body) && t.focus();
139
168
  }
140
- this.#r = null;
169
+ this.#c = null;
141
170
  }
142
171
  _onNativeCancel() {
143
172
  this.#n = "escape";
144
173
  }
145
174
  _onNativeClose() {
146
175
  let t = this.#n ?? "close";
147
- this.#n = null, this.#r = null, this.#p(), this.open = !1, this.#s(), e(this, "vf-close", { reason: t });
176
+ this.#n = null, this.#c = null, this.#r = null, this.#i.detach(), this.#v(), this.open = !1, this.#f(), e(this, "vf-close", { reason: t });
148
177
  }
149
178
  };
150
- o([l({
179
+ o([u({
151
180
  type: Boolean,
152
181
  reflect: !0
153
- })], p.prototype, "open", void 0), o([l({ type: Number })], p.prototype, "width", void 0), o([l({ type: Number })], p.prototype, "height", void 0), o([l({ type: Number })], p.prototype, "top", void 0), o([l({ type: Number })], p.prototype, "left", void 0), o([u("dialog")], p.prototype, "_dialog", void 0);
182
+ })], m.prototype, "open", void 0), o([u({ type: Number })], m.prototype, "width", void 0), o([u({ type: Number })], m.prototype, "height", void 0), o([u({ type: Number })], m.prototype, "top", void 0), o([u({ type: Number })], m.prototype, "left", void 0), o([u({
183
+ type: Boolean,
184
+ reflect: !0,
185
+ attribute: "light-dismiss"
186
+ })], m.prototype, "lightDismiss", void 0), o([d("dialog")], m.prototype, "_dialog", void 0);
154
187
  //#endregion
155
- export { p as VfModalDialog, f as modalDialogStyles };
188
+ export { m as VfModalDialog, p as modalDialogStyles };
package/docs/SPEC.md CHANGED
@@ -227,15 +227,15 @@ The desktop-window shell: the classic document window (see DragThing screenshot)
227
227
 
228
228
  #### `vf-dialog` (`VfDialog`, vf-dialog.ts)
229
229
  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).
230
- - **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), `frame?: 'plain'`.
231
- - **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' }`. No backdrop dimming: `::backdrop { background: transparent; }`. **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.
230
+ - **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'`.
231
+ - **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' }`. 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`. **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.
232
232
  - **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`), `padding: 16px`. An optional `buttons` slot renders a bottom-right `vf-button-group` footer that only takes space when populated (equal-width, faces aligned). 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 is a flex column of a `.content` scroll region (heading + default slot; part `content`) over the pinned footer. While the content fits, nothing matches — rendering is pixel-identical to the old block flow. Once it overflows (`ScrollStateController`, the always-a-rail machinery), the region reserves the 16px channel as its own right padding, wears the drawn `vfScrollRail` rail (§4) boxed by a 1px `.scroll-frame` overlay, 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 wrapper 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.
233
233
  - **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.
234
234
  - **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. A `heading` renders as a centered display-face heading at the top of the body (`margin-bottom: 16px`) — the way those dialogs drew their title in content — and `closable` is ignored, there being no bar to carry the widget.
235
235
  - **A11y:** named by its own title patch — or, on the plain frame, its body-top heading; both carry `id="title"` — via `aria-labelledby` when `heading` is set. With no heading there is nothing to point at — `aria-labelledby` would resolve to an empty node and leave the dialog unnamed — so it falls back to `aria-label`, taking `label` if given and otherwise `'Dialog'`. An explicit `label` wins over `heading`. The close box is labeled `Close ${heading}` like `vf-window`'s.
236
236
  - **Slots:** default, `buttons`.
237
237
  - **Parts:** `frame`, `title-bar` (default chrome), `title`, `close-box` (when `closable`), `body`, `content`, `footer`, `buttons`.
238
- - **Events:** `vf-close`.
238
+ - **Events:** `vf-close` (detail `{ reason: 'escape' | 'close' | 'outside' }`).
239
239
 
240
240
  #### The alert box (composed — no component)
241
241
  System 7's fixed modal alert (double frame: 2px outer, 2px gap, 1px inner rule, *with* the hard shadow) is deliberately not shipped as a component. What separates an alert from a modal dialog is a *picture* — the 32×32 icon — and pictures are the consumer's assets, never the library's (see the glyph-sprites note at the top of this spec). An alert box is composed from the shells above: `vf-dialog frame="plain"` with `label` stated (there is no title bar to name it), a row `vf-stack` slotting the consumer's own 32×32 art through `vf-img`, display-face copy (System 7 alerts used chrome type), and the `buttons` slot. The reference page carries the live recipe, composed from `demo/icons/alert.png`.
@@ -228,7 +228,7 @@
228
228
  },
229
229
  {
230
230
  "name": "vf-dialog",
231
- "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`.\n---\n\n\n### **Events:**\n - **vf-close** - Dialog closed. Detail `{ reason: 'escape' | 'close' }`.\n\n### **Slots:**\n - _default_ - Default slot: dialog body content.\n- **buttons** - Optional action buttons. Rendered as a bottom-right `vf-button-group` (equal-width, faces aligned); the footer only takes space when the slot is populated.\n\n### **CSS Properties:**\n - **--vf-dots-pattern** - the windoid bar's dot-grid dither — a 2×2 tile, one black pixel at the origin (`vfDots`; override the whole pattern like `--vf-desktop-pattern`) _(default: undefined)_\n- **--vf-titlebar-height** - window/dialog title bars _(default: undefined)_\n- **--vf-scrollbar-thumb** - scrollbar thumb/elevator (white) _(default: undefined)_\n- **--vf-scrollbar-track** - the scroll trough's base color under the dot-dither (white) _(default: undefined)_\n\n### **CSS Parts:**\n - **frame** - The outer frame (the double frame's 1px rule; the bar and the inner band sit inside it).\n- **title-bar** - The striped title bar (default chrome only).\n- **title** - The centered title patch (or the plain-frame heading).\n- **close-box** - The close widget (`closable`, default chrome only).\n- **body** - The white content area.\n- **content** - The scrolling region inside the body (heading + slotted content, not the footer). Inert while the content fits; over-stuffed, it scrolls under a System 7 rail and becomes a keyboard stop.\n- **footer** - The action row wrapping the buttons.\n- **buttons** - The button group inside the footer.",
231
+ "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`. With `light-dismiss`, a click outside the frame\ncloses it too, with `{ reason: 'outside' }` — for the About box; off by\ndefault, since the classic modal ignored an outside click.\n---\n\n\n### **Events:**\n - **vf-close** - Dialog closed. Detail `{ reason: 'escape' | 'close' | 'outside' }` — `'outside'` only under `light-dismiss`.\n\n### **Slots:**\n - _default_ - Default slot: dialog body content.\n- **buttons** - Optional action buttons. Rendered as a bottom-right `vf-button-group` (equal-width, faces aligned); the footer only takes space when the slot is populated.\n\n### **CSS Properties:**\n - **--vf-dots-pattern** - the windoid bar's dot-grid dither — a 2×2 tile, one black pixel at the origin (`vfDots`; override the whole pattern like `--vf-desktop-pattern`) _(default: undefined)_\n- **--vf-titlebar-height** - window/dialog title bars _(default: undefined)_\n- **--vf-scrollbar-thumb** - scrollbar thumb/elevator (white) _(default: undefined)_\n- **--vf-scrollbar-track** - the scroll trough's base color under the dot-dither (white) _(default: undefined)_\n\n### **CSS Parts:**\n - **frame** - The outer frame (the double frame's 1px rule; the bar and the inner band sit inside it).\n- **title-bar** - The striped title bar (default chrome only).\n- **title** - The centered title patch (or the plain-frame heading).\n- **close-box** - The close widget (`closable`, default chrome only).\n- **body** - The white content area.\n- **content** - The scrolling region inside the body (heading + slotted content, not the footer). Inert while the content fits; over-stuffed, it scrolls under a System 7 rail and becomes a keyboard stop.\n- **footer** - The action row wrapping the buttons.\n- **buttons** - The button group inside the footer.",
232
232
  "attributes": [
233
233
  {
234
234
  "name": "heading",
@@ -274,6 +274,11 @@
274
274
  "name": "left",
275
275
  "description": "Offset from the left of the viewport, in whole system px. See top.",
276
276
  "values": []
277
+ },
278
+ {
279
+ "name": "light-dismiss",
280
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
281
+ "values": []
277
282
  }
278
283
  ],
279
284
  "references": []
@@ -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.5.2",
4
+ "version": "0.5.3",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {
@@ -554,7 +554,7 @@
554
554
  },
555
555
  {
556
556
  "name": "vf-dialog",
557
- "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`.\n---\n\n\n### **Events:**\n - **vf-close** - Dialog closed. Detail `{ reason: 'escape' | 'close' }`.\n\n### **Slots:**\n - _default_ - Default slot: dialog body content.\n- **buttons** - Optional action buttons. Rendered as a bottom-right `vf-button-group` (equal-width, faces aligned); the footer only takes space when the slot is populated.\n\n### **CSS Properties:**\n - **--vf-dots-pattern** - the windoid bar's dot-grid dither — a 2×2 tile, one black pixel at the origin (`vfDots`; override the whole pattern like `--vf-desktop-pattern`) _(default: undefined)_\n- **--vf-titlebar-height** - window/dialog title bars _(default: undefined)_\n- **--vf-scrollbar-thumb** - scrollbar thumb/elevator (white) _(default: undefined)_\n- **--vf-scrollbar-track** - the scroll trough's base color under the dot-dither (white) _(default: undefined)_\n\n### **CSS Parts:**\n - **frame** - The outer frame (the double frame's 1px rule; the bar and the inner band sit inside it).\n- **title-bar** - The striped title bar (default chrome only).\n- **title** - The centered title patch (or the plain-frame heading).\n- **close-box** - The close widget (`closable`, default chrome only).\n- **body** - The white content area.\n- **content** - The scrolling region inside the body (heading + slotted content, not the footer). Inert while the content fits; over-stuffed, it scrolls under a System 7 rail and becomes a keyboard stop.\n- **footer** - The action row wrapping the buttons.\n- **buttons** - The button group inside the footer.",
557
+ "description": "`<vf-dialog>` — the System 7 modal dialog shell.\n\nTwo chromes, one modal lifecycle (native `<dialog>` for top-layer rendering\nand focus trapping, with a fully transparent backdrop — no dimming). Both\nare the same dBoxProc double frame — 1px outer rule, 2px gap, 2px inner\nband, no shadow (vfModalFrame):\n\n- **Default:** the movable modal (movableDBoxProc) — the striped title bar\n set into the top of that frame, with a centered title over a white body.\n Drag the title bar to move it. `closable` adds the standard close box\n (left of the bar) — the HIG's own figures disagree on whether a movable\n modal carries one (Figure 5-1 says yes, Figure 6-1 and the Chapter 6 text\n say no), so the component enables either reading rather than enforcing\n one.\n- **`frame=\"plain\"`:** the modal dialog box — the bare frame, no title bar —\n and immovable, like the original. A `heading` renders as a centered\n display-face heading at the top of the body (the reference art's \"Dialog\n title\"); `closable` is ignored, there being no bar to carry the widget.\n\nOpen it with `show()` (or set the `open` attribute/property); close with\n`close()`. Escape closes it and fires `vf-close` with\n`{ reason: 'escape' }`; the close box and programmatic closing fire\n`{ reason: 'close' }`. With `light-dismiss`, a click outside the frame\ncloses it too, with `{ reason: 'outside' }` — for the About box; off by\ndefault, since the classic modal ignored an outside click.\n---\n\n\n### **Events:**\n - **vf-close** - Dialog closed. Detail `{ reason: 'escape' | 'close' | 'outside' }` — `'outside'` only under `light-dismiss`.\n\n### **Slots:**\n - _default_ - Default slot: dialog body content.\n- **buttons** - Optional action buttons. Rendered as a bottom-right `vf-button-group` (equal-width, faces aligned); the footer only takes space when the slot is populated.\n\n### **CSS Properties:**\n - **--vf-dots-pattern** - the windoid bar's dot-grid dither — a 2×2 tile, one black pixel at the origin (`vfDots`; override the whole pattern like `--vf-desktop-pattern`) _(default: undefined)_\n- **--vf-titlebar-height** - window/dialog title bars _(default: undefined)_\n- **--vf-scrollbar-thumb** - scrollbar thumb/elevator (white) _(default: undefined)_\n- **--vf-scrollbar-track** - the scroll trough's base color under the dot-dither (white) _(default: undefined)_\n\n### **CSS Parts:**\n - **frame** - The outer frame (the double frame's 1px rule; the bar and the inner band sit inside it).\n- **title-bar** - The striped title bar (default chrome only).\n- **title** - The centered title patch (or the plain-frame heading).\n- **close-box** - The close widget (`closable`, default chrome only).\n- **body** - The white content area.\n- **content** - The scrolling region inside the body (heading + slotted content, not the footer). Inert while the content fits; over-stuffed, it scrolls under a System 7 rail and becomes a keyboard stop.\n- **footer** - The action row wrapping the buttons.\n- **buttons** - The button group inside the footer.",
558
558
  "doc-url": "",
559
559
  "attributes": [
560
560
  {
@@ -601,6 +601,11 @@
601
601
  "name": "left",
602
602
  "description": "Offset from the left of the viewport, in whole system px. See top.",
603
603
  "value": { "type": "number | null | undefined" }
604
+ },
605
+ {
606
+ "name": "light-dismiss",
607
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
608
+ "value": { "type": "boolean", "default": "false" }
604
609
  }
605
610
  ],
606
611
  "slots": [
@@ -613,7 +618,7 @@
613
618
  "events": [
614
619
  {
615
620
  "name": "vf-close",
616
- "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' }`."
621
+ "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' | 'outside' }` — `'outside'` only under `light-dismiss`."
617
622
  }
618
623
  ],
619
624
  "js": {
@@ -662,12 +667,17 @@
662
667
  "name": "left",
663
668
  "description": "Offset from the left of the viewport, in whole system px. See top.",
664
669
  "type": "number | null | undefined"
670
+ },
671
+ {
672
+ "name": "lightDismiss",
673
+ "description": "Close on a click outside the frame — a press on the backdrop — and fire\n`vf-close` with `{ reason: 'outside' }`. Off by default: the classic modal\nignored an outside click (and beeped), and a dialog that asks a question\nshould keep ignoring it. Opt in for the About box and the splash, the\ndialogs the classic Mac did dismiss on a click.\n\nBoth halves of the click have to land outside — the two-step the\nplatform's own `closedby=\"any\"` light dismiss uses — so a press that\nstarts on a control and slides off the frame, or a title-bar drag\nreleased past it, leaves the dialog open. The press is consumed either\nway: a modal's backdrop lets nothing beneath it see the click, so\ndismissing the About box does not also select whatever was under the\npointer. Escape closes the dialog regardless, as it always has.",
674
+ "type": "boolean"
665
675
  }
666
676
  ],
667
677
  "events": [
668
678
  {
669
679
  "name": "vf-close",
670
- "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' }`."
680
+ "description": "Dialog closed. Detail `{ reason: 'escape' | 'close' | 'outside' }` — `'outside'` only under `light-dismiss`."
671
681
  }
672
682
  ]
673
683
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vintage-frames",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",
@@ -73,6 +73,7 @@
73
73
  "probe:menus": "node scripts/probe-menu-scale.mjs",
74
74
  "verify:desktop": "node scripts/verify-desktop-upgrade.mjs",
75
75
  "verify:desktop-activate": "node scripts/verify-desktop-activate.mjs",
76
+ "verify:dialog": "node scripts/verify-dialog.mjs",
76
77
  "verify:list-focus": "node scripts/verify-list-focus.mjs",
77
78
  "verify:control-heights": "node scripts/verify-control-heights.mjs",
78
79
  "verify:list-typeahead": "node scripts/verify-list-typeahead.mjs",