@overpunch/opszstepper 1.0.18

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 ADDED
@@ -0,0 +1,212 @@
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
package/dist/index.cjs ADDED
@@ -0,0 +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;
@@ -0,0 +1,160 @@
1
+ import { default as default_2 } from 'react';
2
+ import { RefObject } from 'react';
3
+
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.
8
+ *
9
+ * @param el - Target element
10
+ * @param options - OpszStepperOptions
11
+ */
12
+ export declare function applyOpszStepper(el: HTMLElement, options: OpszStepperOptions): void;
13
+
14
+ /**
15
+ * A single optical size cut definition: a font-family string and the font-size
16
+ * range in px over which it should be active.
17
+ *
18
+ * Two usage modes:
19
+ * 1. Multi-family hot-swap (e.g. Halyard Micro / Text / Display):
20
+ * set `family` to the CSS font-family string for each cut. Leave `opszValue` unset.
21
+ * 2. Single variable-font opsz axis (e.g. Recursive, Fraunces, Amstelvar):
22
+ * set `family` to the single variable font family, and set `opszValue` to the
23
+ * `opsz` axis value to apply at this cut. The tool will write
24
+ * `font-variation-settings: "opsz" <value>` on the element.
25
+ * Optionally provide `opszMin` and `opszMax` to clamp the value against the
26
+ * font's fvar axis range.
27
+ */
28
+ export declare interface OpszStepperCut {
29
+ /**
30
+ * The CSS font-family string for this optical cut.
31
+ * e.g. 'Halyard Display, sans-serif' or '"Tiempos Display", serif'
32
+ * For variable-font opsz mode, use the same family string in all cuts.
33
+ */
34
+ family: string;
35
+ /** Min font-size in px (inclusive) for this cut to apply. Default: 0 */
36
+ minSize?: number;
37
+ /** Max font-size in px (exclusive) for this cut to apply. Default: Infinity */
38
+ maxSize?: number;
39
+ /**
40
+ * Optional `opsz` axis value to write as `font-variation-settings: "opsz" <value>`.
41
+ * Use this when all cuts share one variable font and you want to drive the opsz axis
42
+ * directly rather than hot-swapping font families.
43
+ * When provided, `font-variation-settings: normal` is written first to clear any
44
+ * inherited axis values before applying the new `opsz` value.
45
+ * Clamped between `opszMin` and `opszMax` when those are supplied.
46
+ */
47
+ opszValue?: number;
48
+ /**
49
+ * Minimum allowed value for the `opsz` axis (from the font's fvar table).
50
+ * Only used when `opszValue` is set. Clamps the written value from below.
51
+ */
52
+ opszMin?: number;
53
+ /**
54
+ * Maximum allowed value for the `opsz` axis (from the font's fvar table).
55
+ * Only used when `opszValue` is set. Clamps the written value from above.
56
+ */
57
+ opszMax?: number;
58
+ }
59
+
60
+ /** Options controlling the opszStepper effect */
61
+ export declare interface OpszStepperOptions {
62
+ /**
63
+ * The optical size cuts, ordered from smallest to largest.
64
+ * Each cut defines a font-family string and the font-size range it applies to.
65
+ * Ranges should be contiguous and non-overlapping.
66
+ *
67
+ * @example Multi-family hot-swap
68
+ * cuts: [
69
+ * { family: 'Halyard Micro, sans-serif', maxSize: 13 },
70
+ * { family: 'Halyard Text, sans-serif', minSize: 13, maxSize: 28 },
71
+ * { family: 'Halyard Display, sans-serif', minSize: 28 },
72
+ * ]
73
+ *
74
+ * @example Single variable font opsz axis
75
+ * cuts: [
76
+ * { family: 'Fraunces, serif', maxSize: 13, opszValue: 9, opszMin: 9, opszMax: 144 },
77
+ * { family: 'Fraunces, serif', minSize: 13, maxSize: 28, opszValue: 24, opszMin: 9, opszMax: 144 },
78
+ * { family: 'Fraunces, serif', minSize: 28, opszValue: 72, opszMin: 9, opszMax: 144 },
79
+ * ]
80
+ */
81
+ cuts: OpszStepperCut[];
82
+ /**
83
+ * Hysteresis dead zone in px per threshold. Prevents oscillation when
84
+ * font-size sits exactly at a cut boundary. Only used by startOpszStepper;
85
+ * applyOpszStepper always does a direct cut lookup without hysteresis.
86
+ * Default: 1
87
+ */
88
+ hysteresis?: number;
89
+ /**
90
+ * Callback fired each time the active cut changes.
91
+ * Receives the new cut that was applied.
92
+ */
93
+ onCutChange?: (cut: OpszStepperCut) => void;
94
+ }
95
+
96
+ /** A stop function returned by startOpszStepper. Call it to disconnect the observer and restore the element. */
97
+ export declare type OpszStepperStop = () => void;
98
+
99
+ /**
100
+ * Drop-in component that automatically swaps between optical size cuts
101
+ * of a typeface family as the element's font-size changes.
102
+ * Forwards the ref to the root element while also attaching the internal hook ref.
103
+ * Accepts all standard HTML and ARIA attributes.
104
+ */
105
+ export declare const OpszStepperText: default_2.ForwardRefExoticComponent<OpszStepperTextProps & default_2.RefAttributes<HTMLElement>>;
106
+
107
+ /**
108
+ * Props for the OpszStepperText component.
109
+ * Extends OpszStepperOptions plus all standard HTML attributes (including ARIA)
110
+ * so that aria-label, role, tabIndex, id, etc. are accepted and forwarded to the DOM.
111
+ */
112
+ declare interface OpszStepperTextProps extends OpszStepperOptions, default_2.HTMLAttributes<HTMLElement> {
113
+ /** HTML element to render. Default: 'p' */
114
+ as?: default_2.ElementType;
115
+ }
116
+
117
+ /**
118
+ * Restore the element's original fontFamily and disconnect any running
119
+ * ResizeObserver started by startOpszStepper. No-op if never applied.
120
+ *
121
+ * @param el - Element previously passed to startOpszStepper or applyOpszStepper
122
+ */
123
+ export declare function removeOpszStepper(el: HTMLElement): void;
124
+
125
+ /**
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.
133
+ *
134
+ * @param el - Target element
135
+ * @param options - OpszStepperOptions
136
+ * @returns A stop function that disconnects the observer and restores the original fontFamily
137
+ */
138
+ export declare function startOpszStepper(el: HTMLElement, options: OpszStepperOptions): OpszStepperStop;
139
+
140
+ /**
141
+ * React hook that starts an opszStepper observer on the returned ref'd element.
142
+ * Calls startOpszStepper in useLayoutEffect and stores the returned stop function.
143
+ *
144
+ * Re-runs (stops and restarts the observer) whenever any cut's family, minSize, or
145
+ * maxSize changes, or when cuts are added/removed, or when hysteresis changes.
146
+ * A stable JSON serialisation of the cuts array is used as the dependency key so
147
+ * that same-length arrays with different content also trigger a restart.
148
+ *
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.
152
+ *
153
+ * Cleans up on unmount.
154
+ *
155
+ * @param options - OpszStepperOptions
156
+ * @returns A ref to attach to the target element
157
+ */
158
+ export declare function useOpszStepper(options: OpszStepperOptions): RefObject<HTMLElement | null>;
159
+
160
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
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;
94
+ }
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);
100
+ },
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ [r]
103
+ );
104
+ return /* @__PURE__ */ F(n, { ref: w, ...a, children: e });
105
+ }
106
+ );
107
+ N.displayName = "OpszStepperText";
108
+ export {
109
+ N as OpszStepperText,
110
+ v as applyOpszStepper,
111
+ R as removeOpszStepper,
112
+ V as startOpszStepper,
113
+ b as useOpszStepper
114
+ };
@@ -0,0 +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})({});
package/package.json ADDED
@@ -0,0 +1,84 @@
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
+ }