vintage-frames 0.1.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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/custom-elements.json +19807 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  5. package/dist/chrome.d.ts +73 -0
  6. package/dist/chrome.js +94 -0
  7. package/dist/components/vf-button-group.d.ts +50 -0
  8. package/dist/components/vf-button-group.js +68 -0
  9. package/dist/components/vf-button.d.ts +169 -0
  10. package/dist/components/vf-button.js +234 -0
  11. package/dist/components/vf-checkbox.d.ts +60 -0
  12. package/dist/components/vf-checkbox.js +135 -0
  13. package/dist/components/vf-container.d.ts +95 -0
  14. package/dist/components/vf-container.js +86 -0
  15. package/dist/components/vf-desktop.d.ts +243 -0
  16. package/dist/components/vf-desktop.js +310 -0
  17. package/dist/components/vf-dialog.d.ts +105 -0
  18. package/dist/components/vf-dialog.js +280 -0
  19. package/dist/components/vf-fieldset.d.ts +34 -0
  20. package/dist/components/vf-fieldset.js +76 -0
  21. package/dist/components/vf-grid.d.ts +124 -0
  22. package/dist/components/vf-grid.js +133 -0
  23. package/dist/components/vf-icon.d.ts +356 -0
  24. package/dist/components/vf-icon.js +507 -0
  25. package/dist/components/vf-img.d.ts +63 -0
  26. package/dist/components/vf-img.js +65 -0
  27. package/dist/components/vf-label.d.ts +117 -0
  28. package/dist/components/vf-label.js +151 -0
  29. package/dist/components/vf-list-item.d.ts +48 -0
  30. package/dist/components/vf-list-item.js +98 -0
  31. package/dist/components/vf-list.d.ts +77 -0
  32. package/dist/components/vf-list.js +256 -0
  33. package/dist/components/vf-menu-bar.d.ts +64 -0
  34. package/dist/components/vf-menu-bar.js +236 -0
  35. package/dist/components/vf-menu-item.d.ts +91 -0
  36. package/dist/components/vf-menu-item.js +252 -0
  37. package/dist/components/vf-menu.d.ts +95 -0
  38. package/dist/components/vf-menu.js +346 -0
  39. package/dist/components/vf-number-field.d.ts +60 -0
  40. package/dist/components/vf-number-field.js +248 -0
  41. package/dist/components/vf-option.d.ts +49 -0
  42. package/dist/components/vf-option.js +119 -0
  43. package/dist/components/vf-paragraph.d.ts +73 -0
  44. package/dist/components/vf-paragraph.js +66 -0
  45. package/dist/components/vf-progress-bar.d.ts +69 -0
  46. package/dist/components/vf-progress-bar.js +272 -0
  47. package/dist/components/vf-radio-group.d.ts +109 -0
  48. package/dist/components/vf-radio-group.js +105 -0
  49. package/dist/components/vf-radio.d.ts +59 -0
  50. package/dist/components/vf-radio.js +135 -0
  51. package/dist/components/vf-scroll-area.d.ts +75 -0
  52. package/dist/components/vf-scroll-area.js +124 -0
  53. package/dist/components/vf-select.d.ts +334 -0
  54. package/dist/components/vf-select.js +658 -0
  55. package/dist/components/vf-separator.d.ts +38 -0
  56. package/dist/components/vf-separator.js +55 -0
  57. package/dist/components/vf-slider.d.ts +80 -0
  58. package/dist/components/vf-slider.js +255 -0
  59. package/dist/components/vf-stack.d.ts +188 -0
  60. package/dist/components/vf-stack.js +168 -0
  61. package/dist/components/vf-swatch.d.ts +98 -0
  62. package/dist/components/vf-swatch.js +201 -0
  63. package/dist/components/vf-text-area.d.ts +77 -0
  64. package/dist/components/vf-text-area.js +121 -0
  65. package/dist/components/vf-text-field.d.ts +53 -0
  66. package/dist/components/vf-text-field.js +79 -0
  67. package/dist/components/vf-window.d.ts +157 -0
  68. package/dist/components/vf-window.js +377 -0
  69. package/dist/cursor-art.d.ts +37 -0
  70. package/dist/cursor-art.js +41 -0
  71. package/dist/cursor.d.ts +49 -0
  72. package/dist/cursor.js +171 -0
  73. package/dist/define.d.ts +45 -0
  74. package/dist/define.js +15 -0
  75. package/dist/document-listeners.d.ts +62 -0
  76. package/dist/document-listeners.js +29 -0
  77. package/dist/drag.d.ts +49 -0
  78. package/dist/drag.js +30 -0
  79. package/dist/events.d.ts +79 -0
  80. package/dist/events.js +25 -0
  81. package/dist/focus-modality.d.ts +94 -0
  82. package/dist/focus-modality.js +56 -0
  83. package/dist/form-control.d.ts +247 -0
  84. package/dist/form-control.js +127 -0
  85. package/dist/glyphs.d.ts +117 -0
  86. package/dist/glyphs.js +100 -0
  87. package/dist/grid-snap.d.ts +73 -0
  88. package/dist/grid-snap.js +135 -0
  89. package/dist/index.d.ts +145 -0
  90. package/dist/index.js +71 -0
  91. package/dist/menu-press.d.ts +60 -0
  92. package/dist/menu-press.js +97 -0
  93. package/dist/modal-dialog.d.ts +148 -0
  94. package/dist/modal-dialog.js +155 -0
  95. package/dist/motion.d.ts +72 -0
  96. package/dist/motion.js +22 -0
  97. package/dist/number.d.ts +24 -0
  98. package/dist/number.js +12 -0
  99. package/dist/open-art.d.ts +40 -0
  100. package/dist/open-art.js +35 -0
  101. package/dist/pixel-frame.d.ts +102 -0
  102. package/dist/pixel-frame.js +77 -0
  103. package/dist/popup-overflow.d.ts +157 -0
  104. package/dist/popup-overflow.js +45 -0
  105. package/dist/position.d.ts +187 -0
  106. package/dist/position.js +74 -0
  107. package/dist/scale.d.ts +259 -0
  108. package/dist/scale.js +133 -0
  109. package/dist/scroll-rail.d.ts +112 -0
  110. package/dist/scroll-rail.js +194 -0
  111. package/dist/scroll-state.d.ts +113 -0
  112. package/dist/scroll-state.js +55 -0
  113. package/dist/size.d.ts +47 -0
  114. package/dist/size.js +30 -0
  115. package/dist/styles/base.d.ts +38 -0
  116. package/dist/styles/body-font.d.ts +7 -0
  117. package/dist/styles/body-font.js +9 -0
  118. package/dist/styles/display-font.d.ts +7 -0
  119. package/dist/styles/display-font.js +9 -0
  120. package/dist/styles/recipes/body-face.d.ts +14 -0
  121. package/dist/styles/recipes/body-face.js +6 -0
  122. package/dist/styles/recipes/display-face.d.ts +23 -0
  123. package/dist/styles/recipes/display-face.js +10 -0
  124. package/dist/styles/recipes/field.d.ts +20 -0
  125. package/dist/styles/recipes/field.js +68 -0
  126. package/dist/styles/recipes/focus.d.ts +76 -0
  127. package/dist/styles/recipes/focus.js +39 -0
  128. package/dist/styles/recipes/host.d.ts +5 -0
  129. package/dist/styles/recipes/host.js +62 -0
  130. package/dist/styles/recipes/pattern.d.ts +80 -0
  131. package/dist/styles/recipes/pattern.js +82 -0
  132. package/dist/styles/recipes/scroll-rail.d.ts +69 -0
  133. package/dist/styles/recipes/scroll-rail.js +222 -0
  134. package/dist/styles/recipes/shadow.d.ts +13 -0
  135. package/dist/styles/recipes/shadow.js +5 -0
  136. package/dist/styles/recipes/static-text.d.ts +24 -0
  137. package/dist/styles/recipes/static-text.js +17 -0
  138. package/dist/styles/recipes/surface.d.ts +28 -0
  139. package/dist/styles/recipes/surface.js +27 -0
  140. package/dist/styles/recipes/tile.d.ts +122 -0
  141. package/dist/styles/recipes/tile.js +40 -0
  142. package/dist/styles/recipes/title-bar.d.ts +49 -0
  143. package/dist/styles/recipes/title-bar.js +134 -0
  144. package/dist/styles/recipes/toggle.d.ts +10 -0
  145. package/dist/styles/recipes/toggle.js +20 -0
  146. package/dist/styles/register-embedded-font.d.ts +64 -0
  147. package/dist/styles/register-embedded-font.js +30 -0
  148. package/dist/text-control.d.ts +131 -0
  149. package/dist/text-control.js +83 -0
  150. package/dist/tile-grid.d.ts +111 -0
  151. package/dist/tile-grid.js +53 -0
  152. package/dist/toggle-control.d.ts +60 -0
  153. package/dist/toggle-control.js +41 -0
  154. package/dist/track-width.d.ts +42 -0
  155. package/dist/track-width.js +30 -0
  156. package/dist/type-ahead.d.ts +35 -0
  157. package/dist/type-ahead.js +20 -0
  158. package/dist/zoom.d.ts +204 -0
  159. package/dist/zoom.js +105 -0
  160. package/docs/SPEC.md +2471 -0
  161. package/editor/vscode.html-custom-data.json +1320 -0
  162. package/editor/web-types.json +3237 -0
  163. package/package.json +118 -0
@@ -0,0 +1,60 @@
1
+ import { LitElement } from 'lit';
2
+ type Constructor<T = object> = new (...args: any[]) => T;
3
+ type AbstractConstructor<T = object> = abstract new (...args: any[]) => T;
4
+ /**
5
+ * The mixin's own surface: what a concrete toggle must supply, and what it
6
+ * gets back. Spelled out as a `declare class` because TypeScript cannot name
7
+ * the anonymous class a mixin returns when emitting declarations (TS4094) —
8
+ * and that is unavoidable here rather than a consequence of our own members,
9
+ * since LitElement's `render`/`updated`/`willUpdate`/`createRenderRoot` are
10
+ * themselves protected. This is the shape Lit documents for typed mixins.
11
+ *
12
+ * Keep it in sync with the implementation below; it is the type half of one
13
+ * declaration.
14
+ */
15
+ export declare abstract class VfToggleControlInterface extends LitElement {
16
+ abstract checked: boolean;
17
+ protected abstract get toggleInternals(): ElementInternals;
18
+ protected abstract get toggleDisabled(): boolean;
19
+ protected abstract activate(): void;
20
+ protected get externallyCoordinated(): boolean;
21
+ protected syncToggleState(): void;
22
+ }
23
+ /**
24
+ * The interaction skeleton shared by the kit's two toggle controls
25
+ * (`vf-checkbox`, `vf-radio`) — everything they did identically apart from
26
+ * their glyphs and their toggle-vs-select semantics:
27
+ *
28
+ * - the click / Space→activate wiring, including the `event.repeat` guard that
29
+ * stops a *held* Space from re-firing on every ~30ms tick;
30
+ * - the single disabled guard every activation passes through, so "a disabled
31
+ * toggle never activates" is guaranteed in one place (the same shape as
32
+ * {@link VfFormControl.syncFormValue}'s disabled funnel for form values);
33
+ * - `aria-checked` / `aria-disabled` mirroring in `updated()`;
34
+ * - the self-managed host tabindex, and the ownership latch that keeps it
35
+ * honest across reconnects (see {@link consumerOwnsTabIndex}).
36
+ *
37
+ * It is a **mixin, not a plain base class**, because the two controls sit on
38
+ * different bases and must stay there: `vf-checkbox` extends
39
+ * {@link VfFormControl} (it submits a value under a name), while `vf-radio` is
40
+ * deliberately *not* form-associated — a lone radio has no value to submit, and
41
+ * its enclosing `vf-radio-group` is the form-associated surface. Unifying them
42
+ * under one concrete base would mean making radio form-associated, which is a
43
+ * behavior change (a bare `<vf-radio>` would start contributing to `FormData`),
44
+ * not a refactor.
45
+ *
46
+ * A concrete control supplies four things: {@link checked}, its
47
+ * {@link toggleInternals}, its {@link toggleDisabled} rule, and {@link activate}.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * class VfCheckbox extends VfToggleControl(VfFormControl) {
52
+ * \@property({ type: Boolean, reflect: true }) checked = false
53
+ * protected override get toggleInternals() { return this.internals }
54
+ * protected override get toggleDisabled() { return this.isDisabled }
55
+ * protected override activate() { ... }
56
+ * }
57
+ * ```
58
+ */
59
+ export declare const VfToggleControl: <T extends Constructor<LitElement>>(Base: T) => AbstractConstructor<VfToggleControlInterface> & T;
60
+ export {};
@@ -0,0 +1,41 @@
1
+ import { ScaleController as e } from "./scale.js";
2
+ import { GridSnapController as t } from "./grid-snap.js";
3
+ import { deferActivation as n } from "./events.js";
4
+ //#region src/toggle-control.ts
5
+ var r = (r) => {
6
+ class i extends r {
7
+ get externallyCoordinated() {
8
+ return !1;
9
+ }
10
+ #e;
11
+ constructor(...r) {
12
+ super(...r), this.scale = new e(this), this.gridSnap = new t(this), this.#e = null, this.selfManagedTabIndex = !1, this.#n = (e) => {
13
+ n(this, e, () => this.#t());
14
+ }, this.#r = (e) => {
15
+ e.key === " " && (e.preventDefault(), !e.repeat && this.dispatchEvent(new MouseEvent("click", {
16
+ bubbles: !0,
17
+ composed: !0,
18
+ cancelable: !0
19
+ })));
20
+ }, this.addEventListener("click", this.#n), this.addEventListener("keydown", this.#r);
21
+ }
22
+ connectedCallback() {
23
+ super.connectedCallback(), this.#e ??= this.hasAttribute("tabindex"), this.selfManagedTabIndex = !this.#e && !this.externallyCoordinated, this.selfManagedTabIndex && (this.tabIndex = this.toggleDisabled ? -1 : 0);
24
+ }
25
+ updated(e) {
26
+ super.updated(e), this.syncToggleState();
27
+ }
28
+ syncToggleState() {
29
+ let e = this.toggleInternals, t = this.toggleDisabled;
30
+ e.ariaChecked = this.checked ? "true" : "false", e.ariaDisabled = t ? "true" : "false", this.selfManagedTabIndex && (this.tabIndex = t ? -1 : 0);
31
+ }
32
+ #t() {
33
+ this.toggleDisabled || this.activate();
34
+ }
35
+ #n;
36
+ #r;
37
+ }
38
+ return i;
39
+ };
40
+ //#endregion
41
+ export { r as VfToggleControl };
@@ -0,0 +1,42 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /**
3
+ * Measured width of an indicator's track box, for components that draw their
4
+ * own 1-bit geometry into it.
5
+ *
6
+ * `vf-slider` and `vf-progress-bar` can't express their fill in CSS: both snap
7
+ * it to whole *system* pixels so the 1px leading edge (and the slider's thumb
8
+ * sprite) lands on the device grid instead of fringing, which means they need
9
+ * the track's width as a number before they can render. This controller
10
+ * supplies it — a {@link ResizeObserver} on the track box, floored to a whole
11
+ * CSS px, requesting a host update whenever it changes.
12
+ *
13
+ * Wiring the observer is the fiddly part, and it is the reason this is a
14
+ * controller rather than two copies of the same twenty lines: the track lives
15
+ * in the shadow root, so it does not exist on the host's *first* connect but
16
+ * does on every later one. Both components previously called their observe
17
+ * helper from `connectedCallback` *and* `firstUpdated`, where the first-mount
18
+ * call is a guarded no-op that exists only so a reconnect re-observes. Here
19
+ * that is one entry point driven by the controller lifecycle, which is also
20
+ * why re-observing is skipped when the element hasn't changed: `observe()` on
21
+ * an already-observed element re-fires the initial callback, and `hostUpdated`
22
+ * runs on every update.
23
+ */
24
+ export declare class TrackWidthController implements ReactiveController {
25
+ private readonly host;
26
+ private readonly getTrack;
27
+ private resizeObserver?;
28
+ private observed;
29
+ private measured;
30
+ constructor(host: ReactiveControllerHost, getTrack: () => HTMLElement | null | undefined);
31
+ /**
32
+ * Content width of the track in CSS px (already scaled — convert with
33
+ * `toSys()` before doing system-pixel math). `0` until first measured, which
34
+ * each component treats as "not measured yet" and falls back from.
35
+ */
36
+ get width(): number;
37
+ hostConnected(): void;
38
+ hostUpdated(): void;
39
+ hostDisconnected(): void;
40
+ /** (Re-)attach the observer to the track box, once per distinct element. */
41
+ private wire;
42
+ }
@@ -0,0 +1,30 @@
1
+ //#region src/track-width.ts
2
+ var e = class {
3
+ constructor(e, t) {
4
+ this.host = e, this.getTrack = t, this.observed = null, this.measured = 0, e.addController(this);
5
+ }
6
+ get width() {
7
+ return this.measured;
8
+ }
9
+ hostConnected() {
10
+ this.wire();
11
+ }
12
+ hostUpdated() {
13
+ this.wire();
14
+ }
15
+ hostDisconnected() {
16
+ this.resizeObserver?.disconnect(), this.observed = null;
17
+ }
18
+ wire() {
19
+ if (typeof ResizeObserver > "u") return;
20
+ let e = this.getTrack();
21
+ !e || e === this.observed || (this.resizeObserver ??= new ResizeObserver((e) => {
22
+ let t = e[0];
23
+ if (!t) return;
24
+ let n = Math.floor(t.contentRect.width);
25
+ n !== this.measured && (this.measured = n, this.host.requestUpdate());
26
+ }), this.resizeObserver.observe(e), this.observed = e);
27
+ }
28
+ };
29
+ //#endregion
30
+ export { e as TrackWidthController };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Classic Finder first-letter type-ahead, as a shared buffer.
3
+ *
4
+ * Keystrokes accumulate into a prefix; {@link TypeAheadBuffer.feed} returns
5
+ * the index of the next entry whose text starts with it. The search wraps,
6
+ * skips disabled entries, re-tests the current entry while the prefix grows,
7
+ * and cycles the entries sharing a first letter when one character is
8
+ * repeated. The prefix resets after {@link TYPEAHEAD_TIMEOUT_MS} of silence.
9
+ *
10
+ * Lifted out of `vf-list` so `vf-select` and the menus run the same model
11
+ * (the way `runSelectionBlink` was lifted into src/motion.ts): each host keeps
12
+ * its own buffer and decides what a match does — select, highlight, or focus.
13
+ */
14
+ /**
15
+ * How long a type-ahead prefix stays open before the buffer resets, so the
16
+ * next keystroke starts a fresh search.
17
+ */
18
+ export declare const TYPEAHEAD_TIMEOUT_MS = 1000;
19
+ /** One searchable row, in the caller's own order. */
20
+ export interface TypeAheadEntry {
21
+ text: string;
22
+ disabled: boolean;
23
+ }
24
+ export declare class TypeAheadBuffer {
25
+ #private;
26
+ /**
27
+ * Feeds one printable key and returns the index of the entry the grown
28
+ * prefix now matches, or -1 for none. `current` is the caller's keyboard
29
+ * cursor (-1 for no cursor): a fresh prefix looks past it, a growing prefix
30
+ * re-tests it so the match can hold as the user keeps typing.
31
+ */
32
+ feed(key: string, current: number, entries: readonly TypeAheadEntry[]): number;
33
+ /** Clears the pending prefix and its timer. */
34
+ reset(): void;
35
+ }
@@ -0,0 +1,20 @@
1
+ //#region src/type-ahead.ts
2
+ var e = 1e3, t = class {
3
+ #e = "";
4
+ #t;
5
+ feed(t, n, r) {
6
+ if (r.length === 0) return -1;
7
+ this.#e += t.toLowerCase(), this.#t !== void 0 && window.clearTimeout(this.#t), this.#t = window.setTimeout(() => this.reset(), e);
8
+ let i = this.#e, a = i.length > 1 && [...i].every((e) => e === i[0]), o = a ? i[0] : i, s = i.length === 1 || a ? n + 1 : Math.max(n, 0);
9
+ for (let e = 0; e < r.length; e++) {
10
+ let t = (s + e) % r.length, n = r[t];
11
+ if (!(!n || n.disabled) && n.text.trim().toLowerCase().startsWith(o)) return t;
12
+ }
13
+ return -1;
14
+ }
15
+ reset() {
16
+ this.#t !== void 0 && window.clearTimeout(this.#t), this.#t = void 0, this.#e = "";
17
+ }
18
+ };
19
+ //#endregion
20
+ export { e as TYPEAHEAD_TIMEOUT_MS, t as TypeAheadBuffer };
package/dist/zoom.d.ts ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Browser-zoom tracking — the zoom half of the display-scale model.
3
+ *
4
+ * Every component renders one system pixel (the 1-bit art unit) as a whole
5
+ * number of device pixels, and that number is *derived from the display*, not
6
+ * fixed: the Macintosh pixel this art was drawn for is 1/72 inch, so the target
7
+ * is the whole device-pixel count that lands nearest that size —
8
+ * `clamp(round(trueDpr × 96/72), 1, 24)`, where 96 is CSS's reference dpi and
9
+ * `trueDpr` is device px per CSS px (see {@link devicePxPerSystemPx} for the
10
+ * worked displays). A 2× Retina display works out to 3, which is where the
11
+ * kit's old hard-coded 3 came from — it was that one display's answer written
12
+ * down as a constant, 3× too large on a 1× monitor and a quarter short on a 3×
13
+ * one. Zoom needs no separate term: it multiplies device px per CSS px, which
14
+ * is what zoom *is*, so it arrives inside `trueDpr` and the art grows *with*
15
+ * the page instead of un-zooming itself back to physical size. Always whole
16
+ * device pixels, because the layout contract's rule 1 is not negotiable.
17
+ *
18
+ * The work is knowing the zoom at all. Zoom multiplies device-px-per-CSS-px in
19
+ * every engine — that is what zoom is — but the engines differ in whether they
20
+ * report it: Chrome and Firefox fold it into `devicePixelRatio`, Safari pins
21
+ * `devicePixelRatio` to the hardware and moves `innerWidth` instead. So the
22
+ * tracker watches two signals:
23
+ *
24
+ * 1. `devicePixelRatio`, via a resolution media query re-armed at each new
25
+ * value. A dpr change is *either* zoom or the window moving to a
26
+ * different-density display, and the two must not be confused — a monitor
27
+ * move already renders perfectly today and must keep doing so. The
28
+ * discriminator: page zoom moves neither the window nor the screen's
29
+ * logical geometry, so a `screen.*`/`screenX/Y` signature that is
30
+ * unchanged (or uniformly scaled by the dpr ratio, for engines that
31
+ * report those members in zoom-affected CSS px) means zoom; anything else
32
+ * means a display change, which rebases the baseline and reports nothing.
33
+ * 2. `innerWidth`/`innerHeight` against `outerWidth`/`outerHeight`, for
34
+ * Safari: zoom rescales the inner (CSS-px) viewport on both axes by the
35
+ * same factor while the outer (screen-px) window holds still. A change in
36
+ * the outer size is a real window resize and rebases; a one-axis inner
37
+ * change (an edge drag, devtools docking, a sidebar) is never zoom and
38
+ * rebases; a both-axes change reads as zoom only when both axes land on
39
+ * the same {@link ZOOM_LADDER} level. A width-derived zoom is an
40
+ * inference, unlike path 1's engine-stated density, so anything it can't
41
+ * match to a real zoom level rebases rather than reports (see
42
+ * {@link onResize} for what accepting a raw ratio used to cost).
43
+ *
44
+ * Once path 1 has classified a change as zoom, the engine is known to fold
45
+ * zoom into dpr and path 2 is switched off for the session
46
+ * ({@link dprTracksZoom}) — killing its false positives wholesale.
47
+ *
48
+ * Like `focus-modality.ts`, this is a module-scoped singleton with refcounted
49
+ * listeners: the first {@link onZoomChange} subscriber starts the tracker, the
50
+ * last release stops it. The baselines are sampled at module evaluation — not
51
+ * at first subscribe — so a lazily imported kit still dates them from page
52
+ * load, which is the closest thing to "100%" a page can observe. A page loaded
53
+ * *already* zoomed (Chrome persists zoom per origin) reads that as its 100%;
54
+ * `resetZoomBaseline()` is the escape hatch, and so is a reload at 100%.
55
+ *
56
+ * Pinch zoom is out of scope by design: `visualViewport.scale` magnifies
57
+ * already-rasterized output at composite time, changes no rasterization
58
+ * density, and is not reported by either path.
59
+ *
60
+ * Everything here is reported *quantized*: measured zoom snaps to the nearest
61
+ * ladder level browsers actually offer (within 2%), so a zoom that sits exactly
62
+ * on a rounding threshold can't flutter between two device-px targets on a
63
+ * width-derived 1.4998 vs 1.5001. The thresholds move with the display — the
64
+ * target flips where `96/72 × trueDpr` hits a half — and on a 1.5× display
65
+ * (Windows at 150%) two real ladder rungs, 75% and 125%, land exactly on one.
66
+ */
67
+ /** The screen the art was drawn for: one system pixel is 1/72 inch. */
68
+ export declare const CLASSIC_DPI = 72;
69
+ /**
70
+ * CSS's reference pixel — 1 CSS px = 1/96 inch — which is the only density
71
+ * anchor the platform offers. `devicePixelRatio` is device px per *CSS* px, so
72
+ * `96 × dpr` is the display's density as far as a browser will say it; nothing
73
+ * reports a panel's real ppi (a `resolution` media query in `dpi` is defined as
74
+ * 96 × dppx — the same number wearing different units).
75
+ *
76
+ * Real hardware differs from the nominal figure, and it differs in the kit's
77
+ * favor: macOS lays a Retina display out at ~109 logical dpi, so its dpr 2 is
78
+ * really ~218 ppi. Both anchors round to the same whole target there (218/72 =
79
+ * 3.03, 192/72 = 2.67), while a *measured* ppi would not — a 254-ppi MacBook
80
+ * Pro panel would ask for 4 and render the art larger than the iMac beside it.
81
+ * The nominal anchor is the stable thing to compute from.
82
+ */
83
+ export declare const CSS_REFERENCE_DPI = 96;
84
+ /** One system pixel in CSS px, before rounding to whole device px: 96/72. */
85
+ export declare const SYSTEM_PX_IN_CSS_PX: number;
86
+ /**
87
+ * ONE THING THIS DELIBERATELY DOES NOT DO, because it was tried and was worse.
88
+ *
89
+ * A whole target does not make `target / trueDpr` — the `--vf-scale` every
90
+ * metric multiplies by — a length the *engine* can hold: Blink lays out in 1/64
91
+ * CSS px, so a scale of 4/3 (a 3× display, or a 2× one at 150% zoom) quantizes
92
+ * to 85/64 and a 7-system-px gap measures 27.98 device px instead of 28. Over a
93
+ * repeated fill that error accumulates, which is what takes the desktop dither
94
+ * to 38% mid-gray at that scale.
95
+ *
96
+ * Preferring counts whose scale IS holdable (multiples of a quarter CSS px)
97
+ * fixes that and breaks something worse: the holdable set depends on the
98
+ * *denominator* of trueDpr, which jumps around, so the target stops being
99
+ * monotonic. On a 2× display that shipped 3 device px at 100%, 5 at 125% and 3
100
+ * again at 150% — zooming in made the art smaller. Nothing rescues it either:
101
+ * at 110% zoom (trueDpr 2.2) the smallest holdable count is 11, four times the
102
+ * art's size, so no policy can be both holdable and near true size everywhere.
103
+ *
104
+ * So the rounding stands, and sub-quantum drift is accepted as the engine's
105
+ * price for a fractional density — the same price the kit paid at every
106
+ * fractional zoom before any of this. Where it shows is tiled art; the fix
107
+ * belongs in those two dither tiles, not in the size of every component.
108
+ */
109
+ /**
110
+ * Zoom levels browsers actually offer — Chrome's set plus Safari's 85% and
111
+ * 115%. Two consumers, two strictnesses: path 1 snaps its dpr-derived zoom to
112
+ * the nearest entry within {@link LADDER_TOLERANCE} and otherwise trusts it
113
+ * raw (the engine measured it — Firefox's 120/133/170% land between entries
114
+ * and pass through, which is fine), while path 2 accepts ONLY these levels:
115
+ * its width-derived measurement is an inference, and every real zoom it can
116
+ * observe is a Safari one, so Safari's own steps must all be entries.
117
+ */
118
+ export declare const ZOOM_LADDER: number[];
119
+ /**
120
+ * Snap a measured zoom to the nearest {@link ZOOM_LADDER} entry within 2%,
121
+ * else return it unchanged (Firefox steps and OS-level zoom can land off the
122
+ * ladder). Exists because the device-px target rounds, so some zoom level on
123
+ * any given display sits exactly on a threshold: without snapping, a
124
+ * width-derived 1.4998 and 1.5001 on a 2× display give 4 and 5 device px — a
125
+ * 25% size flip — on alternating resize events.
126
+ */
127
+ export declare function quantizeZoom(raw: number): number;
128
+ /**
129
+ * Device px per system px the kit aims for on a display of `trueDpr`: the whole
130
+ * count nearest the classic 1/72 inch — `round(96/72 × trueDpr)` — clamped to
131
+ * [1, 24].
132
+ *
133
+ * display ideal target --vf-scale
134
+ * 1× 1.33 1 1
135
+ * 1.25× (Win 125%) 1.67 2 1.6
136
+ * 1.5× (Win 150%) 2.0 2 1.333
137
+ * 2× Retina 2.67 3 1.5
138
+ * 3× 4.0 4 1.333
139
+ *
140
+ * Monotonic by construction: `round` of an increasing quantity never goes
141
+ * backwards, so a denser display — or a deeper zoom, which is the same thing —
142
+ * never renders the art smaller.
143
+ *
144
+ * The 2× row is where the kit's old hard-coded 3 came from — one display's
145
+ * answer written down as a constant. The 1× row is why that was wrong: it
146
+ * rendered every system pixel 3 device px wide on a display where 1.33 is true,
147
+ * three times the size the art was drawn at.
148
+ *
149
+ * The target is a step function, so zoom moves it in steps, and *not moving* is
150
+ * a correct answer: 100% and 125% on a 2× display both round to 3, so the art
151
+ * holds its size while the copy around it grows. Rounding to the nearest whole
152
+ * count is the whole contract — bending it to make zoom feel responsive would
153
+ * put the art off the device grid, which is the one thing that never bends.
154
+ *
155
+ * Pass {@link truePixelRatio}, not `devicePixelRatio`: zoom belongs in this
156
+ * number. A 2× display at 200% zoom is `trueDpr` 4 → 5 device px per system px,
157
+ * so the art grows with the page. `Math.round` ties go *up* (4.5 → 5)
158
+ * deliberately: a zoom-driven accessibility response should err toward larger,
159
+ * and the representability step prefers the larger count for the same reason.
160
+ *
161
+ * Rounding is what costs physical accuracy, and it is bounded at half a device
162
+ * pixel per system pixel: a 1× monitor renders 25% small, a 2× display 12%
163
+ * large, a 1.5× and a 3× one exact. Nothing does better while every system
164
+ * pixel is a whole number of device pixels, and that is the invariant — art off
165
+ * the device grid is not the art.
166
+ */
167
+ export declare function devicePxPerSystemPx(trueDpr: number): number;
168
+ /** Page zoom relative to load, quantized to the ladder. 1 until observed otherwise. */
169
+ export declare function getZoom(): number;
170
+ /**
171
+ * Device px per CSS px *right now* — the number every "snap to the device
172
+ * grid" computation must divide by. `window.devicePixelRatio` is that number
173
+ * only in engines that fold zoom into it; Safari's is simply wrong about the
174
+ * current rasterization density at any non-100% zoom. Reading the live dpr and
175
+ * folding the tracked zoom back in is correct in both:
176
+ *
177
+ * Chrome/Firefox (latched) dpr already includes zoom → dpr
178
+ * Safari (never latches) dpr is the hardware → dpr × zoom
179
+ *
180
+ * The live read matters: a monitor move changes the true density whether or
181
+ * not the tracker is running, and this stays exactly as correct as the old
182
+ * direct `devicePixelRatio` read in that case (zoom is 1 until tracked).
183
+ */
184
+ export declare function truePixelRatio(): number;
185
+ type ZoomListener = (zoom: number) => void;
186
+ /**
187
+ * Subscribe to quantized zoom changes; returns the release function. The
188
+ * listeners behind it are shared and refcounted exactly like
189
+ * `trackFocusModality()`: the first subscriber arms one media query and one
190
+ * `resize` listener for the whole page, the last release removes them
191
+ * (releasing twice is a no-op).
192
+ */
193
+ export declare function onZoomChange(callback: ZoomListener): () => void;
194
+ /**
195
+ * Declare the *current* state to be 100% zoom: re-sample every baseline and
196
+ * clear the tracked zoom. The escape hatch for the one classification hole
197
+ * that needs a human — a display-mode switch at an identical logical size
198
+ * (macOS 1440×900 HiDPI ↔ native), which moves nothing the discriminator can
199
+ * see — and for a page that knows it loaded already-zoomed. The engine latch
200
+ * survives: which signal zoom arrives through is a fact about the browser,
201
+ * not about the baseline.
202
+ */
203
+ export declare function resetZoomBaseline(): void;
204
+ export {};
package/dist/zoom.js ADDED
@@ -0,0 +1,105 @@
1
+ //#region src/zoom.ts
2
+ var e = 72, t = 96, n = 96 / 72, r = 1, i = 24, a = [
3
+ .25,
4
+ .33,
5
+ .5,
6
+ .67,
7
+ .75,
8
+ .8,
9
+ .85,
10
+ .9,
11
+ 1,
12
+ 1.1,
13
+ 1.15,
14
+ 1.25,
15
+ 1.5,
16
+ 1.75,
17
+ 2,
18
+ 2.5,
19
+ 3,
20
+ 4,
21
+ 5
22
+ ], o = .02;
23
+ function s(e) {
24
+ if (!Number.isFinite(e) || e <= 0) return 1;
25
+ for (let t of a) if (Math.abs(e / t - 1) <= o) return t;
26
+ return e;
27
+ }
28
+ function c(e) {
29
+ let t = Math.round(n * e);
30
+ return Math.min(i, Math.max(r, t));
31
+ }
32
+ var l = () => typeof window > "u" ? {
33
+ sw: 0,
34
+ sh: 0,
35
+ aw: 0,
36
+ ah: 0,
37
+ x: 0,
38
+ y: 0
39
+ } : {
40
+ sw: screen.width,
41
+ sh: screen.height,
42
+ aw: screen.availWidth,
43
+ ah: screen.availHeight,
44
+ x: window.screenX,
45
+ y: window.screenY
46
+ }, u = (e, t) => Object.keys(e).every((n) => Math.abs(e[n] - t[n]) <= 1), d = (e, t, n) => Object.keys(e).every((r) => {
47
+ let i = t[r] / n;
48
+ return Math.abs(e[r] - i) <= Math.max(2, Math.abs(i) * .01);
49
+ }), f = 4, p = .001, m = 1, h = 1, g = 0, _ = 0, v = 0, y = 0, b = l(), x = 1, S = !1;
50
+ typeof window < "u" && (m = window.devicePixelRatio || 1, h = m, g = window.innerWidth, _ = window.innerHeight, v = window.outerWidth, y = window.outerHeight);
51
+ function C() {
52
+ return x;
53
+ }
54
+ function w() {
55
+ let e = typeof window < "u" && window.devicePixelRatio || 1;
56
+ return S ? e : e * x;
57
+ }
58
+ var T = /* @__PURE__ */ new Set(), E, D = () => {
59
+ for (let e of [...T]) e(x);
60
+ }, O = (e) => {
61
+ e !== x && (x = e, D());
62
+ }, k = (e) => {
63
+ let t = e / h, n = l(), r = u(n, b) || d(n, b, t);
64
+ h = e, b = n, j(), r ? (S = !0, O(s(e / m))) : m = S ? e / x : e;
65
+ }, A = () => {
66
+ let e = window.devicePixelRatio;
67
+ e === h ? j() : k(e);
68
+ }, j = () => {
69
+ M(), !(typeof window > "u" || !window.matchMedia) && (E = window.matchMedia(`(min-resolution: ${h - p}dppx) and (max-resolution: ${h + p}dppx)`), E.addEventListener("change", A));
70
+ }, M = () => {
71
+ E?.removeEventListener("change", A), E = void 0;
72
+ }, N = () => {
73
+ let e = window.devicePixelRatio;
74
+ if (e !== h) {
75
+ k(e);
76
+ return;
77
+ }
78
+ if (S) return;
79
+ let t = window.innerWidth, n = window.innerHeight, r = window.outerWidth, i = window.outerHeight;
80
+ if (Math.abs(r - v) > f || Math.abs(i - y) > f) {
81
+ v = r, y = i, g = t * x, _ = n * x;
82
+ return;
83
+ }
84
+ let o = () => {
85
+ g = t * x, _ = n * x;
86
+ }, c = Math.abs(t - g / x), l = Math.abs(n - _ / x);
87
+ if (c < f && l < f) return;
88
+ if (c < f || l < f) return o();
89
+ let u = s(g / t);
90
+ if (u !== s(_ / n) || !a.includes(u)) return o();
91
+ O(u);
92
+ }, P = 0;
93
+ function F(e) {
94
+ if (typeof window > "u") return () => {};
95
+ T.add(e), P++ === 0 && (j(), window.addEventListener("resize", N));
96
+ let t = !1;
97
+ return () => {
98
+ t || (t = !0, T.delete(e), --P === 0 && (M(), window.removeEventListener("resize", N)));
99
+ };
100
+ }
101
+ function I() {
102
+ typeof window > "u" || (m = window.devicePixelRatio || 1, h = m, g = window.innerWidth, _ = window.innerHeight, v = window.outerWidth, y = window.outerHeight, b = l(), P > 0 && j(), O(1));
103
+ }
104
+ //#endregion
105
+ export { e as CLASSIC_DPI, t as CSS_REFERENCE_DPI, n as SYSTEM_PX_IN_CSS_PX, a as ZOOM_LADDER, c as devicePxPerSystemPx, C as getZoom, F as onZoomChange, s as quantizeZoom, I as resetZoomBaseline, w as truePixelRatio };