@overpunch/opszstepper 1.0.18 → 1.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.
package/README.md CHANGED
@@ -1,212 +1,216 @@
1
- # opszStepper
2
-
3
- [![npm](https://img.shields.io/npm/v/%40overpunch%2Fopszstepper.svg)](https://www.npmjs.com/package/@overpunch/opszstepper) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![part of liiift type-tools](https://img.shields.io/badge/liiift-type--tools-blueviolet)](https://github.com/over-punch/type-tools)
4
-
5
- `font-optical-sizing: auto` only works for variable fonts with an `opsz` axis. opszStepper solves the other case: professional typeface families that ship separate font files for each optical size cut (Micro, Text, Display) with no axis at all. It automatically swaps the correct cut onto an element as its `font-size` changes.
6
-
7
- **[opszstepper.com](https://opszstepper.com)** · [npm](https://www.npmjs.com/package/@overpunch/opszstepper) · [GitHub](https://github.com/over-punch/opszStepper)
8
-
9
- TypeScript · Zero dependencies · React + Vanilla JS
10
-
11
- ![opszStepper applies the Micro, Text, and Display cuts of Cormorant to the word "Typography" at 15px, 24px, and 60px — each size gets the cut drawn for it](https://raw.githubusercontent.com/over-punch/opszStepper/main/assets/hero.png?v=1)
12
-
13
- ---
14
-
15
- ## Install
16
-
17
- ```bash
18
- npm install @overpunch/opszstepper
19
- ```
20
-
21
- ---
22
-
23
- ## Usage
24
-
25
- > **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
26
-
27
- ### What are optical cuts?
28
-
29
- Many professional editorial typefaces ship as a family of separate font files — each drawn specifically for a different size range. Halyard has Halyard Micro (captions and footnotes), Halyard Text (body), and Halyard Display (headlines). Tiempos has Tiempos Fine, Tiempos Text, and Tiempos Headline. Each cut has different contrast, spacing, and stroke weight tuned for its intended size. CSS has no mechanism to switch between them automatically — `font-optical-sizing: auto` only controls the `opsz` axis of a single variable font file. opszStepper fills that gap.
30
-
31
- Scaling one cut to every size is the problem opszStepper avoids. On the left, a single Display cut is used at all sizes — fine when large, but its high contrast and tight spacing turn spindly and fragile when shrunk. On the right, opszStepper swaps in the cut drawn for each size:
32
-
33
- ![Comparison: the left column scales a single Display cut to small, medium, and large sizes, where it reads thin and fragile when small; the right column shows opszStepper swapping to the Micro, Text, and Display cuts so each size is legible](https://raw.githubusercontent.com/over-punch/opszStepper/main/assets/compare.png?v=1)
34
-
35
- ### Two modes: family hot-swap or `opsz` axis
36
-
37
- opszStepper covers both ways type families ship optical sizes:
38
-
39
- 1. **Multi-family hot-swap** *(the primary case)* — separate font files per cut (Halyard Micro / Text / Display). Give each cut a different `family`; opszStepper sets `font-family` to the matching cut as `font-size` crosses each threshold. Every example below uses this mode.
40
- 2. **Single variable font, `opsz` axis** — one variable font with an `opsz` axis (Fraunces, Recursive, Amstelvar). Use the *same* `family` in every cut and add an `opszValue` per cut; opszStepper writes `font-variation-settings: "opsz" <value>` instead of swapping files. Optional `opszMin`/`opszMax` clamp the value to the font's fvar range:
41
-
42
- ```ts
43
- cuts: [
44
- { family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
45
- { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
46
- { family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
47
- ]
48
- ```
49
-
50
- This steps the axis at discrete thresholds with hysteresis, which is useful when you want explicit control over the `opsz` value per size band rather than the browser's continuous `font-optical-sizing: auto`.
51
-
52
- ### React component
53
-
54
- ```tsx
55
- import { OpszStepperText } from '@overpunch/opszstepper'
56
-
57
- <OpszStepperText
58
- cuts={[
59
- { family: 'Halyard Micro, sans-serif', maxSize: 13 },
60
- { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
61
- { family: 'Halyard Display, sans-serif', minSize: 28 },
62
- ]}
63
- >
64
- Your paragraph text here...
65
- </OpszStepperText>
66
- ```
67
-
68
- ### React hook
69
-
70
- ```tsx
71
- import { useOpszStepper } from '@overpunch/opszstepper'
72
-
73
- // Inside a React component:
74
- const ref = useOpszStepper({
75
- cuts: [
76
- { family: 'Halyard Micro, sans-serif', maxSize: 13 },
77
- { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
78
- { family: 'Halyard Display, sans-serif', minSize: 28 },
79
- ],
80
- })
81
- return <p ref={ref}>{children}</p>
82
- ```
83
-
84
- The hook starts a `ResizeObserver` on the element and re-evaluates the active cut each time the element's size changes (which triggers a re-read of `font-size`). It restarts automatically when `cuts.length` or `hysteresis` changes, and cleans up on unmount.
85
-
86
- ### Vanilla JS — with ResizeObserver
87
-
88
- ```ts
89
- import { startOpszStepper } from '@overpunch/opszstepper'
90
-
91
- const el = document.querySelector('p')
92
-
93
- const cuts = [
94
- { family: 'Halyard Micro, sans-serif', maxSize: 13 },
95
- { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
96
- { family: 'Halyard Display, sans-serif', minSize: 28 },
97
- ]
98
-
99
- let stop = startOpszStepper(el, { cuts })
100
-
101
- // Later — stop the observer and restore original fontFamily:
102
- // stop()
103
- ```
104
-
105
- ### Vanilla JS — one-shot
106
-
107
- ```ts
108
- import { applyOpszStepper } from '@overpunch/opszstepper'
109
-
110
- const el = document.querySelector('p')
111
-
112
- applyOpszStepper(el, {
113
- cuts: [
114
- { family: 'Halyard Micro, sans-serif', maxSize: 13 },
115
- { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
116
- { family: 'Halyard Display, sans-serif', minSize: 28 },
117
- ],
118
- })
119
-
120
- // Later — restore original fontFamily:
121
- // removeOpszStepper(el)
122
- ```
123
-
124
- ### TypeScript
125
-
126
- ```ts
127
- import type { OpszStepperCut, OpszStepperOptions } from '@overpunch/opszstepper'
128
-
129
- const cuts: OpszStepperCut[] = [
130
- { family: 'Tiempos Fine, serif', maxSize: 13 },
131
- { family: 'Tiempos Text, serif', minSize: 13, maxSize: 28 },
132
- { family: 'Tiempos Headline, serif', minSize: 28 },
133
- ]
134
-
135
- const opts: OpszStepperOptions = { cuts, hysteresis: 2 }
136
- ```
137
-
138
- ---
139
-
140
- ## Options
141
-
142
- | Option | Default | Description |
143
- |--------|---------|-------------|
144
- | `cuts` | *(required)* | Array of `OpszStepperCut` objects defining each optical size cut and the font-size range it applies to. Each cut has a `family` string (CSS `font-family` value), an optional `minSize` in px (inclusive, default `0`), and an optional `maxSize` in px (exclusive, default `Infinity`). Ranges should be contiguous and non-overlapping — see the cuts configuration guide below |
145
- | `cut.opszValue` | `undefined` | *(per-cut, `opsz`-axis mode only)* When set, opszStepper writes `font-variation-settings: "opsz" <value>` on the element instead of swapping `family`. Use with a single variable font shared across all cuts — see [Two modes](#two-modes-family-hot-swap-or-opsz-axis) |
146
- | `cut.opszMin` / `cut.opszMax` | `undefined` | *(per-cut, `opsz`-axis mode only)* Clamp the written `opszValue` to the font's fvar `opsz` axis range. Ignored unless `opszValue` is set |
147
- | `hysteresis` | `1` | Dead zone in px around each cut boundary. When font-size sits within `hysteresis` px of a threshold, the current cut is held rather than switching. Prevents oscillation when font-size is computed to hover right at a boundary due to sub-pixel rendering or responsive scaling. Increase to `2`–`4` if you observe rapid toggling |
148
- | `onCutChange` | `undefined` | Callback fired each time the active cut changes. Receives the newly applied `OpszStepperCut`. Useful for logging, analytics, or synchronising sibling elements |
149
- | `as` | `'p'` | HTML element to render. Accepts any valid React element type, e.g. `'h1'`, `'div'`, `'span'`. *(React component only)* |
150
-
151
- ---
152
-
153
- ## Cuts configuration guide
154
-
155
- A cut is active when the element's computed `font-size` satisfies `minSize <= fontSize < maxSize`. The bounds are in CSS pixels as returned by `getComputedStyle(el).fontSize`.
156
-
157
- Structure cuts as contiguous ranges — each `maxSize` should equal the next cut's `minSize`:
158
-
159
- ```ts
160
- cuts: [
161
- { family: 'Halyard Micro, sans-serif', maxSize: 13 }, // 0px – 13px
162
- { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 }, // 13px – 28px
163
- { family: 'Halyard Display, sans-serif', minSize: 28 }, // 28px – ∞
164
- ]
165
- ```
166
-
167
- You can omit the smallest cut's `minSize` (defaults to `0`) and the largest cut's `maxSize` (defaults to `Infinity`). If font-size falls outside all defined ranges — which should not happen with a complete contiguous set — the active cut is left unchanged.
168
-
169
- **Hysteresis at boundaries:** if font-size is `13.4px` and the active cut was Halyard Text (`minSize: 13`), the tool will not switch to Halyard Micro until `fontSize < 13 - hysteresis` (i.e. `< 12` with the default `hysteresis: 1`). Moving in the other direction — Text to Display — requires `fontSize > 28 + hysteresis` (i.e. `> 29`). This prevents flicker when a responsive layout computes font-size to a value that oscillates across a boundary.
170
-
171
- ---
172
-
173
- ## How it works
174
-
175
- `startOpszStepper` reads the element's computed `font-size` via `getComputedStyle(el).fontSize` and finds the matching cut. It then sets `el.style.fontFamily` to that cut's `family` string, overriding whatever the stylesheet specifies. The original `fontFamily` value is stored in a `WeakMap` keyed by element so it can be restored exactly when `removeOpszStepper` or the stop function is called.
176
-
177
- A `ResizeObserver` watches the element for size changes. In responsive layouts, `font-size` is typically driven by `clamp()`, viewport units, or container queries — all of which can change as the element or viewport resizes. Each observer callback re-reads `font-size` and applies hysteresis logic before switching cuts, so a cut swap only fires when the size has moved clearly past a threshold.
178
-
179
- **`document.fonts.load()` is not awaited.** The cut swap is immediate — opszStepper sets `font-family` and the browser handles the font load. If a cut's font file has not yet loaded, the browser will show a fallback until it arrives (standard FOUT behaviour). If you need to eliminate FOUT, preload each cut's font file in the document `<head>` using `<link rel="preload" as="font">`. opszStepper does not manage font loading.
180
-
181
- **Original fontFamily is saved and restored.** When `removeOpszStepper(el)` or the stop function from `startOpszStepper` is called, the element's `style.fontFamily` is reset to exactly the value it had before the first call. If the element had no inline `fontFamily`, it is restored to an empty string (clearing the inline property, deferring to the stylesheet).
182
-
183
- ---
184
-
185
- ## Dev notes
186
-
187
- ### `next` in root devDependencies
188
-
189
- `package.json` at the repo root lists `next` as a devDependency. This is a **Vercel detection workaround** — not a real dependency of the npm package. Vercel's build system inspects the root `package.json` to detect the framework; without `next` present it falls back to a static build and skips the Next.js pipeline, breaking the `/site` subdirectory deploy.
190
-
191
- The package itself has zero runtime dependencies. Do not remove this entry.
192
-
193
- ### Regenerating the README visuals
194
-
195
- The hero and comparison images live in `assets/` and are produced by a committed, re-runnable harness in `scripts/` (it renders the three real Cormorant optical cuts in headless Chromium and screenshots each scene). `assets/` and `scripts/` are kept out of the npm tarball — the package ships `dist` only.
196
-
197
- ```bash
198
- npm i -D playwright && npx playwright install chromium
199
- node scripts/capture.mjs # writes assets/hero.png and assets/compare.png
200
- ```
201
-
202
- Bump the `?v=N` cache-buster on the image URLs in this README after regenerating.
203
-
204
- ---
205
-
206
- ## Future improvements
207
-
208
- - **Container query support** — re-evaluate cuts in response to `@container` size changes, not just element resize, for components embedded in container-query layouts where `font-size` is driven by container width
209
- - **Smooth crossfade** — optionally apply a short CSS `transition: font-family` equivalent using a brief opacity fade between cuts to soften the swap on large editorial pages
210
- - **Multi-element sync** — a `syncGroup` option to tie multiple elements to the same active cut, so a heading and its pull-quote always use the same optical cut at all times
211
- - **Font preload hints** — automatically inject `<link rel="preload">` tags for all cut font files on first call, so the browser can fetch them before they are needed
212
- - **SSR hydration** — detect the correct cut server-side via a CSS custom property or data attribute so the initial render uses the right `font-family` without a post-hydration swap
1
+ # opszStepper
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40overpunch%2Fopszstepper.svg)](https://www.npmjs.com/package/@overpunch/opszstepper) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![part of liiift type-tools](https://img.shields.io/badge/liiift-type--tools-blueviolet)](https://github.com/over-punch/type-tools)
4
+
5
+ `font-optical-sizing: auto` only works for variable fonts with an `opsz` axis. opszStepper solves the other case: professional typeface families that ship separate font files for each optical size cut (Micro, Text, Display) with no axis at all. It automatically swaps the correct cut onto an element as its `font-size` changes.
6
+
7
+ **[opszstepper.com](https://opszstepper.com)** · [npm](https://www.npmjs.com/package/@overpunch/opszstepper) · [GitHub](https://github.com/over-punch/opszStepper)
8
+
9
+ TypeScript · Zero dependencies · React + Vanilla JS
10
+
11
+ ![opszStepper applies the Micro, Text, and Display cuts of Cormorant to the word "Typography" at 15px, 24px, and 60px — each size gets the cut drawn for it](https://raw.githubusercontent.com/over-punch/opszStepper/main/assets/hero.png?v=1)
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @overpunch/opszstepper
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Usage
24
+
25
+ > **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
26
+
27
+ ### What are optical cuts?
28
+
29
+ Many professional editorial typefaces ship as a family of separate font files — each drawn specifically for a different size range. Halyard has Halyard Micro (captions and footnotes), Halyard Text (body), and Halyard Display (headlines). Tiempos has Tiempos Fine, Tiempos Text, and Tiempos Headline. Each cut has different contrast, spacing, and stroke weight tuned for its intended size. CSS has no mechanism to switch between them automatically — `font-optical-sizing: auto` only controls the `opsz` axis of a single variable font file. opszStepper fills that gap.
30
+
31
+ Scaling one cut to every size is the problem opszStepper avoids. On the left, a single Display cut is used at all sizes — fine when large, but its high contrast and tight spacing turn spindly and fragile when shrunk. On the right, opszStepper swaps in the cut drawn for each size:
32
+
33
+ ![Comparison: the left column scales a single Display cut to small, medium, and large sizes, where it reads thin and fragile when small; the right column shows opszStepper swapping to the Micro, Text, and Display cuts so each size is legible](https://raw.githubusercontent.com/over-punch/opszStepper/main/assets/compare.png?v=1)
34
+
35
+ ### Two modes: family hot-swap or `opsz` axis
36
+
37
+ opszStepper covers both ways type families ship optical sizes:
38
+
39
+ 1. **Multi-family hot-swap** *(the primary case)* — separate font files per cut (Halyard Micro / Text / Display). Give each cut a different `family`; opszStepper sets `font-family` to the matching cut as `font-size` crosses each threshold. Every example below uses this mode.
40
+ 2. **Single variable font, `opsz` axis** — one variable font with an `opsz` axis (Fraunces, Recursive, Amstelvar). Use the *same* `family` in every cut and add an `opszValue` per cut; opszStepper sets the `opsz` axis in `font-variation-settings` (keeping any other axes you've set, such as `wght`) instead of swapping files. Optional `opszMin`/`opszMax` clamp the value to the font's fvar range:
41
+
42
+ ```ts
43
+ cuts: [
44
+ { family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
45
+ { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
46
+ { family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
47
+ ]
48
+ ```
49
+
50
+ This steps the axis at discrete thresholds with hysteresis, which is useful when you want explicit control over the `opsz` value per size band rather than the browser's continuous `font-optical-sizing: auto`.
51
+
52
+ ### React component
53
+
54
+ ```tsx
55
+ import { OpszStepperText } from '@overpunch/opszstepper'
56
+
57
+ <OpszStepperText
58
+ cuts={[
59
+ { family: 'Halyard Micro, sans-serif', maxSize: 13 },
60
+ { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
61
+ { family: 'Halyard Display, sans-serif', minSize: 28 },
62
+ ]}
63
+ >
64
+ Your paragraph text here...
65
+ </OpszStepperText>
66
+ ```
67
+
68
+ ### React hook
69
+
70
+ ```tsx
71
+ import { useOpszStepper } from '@overpunch/opszstepper'
72
+
73
+ // Inside a React component:
74
+ const ref = useOpszStepper({
75
+ cuts: [
76
+ { family: 'Halyard Micro, sans-serif', maxSize: 13 },
77
+ { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
78
+ { family: 'Halyard Display, sans-serif', minSize: 28 },
79
+ ],
80
+ })
81
+ return <p ref={ref}>{children}</p>
82
+ ```
83
+
84
+ The hook starts the stepper on the element and re-evaluates the active cut whenever its font-size may have changed (see [How it works](#how-it-works)). It restarts when the cuts or `hysteresis` change, follows the element if React replaces it (a changed `as`, a conditional mount), always calls the latest `onCutChange`, and cleans up on unmount.
85
+
86
+ ### Vanilla JS — live
87
+
88
+ The main entry also exports the React hook and component, so it imports `react`. Without React installed, import from the React-free subpath `@overpunch/opszstepper/core`:
89
+
90
+ ```ts
91
+ import { startOpszStepper } from '@overpunch/opszstepper/core'
92
+
93
+ const el = document.querySelector('p')
94
+
95
+ const cuts = [
96
+ { family: 'Halyard Micro, sans-serif', maxSize: 13 },
97
+ { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
98
+ { family: 'Halyard Display, sans-serif', minSize: 28 },
99
+ ]
100
+
101
+ let stop = startOpszStepper(el, { cuts })
102
+
103
+ // Later — stop watching and restore the original styles:
104
+ // stop()
105
+ ```
106
+
107
+ ### Vanilla JS — one-shot
108
+
109
+ ```ts
110
+ import { applyOpszStepper } from '@overpunch/opszstepper'
111
+
112
+ const el = document.querySelector('p')
113
+
114
+ applyOpszStepper(el, {
115
+ cuts: [
116
+ { family: 'Halyard Micro, sans-serif', maxSize: 13 },
117
+ { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
118
+ { family: 'Halyard Display, sans-serif', minSize: 28 },
119
+ ],
120
+ })
121
+
122
+ // Later — restore the original styles:
123
+ // removeOpszStepper(el)
124
+ ```
125
+
126
+ ### TypeScript
127
+
128
+ ```ts
129
+ import type { OpszStepperCut, OpszStepperOptions } from '@overpunch/opszstepper'
130
+
131
+ const cuts: OpszStepperCut[] = [
132
+ { family: 'Tiempos Fine, serif', maxSize: 13 },
133
+ { family: 'Tiempos Text, serif', minSize: 13, maxSize: 28 },
134
+ { family: 'Tiempos Headline, serif', minSize: 28 },
135
+ ]
136
+
137
+ const opts: OpszStepperOptions = { cuts, hysteresis: 2 }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Options
143
+
144
+ | Option | Default | Description |
145
+ |--------|---------|-------------|
146
+ | `cuts` | *(required)* | Array of `OpszStepperCut` objects defining each optical size cut and the font-size range it applies to. Each cut has a `family` string (CSS `font-family` value), an optional `minSize` in px (inclusive, default `0`), and an optional `maxSize` in px (exclusive, default `Infinity`). Ranges should be contiguous and non-overlapping. They can be listed in any order, and a cut with only `maxSize` starts where the previous one ends — see the cuts configuration guide below |
147
+ | `cut.opszValue` | `undefined` | *(per-cut, `opsz`-axis mode only)* When set, opszStepper sets the `opsz` axis in the element's `font-variation-settings`, keeping its other axes. Use with a single variable font shared across all cuts — see [Two modes](#two-modes-family-hot-swap-or-opsz-axis) |
148
+ | `cut.opszMin` / `cut.opszMax` | `undefined` | *(per-cut, `opsz`-axis mode only)* Clamp the written `opszValue` to the font's fvar `opsz` axis range. Ignored unless `opszValue` is set |
149
+ | `hysteresis` | `1` | Dead zone in px around each cut boundary. When font-size sits within `hysteresis` px of a threshold, the current cut is held rather than switching. Prevents oscillation when font-size is computed to hover right at a boundary due to sub-pixel rendering or responsive scaling. Increase to `2`–`4` if you observe rapid toggling. A value larger than half the narrowest cut is reduced to that (with a warning), so a cut can't be skipped |
150
+ | `onCutChange` | `undefined` | Callback fired each time the active cut changes. Receives the newly applied `OpszStepperCut`. Useful for logging, analytics, or synchronising sibling elements |
151
+ | `as` | `'p'` | HTML element to render. Accepts any valid React element type, e.g. `'h1'`, `'div'`, `'span'`. *(React component only)* |
152
+
153
+ ---
154
+
155
+ ## Cuts configuration guide
156
+
157
+ A cut is active when the element's computed `font-size` satisfies `minSize <= fontSize < maxSize`. The bounds are in CSS pixels as returned by `getComputedStyle(el).fontSize`.
158
+
159
+ Structure cuts as contiguous ranges — each `maxSize` should equal the next cut's `minSize`:
160
+
161
+ ```ts
162
+ cuts: [
163
+ { family: 'Halyard Micro, sans-serif', maxSize: 13 }, // 0px – 13px
164
+ { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 }, // 13px – 28px
165
+ { family: 'Halyard Display, sans-serif', minSize: 28 }, // 28px – ∞
166
+ ]
167
+ ```
168
+
169
+ You can omit the smallest cut's `minSize` (defaults to `0`) and the largest cut's `maxSize` (defaults to `Infinity`). If font-size falls outside all defined ranges — which should not happen with a complete contiguous set — the active cut is left unchanged.
170
+
171
+ **Hysteresis at boundaries:** if font-size is `13.4px` and the active cut was Halyard Text (`minSize: 13`), the tool will not switch to Halyard Micro until `fontSize < 13 - hysteresis` (i.e. `< 12` with the default `hysteresis: 1`). Moving in the other direction — Text to Display — requires `fontSize > 28 + hysteresis` (i.e. `> 29`). This prevents flicker when a responsive layout computes font-size to a value that oscillates across a boundary.
172
+
173
+ ---
174
+
175
+ ## How it works
176
+
177
+ `startOpszStepper` reads the element's computed `font-size` via `getComputedStyle(el).fontSize` and finds the matching cut. It then sets `el.style.fontFamily` to that cut's `family` string, overriding whatever the stylesheet specifies. The original inline `font-family` and `font-variation-settings` (with any `!important`) are saved so they can be restored exactly when `removeOpszStepper` or the stop function is called. In family mode `font-variation-settings` is left alone.
178
+
179
+ A font-size can change without the element's box changing size (a fixed `line-height`, an inline `<span>`, a fixed-size box), so a `ResizeObserver` alone isn't enough. opszStepper re-checks when the element or its parent resizes (container queries), when a `class` or `style` attribute changes anywhere on the page, and when the window resizes (viewport units, media queries). All watched elements share these observers, and each check reads every font-size first and then writes the changed cuts, so a resize with thousands of elements costs one style recalculation. Hysteresis is applied before switching, so a swap only fires when the size has moved clearly past a threshold.
180
+
181
+ **Limits:** a font-size change that comes from none of these (for example an animation of `font-size` on a parent) isn't seen until one of them fires; call `applyOpszStepper` yourself in that case. The size used is the computed `font-size`, so CSS `zoom` and transforms don't change the cut. opszStepper doesn't move the page's scroll position: browsers' scroll anchoring handles the small reflow a swap can cause.
182
+
183
+ **`document.fonts.load()` is not awaited.** The cut swap is immediate — opszStepper sets `font-family` and the browser handles the font load. If a cut's font file has not yet loaded, the browser will show a fallback until it arrives (standard FOUT behaviour). If you need to eliminate FOUT, preload each cut's font file in the document `<head>` using `<link rel="preload" as="font">`. opszStepper does not manage font loading.
184
+
185
+ **Original fontFamily is saved and restored.** When `removeOpszStepper(el)` or the stop function from `startOpszStepper` is called, the element's `style.fontFamily` is reset to exactly the value it had before the first call. If the element had no inline `fontFamily`, the inline property is removed (deferring to the stylesheet), and an element that had no `style` attribute is left without one.
186
+
187
+ ---
188
+
189
+ ## Dev notes
190
+
191
+ ### `next` in root devDependencies
192
+
193
+ `package.json` at the repo root lists `next` as a devDependency. This is a **Vercel detection workaround** — not a real dependency of the npm package. Vercel's build system inspects the root `package.json` to detect the framework; without `next` present it falls back to a static build and skips the Next.js pipeline, breaking the `/site` subdirectory deploy.
194
+
195
+ The package itself has zero runtime dependencies. Do not remove this entry.
196
+
197
+ ### Regenerating the README visuals
198
+
199
+ The hero and comparison images live in `assets/` and are produced by a committed, re-runnable harness in `scripts/` (it renders the three real Cormorant optical cuts in headless Chromium and screenshots each scene). `assets/` and `scripts/` are kept out of the npm tarball — the package ships `dist` only.
200
+
201
+ ```bash
202
+ npm i -D playwright && npx playwright install chromium
203
+ node scripts/capture.mjs # writes assets/hero.png and assets/compare.png
204
+ ```
205
+
206
+ Bump the `?v=N` cache-buster on the image URLs in this README after regenerating.
207
+
208
+ ---
209
+
210
+ ## Future improvements
211
+
212
+ - **Container query support** — re-evaluate cuts in response to `@container` size changes, not just element resize, for components embedded in container-query layouts where `font-size` is driven by container width
213
+ - **Smooth crossfade** — optionally apply a short CSS `transition: font-family` equivalent using a brief opacity fade between cuts to soften the swap on large editorial pages
214
+ - **Multi-element sync** — a `syncGroup` option to tie multiple elements to the same active cut, so a heading and its pull-quote always use the same optical cut at all times
215
+ - **Font preload hints** — automatically inject `<link rel="preload">` tags for all cut font files on first call, so the browser can fetch them before they are needed
216
+ - **SSR hydration** — detect the correct cut server-side via a CSS custom property or data attribute so the initial render uses the right `font-family` without a post-hydration swap
package/dist/core.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=1;function $(t){const e=t.filter(s=>s&&typeof s.family=="string"),n=s=>Number.isFinite(s.minSize)?s.minSize:Number.isFinite(s.maxSize)?s.maxSize-1e-9:1/0,i=[...e].sort((s,u)=>n(s)-n(u)),r=i.map((s,u)=>{const f=i[u-1];return{min:Number.isFinite(s.minSize)?s.minSize:f&&Number.isFinite(f.maxSize)?f.maxSize:0,max:Number.isFinite(s.maxSize)?s.maxSize:1/0,cut:s}});for(let s=0;s<r.length-1;s++)r[s].max===1/0&&(r[s].max=r[s+1].min);return r}function z(t,e){for(let n=0;n<t.length;n++)if(e>=t[n].min&&e<t[n].max)return n;return-1}function R(t,e,n,i){const r=z(t,e);if(n===-1||!t[n])return r;if(r===-1||r===n)return n;const s=t[n];return e>=s.max+i||e<s.min-i?r:n}const A=new Set;function N(t){A.has(t)||(A.add(t),console.warn(t))}function H(t,e){let n=t??g;(!Number.isFinite(n)||n<0)&&(N(`[opszStepper] hysteresis must be a non-negative number; got ${t}, using ${g}`),n=g);const i=Math.min(...e.map(r=>r.max-r.min).filter(r=>Number.isFinite(r)&&r>0));return Number.isFinite(i)&&n>i/2&&(N(`[opszStepper] hysteresis ${n}px is more than half the narrowest cut (${i}px); using ${i/2}px`),n=i/2),n}const a=new Map;function S(t,e){return{value:t.style.getPropertyValue(e),priority:t.style.getPropertyPriority(e)}}function v(t,e,n){n.value?t.style.setProperty(e,n.value,n.priority):t.style.removeProperty(e)}function M(t,e){const n=$(e.cuts),i=H(e.hysteresis,n);let r=a.get(t);return r?(r.cuts=n,r.hysteresis=i,r.onCutChange=e.onCutChange):(r={cuts:n,hysteresis:i,onCutChange:e.onCutChange,activeIndex:-1,origFamily:S(t,"font-family"),origFVS:S(t,"font-variation-settings"),origStyleAttr:t.getAttribute("style"),baseFVS:I(t),writtenFVS:null,writtenStyleAttr:null,live:!1},a.set(t,r)),r}function I(t){const e=getComputedStyle(t);return(typeof e.getPropertyValue=="function"?e.getPropertyValue("font-variation-settings"):e.fontVariationSettings)||"normal"}function L(t,e){const n=`"opsz" ${e}`;if(!t||t==="normal")return n;const i=/(["'])opsz\1\s+-?[\d.eE+-]+/;return i.test(t)?t.replace(i,n):`${t}, ${n}`}function F(t,e,n){const i=e.cuts[n].cut;if(e.writtenFVS!==null&&t.style.getPropertyValue("font-variation-settings")!==e.writtenFVS&&(e.origFVS=S(t,"font-variation-settings"),e.baseFVS=I(t),e.writtenFVS=null),t.style.setProperty("font-family",i.family,e.origFamily.priority),typeof i.opszValue=="number"&&Number.isFinite(i.opszValue)){const r=Number.isFinite(i.opszMin)?i.opszMin:-1/0,s=Number.isFinite(i.opszMax)?i.opszMax:1/0,u=r<=s?Math.min(s,Math.max(r,i.opszValue)):i.opszValue,f=L(e.baseFVS,u);t.style.setProperty("font-variation-settings",f,e.origFVS.priority),e.writtenFVS=t.style.getPropertyValue("font-variation-settings")}else e.writtenFVS!==null&&(v(t,"font-variation-settings",e.origFVS),e.writtenFVS=null);e.activeIndex=n,e.writtenStyleAttr=t.getAttribute("style")}function E(t){const e=a.get(t);e&&(e.writtenStyleAttr!==null&&t.getAttribute("style")===e.writtenStyleAttr?e.origStyleAttr===null?t.removeAttribute("style"):t.setAttribute("style",e.origStyleAttr):(v(t,"font-family",e.origFamily),e.writtenFVS!==null&&v(t,"font-variation-settings",e.origFVS),e.origStyleAttr===null&&!t.getAttribute("style")&&t.removeAttribute("style")),a.delete(t))}const p=new Set;let l=null,O=null;const c=new Map;let y=null,b=!1;function T(){var e;b=!1;const t=[];p.forEach(n=>{const i=a.get(n);if(!i||!n.isConnected)return;const r=parseFloat(getComputedStyle(n).fontSize);if(!Number.isFinite(r))return;const s=R(i.cuts,r,i.activeIndex,i.hysteresis);s!==-1&&s!==i.activeIndex&&t.push([n,i,s])});for(const[n,i,r]of t)F(n,i,r),(e=i.onCutChange)==null||e.call(i,i.cuts[r].cut)}function d(){b||(b=!0,queueMicrotask(T))}function k(){typeof MutationObserver<"u"&&!y&&document.documentElement&&(y=new MutationObserver(d),y.observe(document.documentElement,{attributes:!0,attributeFilter:["class","style","lang","dir"],subtree:!0}),window.addEventListener("resize",d))}function W(){p.size||(l==null||l.disconnect(),l=null,c.clear(),y==null||y.disconnect(),y=null,typeof window<"u"&&window.removeEventListener("resize",d))}function j(t,e){var s;if(typeof window>"u"||!t||!(e!=null&&e.cuts)||e.cuts.length===0)return;const n=parseFloat(getComputedStyle(t).fontSize);if(isNaN(n))return;const i=M(t,e),r=z(i.cuts,n);r!==-1&&r!==i.activeIndex&&(F(t,i,r),(s=i.onCutChange)==null||s.call(i,i.cuts[r].cut))}function q(t,e){var s,u,f;if(typeof window>"u"||!t)return()=>{};if(!(e!=null&&e.cuts)||e.cuts.length===0)return()=>{};(u=(s=a.get(t))==null?void 0:s.stop)==null||u.call(s);const n=M(t,e),i=parseFloat(getComputedStyle(t).fontSize);if(Number.isFinite(i)){const o=z(n.cuts,i);o!==-1&&o!==n.activeIndex&&(F(t,n,o),(f=n.onCutChange)==null||f.call(n,n.cuts[o].cut))}if(k(),p.add(t),n.live=!0,typeof ResizeObserver<"u"&&((!l||O!==ResizeObserver)&&(l=new ResizeObserver(d),O=ResizeObserver,c.clear()),n.ro=l,n.ro.observe(t),n.observedParent=t.parentElement,n.observedParent)){const o=c.get(n.observedParent)??0;o===0&&n.ro.observe(n.observedParent),c.set(n.observedParent,o+1)}const r=()=>{var w,h,V,x,C;if(((w=a.get(t))==null?void 0:w.stop)!==r)return;p.delete(t);const o=a.get(t);if(o!=null&&o.ro&&o.ro===l){(V=(h=o.ro).unobserve)==null||V.call(h,t);const m=o.observedParent;if(m){const P=(c.get(m)??1)-1;P<=0?(c.delete(m),(C=(x=o.ro).unobserve)==null||C.call(x,m)):c.set(m,P)}}E(t),W()};return n.stop=r,r}function D(t){const e=a.get(t);e&&(e.stop?e.stop():E(t))}exports.applyOpszStepper=j;exports.removeOpszStepper=D;exports.startOpszStepper=q;
package/dist/core.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * One-shot application of the correct optical cut for the element's current computed font-size.
3
+ * No watching — use when you want manual control. Hysteresis is not applied here.
4
+ * onCutChange fires only when the cut actually changes.
5
+ *
6
+ * @param el - Target element
7
+ * @param options - OpszStepperOptions
8
+ */
9
+ export declare function applyOpszStepper(el: HTMLElement, options: OpszStepperOptions): void;
10
+
11
+ /**
12
+ * A single optical size cut definition: a font-family string and the font-size
13
+ * range in px over which it should be active.
14
+ *
15
+ * Two usage modes:
16
+ * 1. Multi-family hot-swap (e.g. Halyard Micro / Text / Display):
17
+ * set `family` to the CSS font-family string for each cut. Leave `opszValue` unset.
18
+ * 2. Single variable-font opsz axis (e.g. Recursive, Fraunces, Amstelvar):
19
+ * set `family` to the single variable font family, and set `opszValue` to the
20
+ * `opsz` axis value to apply at this cut. The tool will write
21
+ * `font-variation-settings: "opsz" <value>` on the element.
22
+ * Optionally provide `opszMin` and `opszMax` to clamp the value against the
23
+ * font's fvar axis range.
24
+ */
25
+ export declare interface OpszStepperCut {
26
+ /**
27
+ * The CSS font-family string for this optical cut.
28
+ * e.g. 'Halyard Display, sans-serif' or '"Tiempos Display", serif'
29
+ * For variable-font opsz mode, use the same family string in all cuts.
30
+ */
31
+ family: string;
32
+ /** Min font-size in px (inclusive) for this cut to apply. Default: 0 */
33
+ minSize?: number;
34
+ /** Max font-size in px (exclusive) for this cut to apply. Default: Infinity */
35
+ maxSize?: number;
36
+ /**
37
+ * Optional `opsz` axis value to write as `font-variation-settings: "opsz" <value>`.
38
+ * Use this when all cuts share one variable font and you want to drive the opsz axis
39
+ * directly rather than hot-swapping font families.
40
+ * When provided, `font-variation-settings: normal` is written first to clear any
41
+ * inherited axis values before applying the new `opsz` value.
42
+ * Clamped between `opszMin` and `opszMax` when those are supplied.
43
+ */
44
+ opszValue?: number;
45
+ /**
46
+ * Minimum allowed value for the `opsz` axis (from the font's fvar table).
47
+ * Only used when `opszValue` is set. Clamps the written value from below.
48
+ */
49
+ opszMin?: number;
50
+ /**
51
+ * Maximum allowed value for the `opsz` axis (from the font's fvar table).
52
+ * Only used when `opszValue` is set. Clamps the written value from above.
53
+ */
54
+ opszMax?: number;
55
+ }
56
+
57
+ /** Options controlling the opszStepper effect */
58
+ export declare interface OpszStepperOptions {
59
+ /**
60
+ * The optical size cuts, ordered from smallest to largest.
61
+ * Each cut defines a font-family string and the font-size range it applies to.
62
+ * Ranges should be contiguous and non-overlapping.
63
+ *
64
+ * @example Multi-family hot-swap
65
+ * cuts: [
66
+ * { family: 'Halyard Micro, sans-serif', maxSize: 13 },
67
+ * { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
68
+ * { family: 'Halyard Display, sans-serif', minSize: 28 },
69
+ * ]
70
+ *
71
+ * @example Single variable font opsz axis
72
+ * cuts: [
73
+ * { family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
74
+ * { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
75
+ * { family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
76
+ * ]
77
+ */
78
+ cuts: OpszStepperCut[];
79
+ /**
80
+ * Hysteresis dead zone in px per threshold. Prevents oscillation when
81
+ * font-size sits exactly at a cut boundary. Only used by startOpszStepper;
82
+ * applyOpszStepper always does a direct cut lookup without hysteresis.
83
+ * Default: 1
84
+ */
85
+ hysteresis?: number;
86
+ /**
87
+ * Callback fired each time the active cut changes.
88
+ * Receives the new cut that was applied.
89
+ */
90
+ onCutChange?: (cut: OpszStepperCut) => void;
91
+ }
92
+
93
+ /** A stop function returned by startOpszStepper. Call it to disconnect the observer and restore the element. */
94
+ export declare type OpszStepperStop = () => void;
95
+
96
+ /**
97
+ * Restore the element's original styles and stop watching it. No-op if never applied.
98
+ *
99
+ * @param el - Element previously passed to startOpszStepper or applyOpszStepper
100
+ */
101
+ export declare function removeOpszStepper(el: HTMLElement): void;
102
+
103
+ /**
104
+ * Start watching an element: applies the correct cut now and again whenever its font-size
105
+ * changes — from a resize, a class or style change anywhere on the page, or a viewport change.
106
+ * Calling it again on the same element replaces the earlier watcher.
107
+ *
108
+ * @param el - Target element
109
+ * @param options - OpszStepperOptions
110
+ * @returns A stop function that stops watching and restores the element's original styles
111
+ */
112
+ export declare function startOpszStepper(el: HTMLElement, options: OpszStepperOptions): OpszStepperStop;
113
+
114
+ export { }
package/dist/core.js ADDED
@@ -0,0 +1,155 @@
1
+ function O(t) {
2
+ const e = t.filter((s) => s && typeof s.family == "string"), n = (s) => Number.isFinite(s.minSize) ? s.minSize : Number.isFinite(s.maxSize) ? s.maxSize - 1e-9 : 1 / 0, i = [...e].sort((s, u) => n(s) - n(u)), r = i.map((s, u) => {
3
+ const f = i[u - 1];
4
+ return { min: Number.isFinite(s.minSize) ? s.minSize : f && Number.isFinite(f.maxSize) ? f.maxSize : 0, max: Number.isFinite(s.maxSize) ? s.maxSize : 1 / 0, cut: s };
5
+ });
6
+ for (let s = 0; s < r.length - 1; s++)
7
+ r[s].max === 1 / 0 && (r[s].max = r[s + 1].min);
8
+ return r;
9
+ }
10
+ function F(t, e) {
11
+ for (let n = 0; n < t.length; n++)
12
+ if (e >= t[n].min && e < t[n].max) return n;
13
+ return -1;
14
+ }
15
+ function R(t, e, n, i) {
16
+ const r = F(t, e);
17
+ if (n === -1 || !t[n]) return r;
18
+ if (r === -1 || r === n) return n;
19
+ const s = t[n];
20
+ return e >= s.max + i || e < s.min - i ? r : n;
21
+ }
22
+ const A = /* @__PURE__ */ new Set();
23
+ function E(t) {
24
+ A.has(t) || (A.add(t), console.warn(t));
25
+ }
26
+ function T(t, e) {
27
+ let n = t ?? 1;
28
+ (!Number.isFinite(n) || n < 0) && (E(`[opszStepper] hysteresis must be a non-negative number; got ${t}, using 1`), n = 1);
29
+ const i = Math.min(...e.map((r) => r.max - r.min).filter((r) => Number.isFinite(r) && r > 0));
30
+ return Number.isFinite(i) && n > i / 2 && (E(`[opszStepper] hysteresis ${n}px is more than half the narrowest cut (${i}px); using ${i / 2}px`), n = i / 2), n;
31
+ }
32
+ const a = /* @__PURE__ */ new Map();
33
+ function d(t, e) {
34
+ return { value: t.style.getPropertyValue(e), priority: t.style.getPropertyPriority(e) };
35
+ }
36
+ function g(t, e, n) {
37
+ n.value ? t.style.setProperty(e, n.value, n.priority) : t.style.removeProperty(e);
38
+ }
39
+ function I(t, e) {
40
+ const n = O(e.cuts), i = T(e.hysteresis, n);
41
+ let r = a.get(t);
42
+ return r ? (r.cuts = n, r.hysteresis = i, r.onCutChange = e.onCutChange) : (r = {
43
+ cuts: n,
44
+ hysteresis: i,
45
+ onCutChange: e.onCutChange,
46
+ activeIndex: -1,
47
+ origFamily: d(t, "font-family"),
48
+ origFVS: d(t, "font-variation-settings"),
49
+ origStyleAttr: t.getAttribute("style"),
50
+ baseFVS: N(t),
51
+ writtenFVS: null,
52
+ writtenStyleAttr: null,
53
+ live: !1
54
+ }, a.set(t, r)), r;
55
+ }
56
+ function N(t) {
57
+ const e = getComputedStyle(t);
58
+ return (typeof e.getPropertyValue == "function" ? e.getPropertyValue("font-variation-settings") : e.fontVariationSettings) || "normal";
59
+ }
60
+ function $(t, e) {
61
+ const n = `"opsz" ${e}`;
62
+ if (!t || t === "normal") return n;
63
+ const i = /(["'])opsz\1\s+-?[\d.eE+-]+/;
64
+ return i.test(t) ? t.replace(i, n) : `${t}, ${n}`;
65
+ }
66
+ function b(t, e, n) {
67
+ const i = e.cuts[n].cut;
68
+ if (e.writtenFVS !== null && t.style.getPropertyValue("font-variation-settings") !== e.writtenFVS && (e.origFVS = d(t, "font-variation-settings"), e.baseFVS = N(t), e.writtenFVS = null), t.style.setProperty("font-family", i.family, e.origFamily.priority), typeof i.opszValue == "number" && Number.isFinite(i.opszValue)) {
69
+ const r = Number.isFinite(i.opszMin) ? i.opszMin : -1 / 0, s = Number.isFinite(i.opszMax) ? i.opszMax : 1 / 0, u = r <= s ? Math.min(s, Math.max(r, i.opszValue)) : i.opszValue, f = $(e.baseFVS, u);
70
+ t.style.setProperty("font-variation-settings", f, e.origFVS.priority), e.writtenFVS = t.style.getPropertyValue("font-variation-settings");
71
+ } else e.writtenFVS !== null && (g(t, "font-variation-settings", e.origFVS), e.writtenFVS = null);
72
+ e.activeIndex = n, e.writtenStyleAttr = t.getAttribute("style");
73
+ }
74
+ function M(t) {
75
+ const e = a.get(t);
76
+ e && (e.writtenStyleAttr !== null && t.getAttribute("style") === e.writtenStyleAttr ? e.origStyleAttr === null ? t.removeAttribute("style") : t.setAttribute("style", e.origStyleAttr) : (g(t, "font-family", e.origFamily), e.writtenFVS !== null && g(t, "font-variation-settings", e.origFVS), e.origStyleAttr === null && !t.getAttribute("style") && t.removeAttribute("style")), a.delete(t));
77
+ }
78
+ const S = /* @__PURE__ */ new Set();
79
+ let l = null, P = null;
80
+ const c = /* @__PURE__ */ new Map();
81
+ let y = null, v = !1;
82
+ function H() {
83
+ var e;
84
+ v = !1;
85
+ const t = [];
86
+ S.forEach((n) => {
87
+ const i = a.get(n);
88
+ if (!i || !n.isConnected) return;
89
+ const r = parseFloat(getComputedStyle(n).fontSize);
90
+ if (!Number.isFinite(r)) return;
91
+ const s = R(i.cuts, r, i.activeIndex, i.hysteresis);
92
+ s !== -1 && s !== i.activeIndex && t.push([n, i, s]);
93
+ });
94
+ for (const [n, i, r] of t)
95
+ b(n, i, r), (e = i.onCutChange) == null || e.call(i, i.cuts[r].cut);
96
+ }
97
+ function p() {
98
+ v || (v = !0, queueMicrotask(H));
99
+ }
100
+ function L() {
101
+ typeof MutationObserver < "u" && !y && document.documentElement && (y = new MutationObserver(p), y.observe(document.documentElement, { attributes: !0, attributeFilter: ["class", "style", "lang", "dir"], subtree: !0 }), window.addEventListener("resize", p));
102
+ }
103
+ function D() {
104
+ S.size || (l == null || l.disconnect(), l = null, c.clear(), y == null || y.disconnect(), y = null, typeof window < "u" && window.removeEventListener("resize", p));
105
+ }
106
+ function U(t, e) {
107
+ var s;
108
+ if (typeof window > "u" || !t || !(e != null && e.cuts) || e.cuts.length === 0) return;
109
+ const n = parseFloat(getComputedStyle(t).fontSize);
110
+ if (isNaN(n)) return;
111
+ const i = I(t, e), r = F(i.cuts, n);
112
+ r !== -1 && r !== i.activeIndex && (b(t, i, r), (s = i.onCutChange) == null || s.call(i, i.cuts[r].cut));
113
+ }
114
+ function Y(t, e) {
115
+ var s, u, f;
116
+ if (typeof window > "u" || !t) return () => {
117
+ };
118
+ if (!(e != null && e.cuts) || e.cuts.length === 0) return () => {
119
+ };
120
+ (u = (s = a.get(t)) == null ? void 0 : s.stop) == null || u.call(s);
121
+ const n = I(t, e), i = parseFloat(getComputedStyle(t).fontSize);
122
+ if (Number.isFinite(i)) {
123
+ const o = F(n.cuts, i);
124
+ o !== -1 && o !== n.activeIndex && (b(t, n, o), (f = n.onCutChange) == null || f.call(n, n.cuts[o].cut));
125
+ }
126
+ if (L(), S.add(t), n.live = !0, typeof ResizeObserver < "u" && ((!l || P !== ResizeObserver) && (l = new ResizeObserver(p), P = ResizeObserver, c.clear()), n.ro = l, n.ro.observe(t), n.observedParent = t.parentElement, n.observedParent)) {
127
+ const o = c.get(n.observedParent) ?? 0;
128
+ o === 0 && n.ro.observe(n.observedParent), c.set(n.observedParent, o + 1);
129
+ }
130
+ const r = () => {
131
+ var w, z, h, x, V;
132
+ if (((w = a.get(t)) == null ? void 0 : w.stop) !== r) return;
133
+ S.delete(t);
134
+ const o = a.get(t);
135
+ if (o != null && o.ro && o.ro === l) {
136
+ (h = (z = o.ro).unobserve) == null || h.call(z, t);
137
+ const m = o.observedParent;
138
+ if (m) {
139
+ const C = (c.get(m) ?? 1) - 1;
140
+ C <= 0 ? (c.delete(m), (V = (x = o.ro).unobserve) == null || V.call(x, m)) : c.set(m, C);
141
+ }
142
+ }
143
+ M(t), D();
144
+ };
145
+ return n.stop = r, r;
146
+ }
147
+ function _(t) {
148
+ const e = a.get(t);
149
+ e && (e.stop ? e.stop() : M(t));
150
+ }
151
+ export {
152
+ U as applyOpszStepper,
153
+ _ as removeOpszStepper,
154
+ Y as startOpszStepper
155
+ };
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const u=require("react"),x=require("react/jsx-runtime"),p=new WeakMap,y=new WeakMap,l=new WeakMap,F=1;function d(t,n){for(let e=0;e<t.length;e++){const s=t[e],i=s.minSize??0,r=s.maxSize??1/0;if(n>=i&&n<r)return e}return-1}function v(t,n,e,s){const i=d(t,n);if(i===-1)return e===-1?i:e;if(e===-1)return i;if(i===e)return e;const r=t[e],a=t[i];if(i>e){const o=(a.minSize??0)+s;return n>o?i:e}else{const o=(r.minSize??0)-s;return n<o?i:e}}function z(t,n){const e=window.scrollY;if(t.style.fontFamily=n.family,n.opszValue!==void 0){const s=n.opszMin??-1/0,i=n.opszMax??1/0,r=Math.min(i,Math.max(s,n.opszValue));t.style.fontVariationSettings=`"opsz" ${r}`}else t.style.fontVariationSettings&&(t.style.fontVariationSettings="");requestAnimationFrame(()=>{Math.abs(window.scrollY-e)>2&&window.scrollTo({top:e,behavior:"instant"})})}function g(t,n){if(typeof window>"u")return;const{cuts:e,onCutChange:s}=n;if(!e||e.length===0)return;const i=parseFloat(getComputedStyle(t).fontSize);if(isNaN(i))return;const r=d(e,i);if(r===-1)return;const a=e[r];p.has(t)||p.set(t,t.style.fontFamily),z(t,a),l.set(t,r),s==null||s(a)}function w(t,n){if(typeof window>"u")return()=>{};const{cuts:e,onCutChange:s}=n,i=n.hysteresis??F;if(!e||e.length===0)return()=>{};const r=y.get(t);r&&r(),p.has(t)||p.set(t,t.style.fontFamily),g(t,n);const a=new ResizeObserver(()=>{const c=parseFloat(getComputedStyle(t).fontSize);if(isNaN(c))return;const S=l.get(t)??-1,f=v(e,c,S,i);f!==S&&f!==-1&&(z(t,e[f]),l.set(t,f),s==null||s(e[f]))});a.observe(t);const o=()=>{a.disconnect();const c=p.get(t);c!==void 0&&(t.style.fontFamily=c,t.style.fontVariationSettings="",p.delete(t)),l.delete(t),y.delete(t)};return y.set(t,o),o}function b(t){const n=y.get(t);if(n){n();return}if(p.has(t)){const e=p.get(t);t.style.fontFamily=e??"",t.style.fontVariationSettings="",p.delete(t),l.delete(t)}}function O(t){const n=u.useRef(null),e=u.useRef(t);e.current=t;const s=u.useRef(null),i=JSON.stringify(t.cuts),{hysteresis:r}=t;return u.useLayoutEffect(()=>{var o;const a=n.current;if(a)return(o=s.current)==null||o.call(s),s.current=w(a,e.current),()=>{var c;(c=s.current)==null||c.call(s),s.current=null}},[i,r]),n}const h=u.forwardRef(function({children:n,as:e="p",cuts:s,hysteresis:i,onCutChange:r,...a},o){const f=O({cuts:s,hysteresis:i,onCutChange:r}),M=u.useCallback(m=>{f.current=m,typeof o=="function"?o(m):o&&(o.current=m)},[o]);return x.jsx(e,{ref:M,...a,children:n})});h.displayName="OpszStepperText";exports.OpszStepperText=h;exports.applyOpszStepper=g;exports.removeOpszStepper=b;exports.startOpszStepper=w;exports.useOpszStepper=O;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const c=require("./core.cjs"),r=require("react"),g=require("react/jsx-runtime");function l(s){const[n,o]=r.useState(null),i=r.useMemo(()=>{let e=null;return{get current(){return e},set current(t){t!==e&&(e=t,o(t))}}},[]),p=r.useRef(s);p.current=s;const a=JSON.stringify(s.cuts),{hysteresis:f}=s;return r.useLayoutEffect(()=>n?c.startOpszStepper(n,{...p.current,onCutChange:t=>{var S,u;return(u=(S=p.current).onCutChange)==null?void 0:u.call(S,t)}}):void 0,[n,a,f]),i}const z=r.forwardRef(function({children:n,as:o="p",cuts:i,hysteresis:p,onCutChange:a,...f},e){const u=l({cuts:i,hysteresis:p,onCutChange:a}),y=r.useCallback(O=>{u.current=O,typeof e=="function"?e(O):e&&(e.current=O)},[e]);return g.jsx(o,{ref:y,...f,children:n})});z.displayName="OpszStepperText";exports.applyOpszStepper=c.applyOpszStepper;exports.removeOpszStepper=c.removeOpszStepper;exports.startOpszStepper=c.startOpszStepper;exports.OpszStepperText=z;exports.useOpszStepper=l;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { default as default_2 } from 'react';
2
- import { RefObject } from 'react';
2
+ import { MutableRefObject } from 'react';
3
3
 
4
4
  /**
5
- * One-shot application of the correct optical cut for the element's current
6
- * computed font-size. No ResizeObserver — use when you want manual control.
7
- * Note: hysteresis is not applied here; use startOpszStepper for hysteresis support.
5
+ * One-shot application of the correct optical cut for the element's current computed font-size.
6
+ * No watching — use when you want manual control. Hysteresis is not applied here.
7
+ * onCutChange fires only when the cut actually changes.
8
8
  *
9
9
  * @param el - Target element
10
10
  * @param options - OpszStepperOptions
@@ -115,25 +115,20 @@ declare interface OpszStepperTextProps extends OpszStepperOptions, default_2.HTM
115
115
  }
116
116
 
117
117
  /**
118
- * Restore the element's original fontFamily and disconnect any running
119
- * ResizeObserver started by startOpszStepper. No-op if never applied.
118
+ * Restore the element's original styles and stop watching it. No-op if never applied.
120
119
  *
121
120
  * @param el - Element previously passed to startOpszStepper or applyOpszStepper
122
121
  */
123
122
  export declare function removeOpszStepper(el: HTMLElement): void;
124
123
 
125
124
  /**
126
- * Start a ResizeObserver-backed optical cut watcher on an element.
127
- * Re-evaluates the correct cut each time the element's size changes
128
- * (which can cause font-size to change in responsive designs).
129
- *
130
- * Applies the correct cut immediately on first call.
131
- * If called a second time on the same element, the prior observer is stopped
132
- * first to avoid orphaned observers.
125
+ * Start watching an element: applies the correct cut now and again whenever its font-size
126
+ * changes — from a resize, a class or style change anywhere on the page, or a viewport change.
127
+ * Calling it again on the same element replaces the earlier watcher.
133
128
  *
134
129
  * @param el - Target element
135
130
  * @param options - OpszStepperOptions
136
- * @returns A stop function that disconnects the observer and restores the original fontFamily
131
+ * @returns A stop function that stops watching and restores the element's original styles
137
132
  */
138
133
  export declare function startOpszStepper(el: HTMLElement, options: OpszStepperOptions): OpszStepperStop;
139
134
 
@@ -146,15 +141,14 @@ export declare function startOpszStepper(el: HTMLElement, options: OpszStepperOp
146
141
  * A stable JSON serialisation of the cuts array is used as the dependency key so
147
142
  * that same-length arrays with different content also trigger a restart.
148
143
  *
149
- * Note: onCutChange is kept in a ref and never used as a dependency — it is always
150
- * read fresh from optionsRef on each observer callback, so it does not need to be
151
- * stable across renders.
144
+ * onCutChange is read from the latest render each time it fires, so it doesn't need to be
145
+ * stable across renders. The stepper follows the element if React replaces it.
152
146
  *
153
147
  * Cleans up on unmount.
154
148
  *
155
149
  * @param options - OpszStepperOptions
156
150
  * @returns A ref to attach to the target element
157
151
  */
158
- export declare function useOpszStepper(options: OpszStepperOptions): RefObject<HTMLElement | null>;
152
+ export declare function useOpszStepper(options: OpszStepperOptions): MutableRefObject<HTMLElement | null>;
159
153
 
160
154
  export { }
package/dist/index.js CHANGED
@@ -1,114 +1,46 @@
1
- import { useRef as S, useLayoutEffect as z, forwardRef as h, useCallback as M } from "react";
2
- import { jsx as F } from "react/jsx-runtime";
3
- const f = /* @__PURE__ */ new WeakMap(), y = /* @__PURE__ */ new WeakMap(), p = /* @__PURE__ */ new WeakMap(), x = 1;
4
- function d(t, e) {
5
- for (let n = 0; n < t.length; n++) {
6
- const s = t[n], i = s.minSize ?? 0, o = s.maxSize ?? 1 / 0;
7
- if (e >= i && e < o)
8
- return n;
9
- }
10
- return -1;
11
- }
12
- function O(t, e, n, s) {
13
- const i = d(t, e);
14
- if (i === -1) return n === -1 ? i : n;
15
- if (n === -1) return i;
16
- if (i === n) return n;
17
- const o = t[n], a = t[i];
18
- if (i > n) {
19
- const r = (a.minSize ?? 0) + s;
20
- return e > r ? i : n;
21
- } else {
22
- const r = (o.minSize ?? 0) - s;
23
- return e < r ? i : n;
24
- }
25
- }
26
- function g(t, e) {
27
- const n = window.scrollY;
28
- if (t.style.fontFamily = e.family, e.opszValue !== void 0) {
29
- const s = e.opszMin ?? -1 / 0, i = e.opszMax ?? 1 / 0, o = Math.min(i, Math.max(s, e.opszValue));
30
- t.style.fontVariationSettings = `"opsz" ${o}`;
31
- } else
32
- t.style.fontVariationSettings && (t.style.fontVariationSettings = "");
33
- requestAnimationFrame(() => {
34
- Math.abs(window.scrollY - n) > 2 && window.scrollTo({ top: n, behavior: "instant" });
35
- });
36
- }
37
- function v(t, e) {
38
- if (typeof window > "u") return;
39
- const { cuts: n, onCutChange: s } = e;
40
- if (!n || n.length === 0) return;
41
- const i = parseFloat(getComputedStyle(t).fontSize);
42
- if (isNaN(i)) return;
43
- const o = d(n, i);
44
- if (o === -1) return;
45
- const a = n[o];
46
- f.has(t) || f.set(t, t.style.fontFamily), g(t, a), p.set(t, o), s == null || s(a);
47
- }
48
- function V(t, e) {
49
- if (typeof window > "u") return () => {
50
- };
51
- const { cuts: n, onCutChange: s } = e, i = e.hysteresis ?? x;
52
- if (!n || n.length === 0) return () => {
53
- };
54
- const o = y.get(t);
55
- o && o(), f.has(t) || f.set(t, t.style.fontFamily), v(t, e);
56
- const a = new ResizeObserver(() => {
57
- const c = parseFloat(getComputedStyle(t).fontSize);
58
- if (isNaN(c)) return;
59
- const l = p.get(t) ?? -1, u = O(n, c, l, i);
60
- u !== l && u !== -1 && (g(t, n[u]), p.set(t, u), s == null || s(n[u]));
61
- });
62
- a.observe(t);
63
- const r = () => {
64
- a.disconnect();
65
- const c = f.get(t);
66
- c !== void 0 && (t.style.fontFamily = c, t.style.fontVariationSettings = "", f.delete(t)), p.delete(t), y.delete(t);
67
- };
68
- return y.set(t, r), r;
69
- }
70
- function R(t) {
71
- const e = y.get(t);
72
- if (e) {
73
- e();
74
- return;
75
- }
76
- if (f.has(t)) {
77
- const n = f.get(t);
78
- t.style.fontFamily = n ?? "", t.style.fontVariationSettings = "", f.delete(t), p.delete(t);
79
- }
80
- }
81
- function b(t) {
82
- const e = S(null), n = S(t);
83
- n.current = t;
84
- const s = S(null), i = JSON.stringify(t.cuts), { hysteresis: o } = t;
85
- return z(() => {
86
- var r;
87
- const a = e.current;
88
- if (a)
89
- return (r = s.current) == null || r.call(s), s.current = V(a, n.current), () => {
90
- var c;
91
- (c = s.current) == null || c.call(s), s.current = null;
92
- };
93
- }, [i, o]), e;
1
+ import { startOpszStepper as l } from "./core.js";
2
+ import { applyOpszStepper as j, removeOpszStepper as k } from "./core.js";
3
+ import { useState as S, useMemo as O, useRef as y, useLayoutEffect as z, forwardRef as x, useCallback as R } from "react";
4
+ import { jsx as g } from "react/jsx-runtime";
5
+ function C(r) {
6
+ const [n, o] = S(null), u = O(() => {
7
+ let e = null;
8
+ return {
9
+ get current() {
10
+ return e;
11
+ },
12
+ set current(t) {
13
+ t !== e && (e = t, o(t));
14
+ }
15
+ };
16
+ }, []), s = y(r);
17
+ s.current = r;
18
+ const c = JSON.stringify(r.cuts), { hysteresis: f } = r;
19
+ return z(() => n ? l(n, {
20
+ ...s.current,
21
+ onCutChange: (t) => {
22
+ var i, p;
23
+ return (p = (i = s.current).onCutChange) == null ? void 0 : p.call(i, t);
24
+ }
25
+ }) : void 0, [n, c, f]), u;
94
26
  }
95
- const N = h(
96
- function({ children: e, as: n = "p", cuts: s, hysteresis: i, onCutChange: o, ...a }, r) {
97
- const u = b({ cuts: s, hysteresis: i, onCutChange: o }), w = M(
98
- (m) => {
99
- u.current = m, typeof r == "function" ? r(m) : r && (r.current = m);
27
+ const d = x(
28
+ function({ children: n, as: o = "p", cuts: u, hysteresis: s, onCutChange: c, ...f }, e) {
29
+ const p = C({ cuts: u, hysteresis: s, onCutChange: c }), m = R(
30
+ (a) => {
31
+ p.current = a, typeof e == "function" ? e(a) : e && (e.current = a);
100
32
  },
101
33
  // eslint-disable-next-line react-hooks/exhaustive-deps
102
- [r]
34
+ [e]
103
35
  );
104
- return /* @__PURE__ */ F(n, { ref: w, ...a, children: e });
36
+ return /* @__PURE__ */ g(o, { ref: m, ...f, children: n });
105
37
  }
106
38
  );
107
- N.displayName = "OpszStepperText";
39
+ d.displayName = "OpszStepperText";
108
40
  export {
109
- N as OpszStepperText,
110
- v as applyOpszStepper,
111
- R as removeOpszStepper,
112
- V as startOpszStepper,
113
- b as useOpszStepper
41
+ d as OpszStepperText,
42
+ j as applyOpszStepper,
43
+ k as removeOpszStepper,
44
+ l as startOpszStepper,
45
+ C as useOpszStepper
114
46
  };
@@ -1 +1 @@
1
- var OpszStepper=(function(u){"use strict";const r=new WeakMap,z=new WeakMap,d=new WeakMap,N=1;function g(t,n){for(let o=0;o<t.length;o++){const s=t[o],i=s.minSize??0,e=s.maxSize??1/0;if(n>=i&&n<e)return o}return-1}function O(t,n,o,s){const i=g(t,n);if(i===-1)return o===-1?i:o;if(o===-1)return i;if(i===o)return o;const e=t[o],a=t[i];if(i>o){const c=(a.minSize??0)+s;return n>c?i:o}else{const c=(e.minSize??0)-s;return n<c?i:o}}function v(t,n){const o=window.scrollY;if(t.style.fontFamily=n.family,n.opszValue!==void 0){const s=n.opszMin??-1/0,i=n.opszMax??1/0,e=Math.min(i,Math.max(s,n.opszValue));t.style.fontVariationSettings=`"opsz" ${e}`}else t.style.fontVariationSettings&&(t.style.fontVariationSettings="");requestAnimationFrame(()=>{Math.abs(window.scrollY-o)>2&&window.scrollTo({top:o,behavior:"instant"})})}function F(t,n){if(typeof window>"u")return;const{cuts:o,onCutChange:s}=n;if(!o||o.length===0)return;const i=parseFloat(getComputedStyle(t).fontSize);if(isNaN(i))return;const e=g(o,i);if(e===-1)return;const a=o[e];r.has(t)||r.set(t,t.style.fontFamily),v(t,a),d.set(t,e),s==null||s(a)}function x(t,n){if(typeof window>"u")return()=>{};const{cuts:o,onCutChange:s}=n,i=n.hysteresis??N;if(!o||o.length===0)return()=>{};const e=z.get(t);e&&e(),r.has(t)||r.set(t,t.style.fontFamily),F(t,n);const a=new ResizeObserver(()=>{const f=parseFloat(getComputedStyle(t).fontSize);if(isNaN(f))return;const y=d.get(t)??-1,p=O(o,f,y,i);p!==y&&p!==-1&&(v(t,o[p]),d.set(t,p),s==null||s(o[p]))});a.observe(t);const c=()=>{a.disconnect();const f=r.get(t);f!==void 0&&(t.style.fontFamily=f,t.style.fontVariationSettings="",r.delete(t)),d.delete(t),z.delete(t)};return z.set(t,c),c}function b(t){const n=z.get(t);if(n){n();return}if(r.has(t)){const o=r.get(t);t.style.fontFamily=o??"",t.style.fontVariationSettings="",r.delete(t),d.delete(t)}}const V="data-opszstepper",S=new WeakMap;function C(t,n,o,s){const i=[];for(const e of t.split(",")){const a=e.trim();if(!a)continue;const[c,f]=a.split(":");if(f===void 0)continue;const y=parseFloat(f.trim());if(isNaN(y))continue;const[p,P]=c.split("-"),h=parseFloat((p??"").trim()),w=parseFloat((P??"").trim()),l={family:n,opszValue:y};isNaN(h)||(l.minSize=h),isNaN(w)||(l.maxSize=w),o!==void 0&&(l.opszMin=o),s!==void 0&&(l.opszMax=s),i.push(l)}return i}function T(t){if(!Array.isArray(t))return[];const n=[];for(const o of t){if(!o||typeof o!="object")continue;const s=o;if(typeof s.family!="string")continue;const i={family:s.family};typeof s.minSize=="number"&&(i.minSize=s.minSize),typeof s.maxSize=="number"&&(i.maxSize=s.maxSize),typeof s.opszValue=="number"&&(i.opszValue=s.opszValue),typeof s.opszMin=="number"&&(i.opszMin=s.opszMin),typeof s.opszMax=="number"&&(i.opszMax=s.opszMax),n.push(i)}return n}function A(t){const n=t.dataset;let o=[];if(n.osCuts)try{o=T(JSON.parse(n.osCuts))}catch{console.error("OpszStepper: data-os-cuts is not valid JSON — ignoring.")}if(o.length===0&&n.osFamily&&n.osOpsz){const i=n.osOpszMin!==void 0?parseFloat(n.osOpszMin):void 0,e=n.osOpszMax!==void 0?parseFloat(n.osOpszMax):void 0;o=C(n.osOpsz,n.osFamily,i!==void 0&&!isNaN(i)?i:void 0,e!==void 0&&!isNaN(e)?e:void 0)}const s={cuts:o};if(n.osHysteresis!==void 0){const i=parseFloat(n.osHysteresis);isNaN(i)||(s.hysteresis=i)}return s}function E(t){M(t);const n=A(t);if(n.cuts.length===0){console.warn("OpszStepper: no valid cuts found on element — skipping.");return}const o=x(t,n);S.set(t,{stop:o})}function M(t){const n=S.get(t);if(!n){b(t);return}n.stop(),S.delete(t)}function m(t=document){t.querySelectorAll(`[${V}]`).forEach(E)}function I(t=document){m(t)}function k(){const t=()=>{var n;(n=document.fonts)!=null&&n.ready?document.fonts.ready.then(()=>m()).catch(()=>m()):m()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}return k(),u.destroy=M,u.init=m,u.restart=I,Object.defineProperty(u,Symbol.toStringTag,{value:"Module"}),u})({});
1
+ var OpszStepper=(function(z){"use strict";function R(t){const e=t.filter(o=>o&&typeof o.family=="string"),n=o=>Number.isFinite(o.minSize)?o.minSize:Number.isFinite(o.maxSize)?o.maxSize-1e-9:1/0,s=[...e].sort((o,u)=>n(o)-n(u)),i=s.map((o,u)=>{const c=s[u-1];return{min:Number.isFinite(o.minSize)?o.minSize:c&&Number.isFinite(c.maxSize)?c.maxSize:0,max:Number.isFinite(o.maxSize)?o.maxSize:1/0,cut:o}});for(let o=0;o<i.length-1;o++)i[o].max===1/0&&(i[o].max=i[o+1].min);return i}function C(t,e){for(let n=0;n<t.length;n++)if(e>=t[n].min&&e<t[n].max)return n;return-1}function H(t,e,n,s){const i=C(t,e);if(n===-1||!t[n])return i;if(i===-1||i===n)return n;const o=t[n];return e>=o.max+s||e<o.min-s?i:n}const M=new Set;function O(t){M.has(t)||(M.add(t),console.warn(t))}function L(t,e){let n=t??1;(!Number.isFinite(n)||n<0)&&(O(`[opszStepper] hysteresis must be a non-negative number; got ${t}, using 1`),n=1);const s=Math.min(...e.map(i=>i.max-i.min).filter(i=>Number.isFinite(i)&&i>0));return Number.isFinite(s)&&n>s/2&&(O(`[opszStepper] hysteresis ${n}px is more than half the narrowest cut (${s}px); using ${s/2}px`),n=s/2),n}const l=new Map;function w(t,e){return{value:t.style.getPropertyValue(e),priority:t.style.getPropertyPriority(e)}}function N(t,e,n){n.value?t.style.setProperty(e,n.value,n.priority):t.style.removeProperty(e)}function _(t,e){const n=R(e.cuts),s=L(e.hysteresis,n);let i=l.get(t);return i?(i.cuts=n,i.hysteresis=s,i.onCutChange=e.onCutChange):(i={cuts:n,hysteresis:s,onCutChange:e.onCutChange,activeIndex:-1,origFamily:w(t,"font-family"),origFVS:w(t,"font-variation-settings"),origStyleAttr:t.getAttribute("style"),baseFVS:A(t),writtenFVS:null,writtenStyleAttr:null,live:!1},l.set(t,i)),i}function A(t){const e=getComputedStyle(t);return(typeof e.getPropertyValue=="function"?e.getPropertyValue("font-variation-settings"):e.fontVariationSettings)||"normal"}function D(t,e){const n=`"opsz" ${e}`;if(!t||t==="normal")return n;const s=/(["'])opsz\1\s+-?[\d.eE+-]+/;return s.test(t)?t.replace(s,n):`${t}, ${n}`}function E(t,e,n){const s=e.cuts[n].cut;if(e.writtenFVS!==null&&t.style.getPropertyValue("font-variation-settings")!==e.writtenFVS&&(e.origFVS=w(t,"font-variation-settings"),e.baseFVS=A(t),e.writtenFVS=null),t.style.setProperty("font-family",s.family,e.origFamily.priority),typeof s.opszValue=="number"&&Number.isFinite(s.opszValue)){const i=Number.isFinite(s.opszMin)?s.opszMin:-1/0,o=Number.isFinite(s.opszMax)?s.opszMax:1/0,u=i<=o?Math.min(o,Math.max(i,s.opszValue)):s.opszValue,c=D(e.baseFVS,u);t.style.setProperty("font-variation-settings",c,e.origFVS.priority),e.writtenFVS=t.style.getPropertyValue("font-variation-settings")}else e.writtenFVS!==null&&(N(t,"font-variation-settings",e.origFVS),e.writtenFVS=null);e.activeIndex=n,e.writtenStyleAttr=t.getAttribute("style")}function P(t){const e=l.get(t);e&&(e.writtenStyleAttr!==null&&t.getAttribute("style")===e.writtenStyleAttr?e.origStyleAttr===null?t.removeAttribute("style"):t.setAttribute("style",e.origStyleAttr):(N(t,"font-family",e.origFamily),e.writtenFVS!==null&&N(t,"font-variation-settings",e.origFVS),e.origStyleAttr===null&&!t.getAttribute("style")&&t.removeAttribute("style")),l.delete(t))}const b=new Set;let p=null,I=null;const y=new Map;let S=null,x=!1;function J(){var e;x=!1;const t=[];b.forEach(n=>{const s=l.get(n);if(!s||!n.isConnected)return;const i=parseFloat(getComputedStyle(n).fontSize);if(!Number.isFinite(i))return;const o=H(s.cuts,i,s.activeIndex,s.hysteresis);o!==-1&&o!==s.activeIndex&&t.push([n,s,o])});for(const[n,s,i]of t)E(n,s,i),(e=s.onCutChange)==null||e.call(s,s.cuts[i].cut)}function h(){x||(x=!0,queueMicrotask(J))}function U(){typeof MutationObserver<"u"&&!S&&document.documentElement&&(S=new MutationObserver(h),S.observe(document.documentElement,{attributes:!0,attributeFilter:["class","style","lang","dir"],subtree:!0}),window.addEventListener("resize",h))}function Y(){b.size||(p==null||p.disconnect(),p=null,y.clear(),S==null||S.disconnect(),S=null,typeof window<"u"&&window.removeEventListener("resize",h))}function k(t,e){var o,u,c;if(typeof window>"u"||!t)return()=>{};if(!(e!=null&&e.cuts)||e.cuts.length===0)return()=>{};(u=(o=l.get(t))==null?void 0:o.stop)==null||u.call(o);const n=_(t,e),s=parseFloat(getComputedStyle(t).fontSize);if(Number.isFinite(s)){const r=C(n.cuts,s);r!==-1&&r!==n.activeIndex&&(E(t,n,r),(c=n.onCutChange)==null||c.call(n,n.cuts[r].cut))}if(U(),b.add(t),n.live=!0,typeof ResizeObserver<"u"&&((!p||I!==ResizeObserver)&&(p=new ResizeObserver(h),I=ResizeObserver,y.clear()),n.ro=p,n.ro.observe(t),n.observedParent=t.parentElement,n.observedParent)){const r=y.get(n.observedParent)??0;r===0&&n.ro.observe(n.observedParent),y.set(n.observedParent,r+1)}const i=()=>{var m,v,g,a,d;if(((m=l.get(t))==null?void 0:m.stop)!==i)return;b.delete(t);const r=l.get(t);if(r!=null&&r.ro&&r.ro===p){(g=(v=r.ro).unobserve)==null||g.call(v,t);const f=r.observedParent;if(f){const $=(y.get(f)??1)-1;$<=0?(y.delete(f),(d=(a=r.ro).unobserve)==null||d.call(a,f)):y.set(f,$)}}P(t),Y()};return n.stop=i,i}function W(t){const e=l.get(t);e&&(e.stop?e.stop():P(t))}const j="data-opszstepper",V=new WeakMap;function q(t,e,n,s){const i=[];for(const o of t.split(",")){const u=o.trim();if(!u)continue;const[c,r]=u.split(":");if(r===void 0)continue;const m=parseFloat(r.trim());if(isNaN(m))continue;const[v,g]=c.replace(/[\u2013\u2014]/g,"-").split("-"),a=parseFloat((v??"").trim()),d=parseFloat((g??"").trim()),f={family:e,opszValue:m};isNaN(a)||(f.minSize=a),isNaN(d)||(f.maxSize=d),n!==void 0&&(f.opszMin=n),s!==void 0&&(f.opszMax=s),i.push(f)}return i}function Q(t){if(!Array.isArray(t))return[];const e=[];for(const n of t){if(!n||typeof n!="object")continue;const s=n;if(typeof s.family!="string")continue;const i={family:s.family},o=g=>{const a=s[g];if(a==null)return;const d=typeof a=="number"?a:typeof a=="string"&&a.trim()!==""?Number(a):NaN;if(Number.isFinite(d))return d;console.warn(`OpszStepper: "${g}" in data-os-cuts must be a number; got ${JSON.stringify(a)} — ignoring it.`)},u=o("minSize");u!==void 0&&(i.minSize=u);const c=o("maxSize");c!==void 0&&(i.maxSize=c);const r=o("opszValue");r!==void 0&&(i.opszValue=r);const m=o("opszMin");m!==void 0&&(i.opszMin=m);const v=o("opszMax");v!==void 0&&(i.opszMax=v),e.push(i)}return e}function B(t){const e=t.dataset;let n=[];if(e.osCuts)try{n=Q(JSON.parse(e.osCuts))}catch{console.error("OpszStepper: data-os-cuts is not valid JSON — ignoring.")}if(n.length===0&&e.osFamily&&e.osOpsz){const i=e.osOpszMin!==void 0?parseFloat(e.osOpszMin):void 0,o=e.osOpszMax!==void 0?parseFloat(e.osOpszMax):void 0;n=q(e.osOpsz,e.osFamily,i!==void 0&&!isNaN(i)?i:void 0,o!==void 0&&!isNaN(o)?o:void 0)}const s={cuts:n};if(e.osHysteresis!==void 0){const i=parseFloat(e.osHysteresis);isNaN(i)||(s.hysteresis=i)}return s}function G(t){T(t);const e=B(t);if(e.cuts.length===0){console.warn("OpszStepper: no valid cuts found on element — skipping.");return}const n=k(t,e);V.set(t,{stop:n})}function T(t){const e=V.get(t);if(!e){W(t);return}e.stop(),V.delete(t)}function F(t=document){t.querySelectorAll(`[${j}]`).forEach(G)}function K(t=document){F(t)}function X(){const t=()=>{var e;(e=document.fonts)!=null&&e.ready?document.fonts.ready.then(()=>F()).catch(()=>F()):F()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}return X(),z.destroy=T,z.init=F,z.restart=K,Object.defineProperty(z,Symbol.toStringTag,{value:"Module"}),z})({});
package/package.json CHANGED
@@ -1,84 +1,89 @@
1
- {
2
- "name": "@overpunch/opszstepper",
3
- "version": "1.0.18",
4
- "description": "Multi-cut optical family hot-swap by font-size — automatically applies Micro, Text, or Display cuts based on the element's computed font-size",
5
- "type": "module",
6
- "main": "dist/index.cjs",
7
- "exports": {
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js",
11
- "require": "./dist/index.cjs"
12
- }
13
- },
14
- "files": [
15
- "dist"
16
- ],
17
- "scripts": {
18
- "build": "vite build",
19
- "build:webflow": "vite build --config vite.webflow.config.ts",
20
- "test": "vitest run",
21
- "test:run": "vitest run",
22
- "typecheck": "tsc --noEmit",
23
- "prepublishOnly": "npm run test && npm run build && npm run build:webflow"
24
- },
25
- "peerDependencies": {
26
- "react": ">=17",
27
- "react-dom": ">=17"
28
- },
29
- "peerDependenciesMeta": {
30
- "react": {
31
- "optional": true
32
- },
33
- "react-dom": {
34
- "optional": true
35
- }
36
- },
37
- "devDependencies": {
38
- "@testing-library/react": "^16.3.2",
39
- "@testing-library/user-event": "^14.6.1",
40
- "@types/react": "^19.0.0",
41
- "@vitejs/plugin-react": "^4.0.0",
42
- "happy-dom": "^20.10.6",
43
- "next": "16.2.2",
44
- "react": "^19.0.0",
45
- "typescript": "^5.0.0",
46
- "vite": "^6.0.0",
47
- "vite-plugin-dts": "^4.0.0",
48
- "vitest": "^3.0.0"
49
- },
50
- "keywords": [
51
- "typography",
52
- "web-typography",
53
- "frontend",
54
- "liiift-studio",
55
- "optical-size",
56
- "font-family",
57
- "opsz",
58
- "optical-sizing",
59
- "font-swap",
60
- "fluid-type",
61
- "multi-cut",
62
- "responsive-type",
63
- "responsive-design",
64
- "typographic",
65
- "zero-dependencies",
66
- "react",
67
- "typescript",
68
- "css"
69
- ],
70
- "author": "Quinn Keaveney <quinn@liiift.studio>",
71
- "license": "MIT",
72
- "homepage": "https://opszstepper.com",
73
- "repository": {
74
- "type": "git",
75
- "url": "git+https://github.com/over-punch/opszStepper.git"
76
- },
77
- "bugs": {
78
- "url": "https://github.com/over-punch/opszStepper/issues"
79
- },
80
- "sideEffects": false,
81
- "publishConfig": {
82
- "access": "public"
83
- }
84
- }
1
+ {
2
+ "name": "@overpunch/opszstepper",
3
+ "version": "1.1.0",
4
+ "description": "Multi-cut optical family hot-swap by font-size — automatically applies Micro, Text, or Display cuts based on the element's computed font-size",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.cjs"
12
+ },
13
+ "./core": {
14
+ "types": "./dist/core.d.ts",
15
+ "import": "./dist/core.js",
16
+ "require": "./dist/core.cjs"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "vite build",
24
+ "build:webflow": "vite build --config vite.webflow.config.ts",
25
+ "test": "vitest run",
26
+ "test:run": "vitest run",
27
+ "typecheck": "tsc --noEmit",
28
+ "prepublishOnly": "npm run test && npm run build && npm run build:webflow"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=17",
32
+ "react-dom": ">=17"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "react": {
36
+ "optional": true
37
+ },
38
+ "react-dom": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@testing-library/react": "^16.3.2",
44
+ "@testing-library/user-event": "^14.6.1",
45
+ "@types/react": "^19.0.0",
46
+ "@vitejs/plugin-react": "^4.0.0",
47
+ "happy-dom": "^20.10.6",
48
+ "next": "16.2.2",
49
+ "react": "^19.0.0",
50
+ "typescript": "^5.0.0",
51
+ "vite": "^6.0.0",
52
+ "vite-plugin-dts": "^4.0.0",
53
+ "vitest": "^3.0.0"
54
+ },
55
+ "keywords": [
56
+ "typography",
57
+ "web-typography",
58
+ "frontend",
59
+ "liiift-studio",
60
+ "optical-size",
61
+ "font-family",
62
+ "opsz",
63
+ "optical-sizing",
64
+ "font-swap",
65
+ "fluid-type",
66
+ "multi-cut",
67
+ "responsive-type",
68
+ "responsive-design",
69
+ "typographic",
70
+ "zero-dependencies",
71
+ "react",
72
+ "typescript",
73
+ "css"
74
+ ],
75
+ "author": "Quinn Keaveney <quinn@overpunch.ca>",
76
+ "license": "MIT",
77
+ "homepage": "https://opszstepper.com",
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "git+https://github.com/over-punch/opszStepper.git"
81
+ },
82
+ "bugs": {
83
+ "url": "https://github.com/over-punch/opszStepper/issues"
84
+ },
85
+ "sideEffects": false,
86
+ "publishConfig": {
87
+ "access": "public"
88
+ }
89
+ }