@liiift-studio/steadygray 0.0.1

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,23 @@
1
+ # Steady Gray
2
+
3
+ **[steadygray.com](https://steadygray.com)** · [npm](https://www.npmjs.com/package/@liiift-studio/steadygray) · [GitHub](https://github.com/Liiift-Studio/SteadyGray)
4
+
5
+ ---
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @liiift-studio/steadygray
11
+ ```
12
+
13
+ See [steadygray.com](https://steadygray.com) for full API docs and a live demo.
14
+
15
+ ---
16
+
17
+ ## Dev notes
18
+
19
+ ### `next` in root devDependencies
20
+
21
+ `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.
22
+
23
+ The package itself has zero runtime dependencies. Do not remove this entry.
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const w=require("react"),W=require("react/jsx-runtime"),L={word:"gv-word",line:"gv-line"},C={targetDensity:"auto",method:"letter-spacing",maxAdjustment:.05,calibrationFactor:2};function z(o){const r=getComputedStyle(o),s=r.fontWeight,a=r.fontSize,t=r.fontFamily.split(",")[0].replace(/['"]/g,"").trim();return`${s} ${a} ${t}`}function v(o,r,s,a,t){const i=t.getContext("2d");if(!i)return 0;t.width=Math.max(1,Math.ceil(s)),t.height=Math.max(1,Math.ceil(a)),i.clearRect(0,0,t.width,t.height),i.fillStyle="white",i.fillRect(0,0,t.width,t.height),i.fillStyle="black",i.font=r,i.fillText(o,0,t.height*.75);const c=i.getImageData(0,0,t.width,t.height).data;let p=0;const f=t.width*t.height;for(let u=0;u<c.length;u+=4){const A=c[u],b=c[u+1],y=c[u+2];(A<200||b<200||y<200)&&p++}return f>0?p/f:0}function G(o){const r=o.cloneNode(!0);return r.querySelectorAll(`.${L.word}, .${L.line}`).forEach(a=>{const t=a.parentNode;if(t){for(;a.firstChild;)t.insertBefore(a.firstChild,a);t.removeChild(a)}}),r.querySelectorAll("br[data-gv-break]").forEach(a=>a.remove()),r.innerHTML}function V(o,r,s={},a){if(typeof window>"u")return;const t=window.scrollY,i=s.targetDensity??C.targetDensity,d=s.method??C.method,c=s.maxAdjustment??C.maxAdjustment,p=s.calibrationFactor??C.calibrationFactor;if(o.innerHTML=r,!o.offsetWidth){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const f=o.offsetWidth,u=parseFloat(getComputedStyle(o).fontSize)||16,A=z(o),b=[];(function n(e){e.nodeType===Node.TEXT_NODE?b.push(e):e.childNodes.forEach(n)})(o);const y=[];for(const n of b){const e=n.textContent??"";if(!e.trim())continue;const l=e.split(/(\S+)/),D=document.createDocumentFragment();for(let h=0;h<l.length;h+=2){const N=l[h],S=l[h+1];if(!S)continue;const M=l[h+3]===void 0?l[h+2]??"":"",x=document.createElement("span");x.className=L.word,x.style.cssText="display:inline-block;white-space:nowrap;",x.appendChild(document.createTextNode(N+S+M)),D.appendChild(x),y.push(x)}n.parentNode.replaceChild(D,n)}if(y.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const m=[];let F=null,g=null;for(const n of y){const e=n.getBoundingClientRect(),l=Math.round(e.top);F===null||l!==F?(F=l,g={text:"",width:e.width,height:e.height||u,spans:[n]},m.push(g)):(g.width+=e.width,e.height>g.height&&(g.height=e.height),g.spans.push(n))}for(const n of m)n.text=n.spans.map(e=>e.textContent??"").join("").trim();if(m.length===0){requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})});return}const k=a??document.createElement("canvas"),T=m.map(n=>v(n.text,A,f,n.height||u,k));let E;if(typeof i=="number")E=i;else{const n=T.reduce((e,l)=>e+l,0);E=T.length>0?n/T.length:0}const Y=T.map(n=>{const e=(E-n)*p;return Math.max(-c,Math.min(c,e))});o.innerHTML=r;const H="display:inline-block;white-space:nowrap;vertical-align:top;";let R="";m.forEach((n,e)=>{const l=Y[e],h=`${H}${d==="word-spacing"?"word-spacing":"letter-spacing"}:${l}em;`,N=n.spans.map((S,j)=>{const M=S.textContent??"";return j===0?M.replace(/^[^\S\u00a0]+/,""):M}).join("");R+=`<span class="${L.line}" style="${h}">${N}</span>`,e<m.length-1&&(R+='<br data-gv-break="1">')}),o.innerHTML=R,requestAnimationFrame(()=>{Math.abs(window.scrollY-t)>2&&window.scrollTo({top:t,behavior:"instant"})})}function P(o,r){o.innerHTML=r}function $(o){const r=w.useRef(null),s=w.useRef(null),a=w.useRef(o);a.current=o;const t=w.useCallback(()=>{const i=r.current;i&&(s.current===null&&(s.current=G(i)),V(i,s.current,a.current))},[]);return w.useLayoutEffect(()=>{t();let i=0,d=0;const c=new ResizeObserver(p=>{const f=Math.round(p[0].contentRect.width);f!==i&&(i=f,cancelAnimationFrame(d),d=requestAnimationFrame(t))});return c.observe(r.current),()=>{c.disconnect(),cancelAnimationFrame(d)}},[t]),r}const q=w.forwardRef(function({children:r,className:s,style:a,as:t="p",...i},d){const c=$(i);return W.jsx(t,{ref:c,className:s,style:a,children:r})});q.displayName="GrayValueText";exports.GrayValueText=q;exports.applyGrayValue=V;exports.getCleanHTML=G;exports.measureLineDensity=v;exports.removeGrayValue=P;exports.useGrayValue=$;
@@ -0,0 +1,106 @@
1
+ import { default as default_2 } from 'react';
2
+ import { RefObject } from 'react';
3
+
4
+ /**
5
+ * Applies gray-value optical density equalization to an element.
6
+ *
7
+ * Algorithm (7 passes):
8
+ * 1. Reset — restore original HTML
9
+ * 2. Word wrap — wrap each word in a gv-word span
10
+ * 3. Read phase — group words into visual lines via BCR
11
+ * 4. Measure — render each line to Canvas and compute density ratio
12
+ * 5. Target — compute target density (average or user-specified)
13
+ * 6. Adjust — calculate per-line letter-spacing via linear approximation
14
+ * 7. Write — rebuild HTML with gv-line spans carrying adjusted spacing
15
+ *
16
+ * Note: binary-search refinement of the spacing value is a future enhancement.
17
+ * The linear approximation (calibrationFactor × density delta) is sufficient for v1
18
+ * because relative density comparison between lines is what matters most.
19
+ *
20
+ * @param element - Live DOM element to adjust (must be rendered and visible)
21
+ * @param originalHTML - HTML snapshot taken before the first adjustment run
22
+ * @param options - GrayValueOptions (merged with defaults)
23
+ * @param _canvas - Optional injectable Canvas for testing (creates one if absent)
24
+ */
25
+ export declare function applyGrayValue(element: HTMLElement, originalHTML: string, options?: GrayValueOptions, _canvas?: HTMLCanvasElement): void;
26
+
27
+ /**
28
+ * Returns the innerHTML of an element with all gray-value injected spans removed,
29
+ * unwrapping their children in place. Safe for complex markup. Idempotent.
30
+ *
31
+ * @param el - Element that may contain gray-value markup
32
+ */
33
+ export declare function getCleanHTML(el: HTMLElement): string;
34
+
35
+ /** Options controlling the gray-value density-equalization algorithm */
36
+ export declare interface GrayValueOptions {
37
+ /**
38
+ * Target optical density ratio (0–1).
39
+ * 'auto' = average of all measured line densities (default).
40
+ */
41
+ targetDensity?: number | 'auto';
42
+ /**
43
+ * Which CSS spacing property to adjust per line.
44
+ * Default: 'letter-spacing'.
45
+ */
46
+ method?: 'letter-spacing' | 'word-spacing';
47
+ /**
48
+ * Maximum spacing adjustment in em units.
49
+ * Positive and negative adjustments are both clamped to this magnitude.
50
+ * Default: 0.05
51
+ */
52
+ maxAdjustment?: number;
53
+ /**
54
+ * Acceptable density difference before a line is considered equalized.
55
+ * Default: 0.01
56
+ */
57
+ tolerance?: number;
58
+ /**
59
+ * Linear scaling factor: em spacing change per 1.0 density unit difference.
60
+ * Increase to apply stronger corrections, decrease to be more conservative.
61
+ * Default: 2.0
62
+ */
63
+ calibrationFactor?: number;
64
+ }
65
+
66
+ /**
67
+ * Drop-in component that applies the gray-value effect to its children.
68
+ */
69
+ export declare const GrayValueText: default_2.ForwardRefExoticComponent<GrayValueTextProps & default_2.RefAttributes<HTMLElement>>;
70
+
71
+ declare interface GrayValueTextProps extends GrayValueOptions {
72
+ children: default_2.ReactNode;
73
+ className?: string;
74
+ style?: default_2.CSSProperties;
75
+ as?: default_2.ElementType;
76
+ }
77
+
78
+ /**
79
+ * Measures the optical density (ink pixel ratio) of a single line of text
80
+ * by rendering it to a Canvas and counting non-white pixels.
81
+ *
82
+ * Returns a value in [0, 1] where 0 = no ink and 1 = fully black.
83
+ *
84
+ * @param text - The text content of the line
85
+ * @param fontStyle - Canvas-compatible font string (e.g. "400 18px Georgia")
86
+ * @param targetWidth - Width of the canvas in CSS pixels
87
+ * @param lineHeight - Height of the canvas in CSS pixels
88
+ * @param canvas - Canvas element to render into (reused across calls)
89
+ */
90
+ export declare function measureLineDensity(text: string, fontStyle: string, targetWidth: number, lineHeight: number, canvas: HTMLCanvasElement): number;
91
+
92
+ /**
93
+ * Remove gray-value markup and restore original HTML.
94
+ *
95
+ * @param element - Element previously adjusted by applyGrayValue
96
+ * @param originalHTML - The clean HTML snapshot passed to applyGrayValue
97
+ */
98
+ export declare function removeGrayValue(element: HTMLElement, originalHTML: string): void;
99
+
100
+ /**
101
+ * React hook that applies the gray-value effect to a ref'd element.
102
+ * Automatically re-runs on resize (width changes only).
103
+ */
104
+ export declare function useGrayValue(options: GrayValueOptions): RefObject<HTMLElement | null>;
105
+
106
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ import { useRef as R, useCallback as Y, useLayoutEffect as q, forwardRef as G } from "react";
2
+ import { jsx as V } from "react/jsx-runtime";
3
+ const A = {
4
+ word: "gv-word",
5
+ line: "gv-line"
6
+ }, M = {
7
+ targetDensity: "auto",
8
+ method: "letter-spacing",
9
+ maxAdjustment: 0.05,
10
+ calibrationFactor: 2
11
+ };
12
+ function W(o) {
13
+ const i = getComputedStyle(o), a = i.fontWeight, s = i.fontSize, t = i.fontFamily.split(",")[0].replace(/['"]/g, "").trim();
14
+ return `${a} ${s} ${t}`;
15
+ }
16
+ function H(o, i, a, s, t) {
17
+ const r = t.getContext("2d");
18
+ if (!r) return 0;
19
+ t.width = Math.max(1, Math.ceil(a)), t.height = Math.max(1, Math.ceil(s)), r.clearRect(0, 0, t.width, t.height), r.fillStyle = "white", r.fillRect(0, 0, t.width, t.height), r.fillStyle = "black", r.font = i, r.fillText(o, 0, t.height * 0.75);
20
+ const c = r.getImageData(0, 0, t.width, t.height).data;
21
+ let p = 0;
22
+ const f = t.width * t.height;
23
+ for (let h = 0; h < c.length; h += 4) {
24
+ const C = c[h], x = c[h + 1], w = c[h + 2];
25
+ (C < 200 || x < 200 || w < 200) && p++;
26
+ }
27
+ return f > 0 ? p / f : 0;
28
+ }
29
+ function z(o) {
30
+ const i = o.cloneNode(!0);
31
+ return i.querySelectorAll(
32
+ `.${A.word}, .${A.line}`
33
+ ).forEach((s) => {
34
+ const t = s.parentNode;
35
+ if (t) {
36
+ for (; s.firstChild; ) t.insertBefore(s.firstChild, s);
37
+ t.removeChild(s);
38
+ }
39
+ }), i.querySelectorAll("br[data-gv-break]").forEach((s) => s.remove()), i.innerHTML;
40
+ }
41
+ function _(o, i, a = {}, s) {
42
+ if (typeof window > "u") return;
43
+ const t = window.scrollY, r = a.targetDensity ?? M.targetDensity, d = a.method ?? M.method, c = a.maxAdjustment ?? M.maxAdjustment, p = a.calibrationFactor ?? M.calibrationFactor;
44
+ if (o.innerHTML = i, !o.offsetWidth) {
45
+ requestAnimationFrame(() => {
46
+ Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
47
+ });
48
+ return;
49
+ }
50
+ const f = o.offsetWidth, h = parseFloat(getComputedStyle(o).fontSize) || 16, C = W(o), x = [];
51
+ (function n(e) {
52
+ e.nodeType === Node.TEXT_NODE ? x.push(e) : e.childNodes.forEach(n);
53
+ })(o);
54
+ const w = [];
55
+ for (const n of x) {
56
+ const e = n.textContent ?? "";
57
+ if (!e.trim()) continue;
58
+ const l = e.split(/(\S+)/), N = document.createDocumentFragment();
59
+ for (let u = 0; u < l.length; u += 2) {
60
+ const D = l[u], T = l[u + 1];
61
+ if (!T) continue;
62
+ const S = l[u + 3] === void 0 ? l[u + 2] ?? "" : "", y = document.createElement("span");
63
+ y.className = A.word, y.style.cssText = "display:inline-block;white-space:nowrap;", y.appendChild(document.createTextNode(D + T + S)), N.appendChild(y), w.push(y);
64
+ }
65
+ n.parentNode.replaceChild(N, n);
66
+ }
67
+ if (w.length === 0) {
68
+ requestAnimationFrame(() => {
69
+ Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
70
+ });
71
+ return;
72
+ }
73
+ const m = [];
74
+ let F = null, g = null;
75
+ for (const n of w) {
76
+ const e = n.getBoundingClientRect(), l = Math.round(e.top);
77
+ F === null || l !== F ? (F = l, g = {
78
+ text: "",
79
+ width: e.width,
80
+ height: e.height || h,
81
+ spans: [n]
82
+ }, m.push(g)) : (g.width += e.width, e.height > g.height && (g.height = e.height), g.spans.push(n));
83
+ }
84
+ for (const n of m)
85
+ n.text = n.spans.map((e) => e.textContent ?? "").join("").trim();
86
+ if (m.length === 0) {
87
+ requestAnimationFrame(() => {
88
+ Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
89
+ });
90
+ return;
91
+ }
92
+ const j = s ?? document.createElement("canvas"), b = m.map(
93
+ (n) => H(
94
+ n.text,
95
+ C,
96
+ f,
97
+ n.height || h,
98
+ j
99
+ )
100
+ );
101
+ let L;
102
+ if (typeof r == "number")
103
+ L = r;
104
+ else {
105
+ const n = b.reduce((e, l) => e + l, 0);
106
+ L = b.length > 0 ? n / b.length : 0;
107
+ }
108
+ const v = b.map((n) => {
109
+ const e = (L - n) * p;
110
+ return Math.max(-c, Math.min(c, e));
111
+ });
112
+ o.innerHTML = i;
113
+ const k = "display:inline-block;white-space:nowrap;vertical-align:top;";
114
+ let E = "";
115
+ m.forEach((n, e) => {
116
+ const l = v[e], u = `${k}${d === "word-spacing" ? "word-spacing" : "letter-spacing"}:${l}em;`, D = n.spans.map((T, $) => {
117
+ const S = T.textContent ?? "";
118
+ return $ === 0 ? S.replace(/^[^\S\u00a0]+/, "") : S;
119
+ }).join("");
120
+ E += `<span class="${A.line}" style="${u}">${D}</span>`, e < m.length - 1 && (E += '<br data-gv-break="1">');
121
+ }), o.innerHTML = E, requestAnimationFrame(() => {
122
+ Math.abs(window.scrollY - t) > 2 && window.scrollTo({ top: t, behavior: "instant" });
123
+ });
124
+ }
125
+ function U(o, i) {
126
+ o.innerHTML = i;
127
+ }
128
+ function P(o) {
129
+ const i = R(null), a = R(null), s = R(o);
130
+ s.current = o;
131
+ const t = Y(() => {
132
+ const r = i.current;
133
+ r && (a.current === null && (a.current = z(r)), _(r, a.current, s.current));
134
+ }, []);
135
+ return q(() => {
136
+ t();
137
+ let r = 0, d = 0;
138
+ const c = new ResizeObserver((p) => {
139
+ const f = Math.round(p[0].contentRect.width);
140
+ f !== r && (r = f, cancelAnimationFrame(d), d = requestAnimationFrame(t));
141
+ });
142
+ return c.observe(i.current), () => {
143
+ c.disconnect(), cancelAnimationFrame(d);
144
+ };
145
+ }, [t]), i;
146
+ }
147
+ const I = G(
148
+ function({ children: i, className: a, style: s, as: t = "p", ...r }, d) {
149
+ const c = P(r);
150
+ return /* @__PURE__ */ V(t, { ref: c, className: a, style: s, children: i });
151
+ }
152
+ );
153
+ I.displayName = "GrayValueText";
154
+ export {
155
+ I as GrayValueText,
156
+ _ as applyGrayValue,
157
+ z as getCleanHTML,
158
+ H as measureLineDensity,
159
+ U as removeGrayValue,
160
+ P as useGrayValue
161
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@liiift-studio/steadygray",
3
+ "version": "0.0.1",
4
+ "description": "Paragraph optical density equalization — measure actual glyph area per line and equalize visual density across all lines",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "vite build",
21
+ "test": "vitest",
22
+ "test:run": "vitest run",
23
+ "lint": "tsc --noEmit",
24
+ "prepublishOnly": "npm run test:run && npm run build"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=17",
28
+ "react-dom": ">=17"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "react": {
32
+ "optional": true
33
+ },
34
+ "react-dom": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19.0.0",
40
+ "@vitejs/plugin-react": "^4.0.0",
41
+ "happy-dom": "^12.0.0",
42
+ "next": "16.2.2",
43
+ "react": "^19.0.0",
44
+ "typescript": "^5.0.0",
45
+ "vite": "^6.0.0",
46
+ "vite-plugin-dts": "^4.0.0",
47
+ "vitest": "^3.0.0"
48
+ },
49
+ "keywords": [
50
+ "typography",
51
+ "gray-value",
52
+ "optical-density",
53
+ "typesetting",
54
+ "react",
55
+ "typescript"
56
+ ],
57
+ "author": "Quinn Keaveney <quinn@liiift.studio>",
58
+ "license": "MIT",
59
+ "homepage": "https://steadygray.com",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/Liiift-Studio/SteadyGray.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/Liiift-Studio/SteadyGray/issues"
66
+ },
67
+ "sideEffects": false
68
+ }