psychic-potato 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # psychic-potato
2
+
3
+ `psychic-potato` exports a single browser function, `squeezeText`, that mutates matching `div` / `span` pairs in place so the rendered span boxes fit inside their corresponding div content boxes.
4
+
5
+ ## Contract
6
+
7
+ - pass `HTMLDivElement[]` and `HTMLSpanElement[]` of the same length
8
+ - each span must be inside its matching div
9
+ - the library measures spans with `getBoundingClientRect()`
10
+ - the fitting target is each div's content box, so padding and borders reduce usable space
11
+ - the same scalar font-size multiplier is applied to both every div and every span
12
+ - fitting defaults to both axes, but you can set `axis: "width"` or `axis: "height"`
13
+ - during the exponential sweep, the divs must stay size-stable on the measured axis or the function throws
14
+
15
+ ## Intended usage
16
+
17
+ This is designed for UI resize flows where the spans are already laid out how you want them. If you want tighter or looser fitting, control that through your own span/div CSS; `squeezeText` fits the rendered DOM boxes it sees.
18
+
19
+ ```ts
20
+ import { squeezeText } from "psychic-potato";
21
+
22
+ const divs = Array.from(document.querySelectorAll("div.fit-target"));
23
+ const spans = Array.from(document.querySelectorAll("div.fit-target > span"));
24
+
25
+ squeezeText(divs, spans);
26
+ squeezeText(divs, spans, { axis: "width" });
27
+ ```
@@ -0,0 +1,5 @@
1
+ import { type FitAxis } from "./measurement.js";
2
+ export type SqueezeTextOptions = {
3
+ axis?: FitAxis;
4
+ };
5
+ export declare function squeezeText(divs: HTMLDivElement[], spans: HTMLSpanElement[], options?: SqueezeTextOptions): void;
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ const SEARCH_EPSILON_START_PX = 1;
2
+ const MAX_SWEEP_STEPS = 32;
3
+ const MAX_BINARY_STEPS = 32;
4
+ const MIN_SCALAR = 1 / 2 ** 32;
5
+ const SCALAR_STALL_EPSILON = 1e-7;
6
+ import { assertAtLeastOneSpanRenders, assertDivsStable, measureDivTarget, measureFit, } from "./measurement.js";
7
+ export function squeezeText(divs, spans, options = {}) {
8
+ validateInputs(divs, spans);
9
+ const axis = options.axis ?? "both";
10
+ const fontSnapshot = snapshotInlineFontSizes(divs, spans);
11
+ const baseFontSizes = captureBaseFontSizes(divs, spans);
12
+ const divTargets = divs.map((div) => measureDivTarget(div, axis));
13
+ const appliedScalar = { value: null };
14
+ try {
15
+ applyScalar(divs, spans, baseFontSizes, appliedScalar, 1);
16
+ assertAtLeastOneSpanRenders(spans);
17
+ const bracket = runExponentialSweep(divs, spans, baseFontSizes, divTargets, appliedScalar, axis);
18
+ runBinarySearch(divs, spans, baseFontSizes, divTargets, bracket, appliedScalar, axis);
19
+ }
20
+ catch (error) {
21
+ restoreInlineFontSizes(divs, spans, fontSnapshot);
22
+ throw error;
23
+ }
24
+ }
25
+ function validateInputs(divs, spans) {
26
+ if (divs.length !== spans.length) {
27
+ throw new Error("squeezeText requires div and span arrays of the same length.");
28
+ }
29
+ if (divs.length === 0) {
30
+ throw new Error("squeezeText requires at least one div/span pair.");
31
+ }
32
+ const hasAnyText = spans.some((span) => (span.textContent ?? "").length > 0);
33
+ if (!hasAnyText) {
34
+ throw new Error("squeezeText requires at least one span with text content.");
35
+ }
36
+ for (let index = 0; index < divs.length; index += 1) {
37
+ const div = divs[index];
38
+ const span = spans[index];
39
+ if (!div.isConnected || !span.isConnected) {
40
+ throw new Error("squeezeText requires connected div/span elements.");
41
+ }
42
+ if (!div.contains(span)) {
43
+ throw new Error(`squeezeText requires span ${index} to be contained inside div ${index}.`);
44
+ }
45
+ }
46
+ }
47
+ function snapshotInlineFontSizes(divs, spans) {
48
+ return {
49
+ divInline: divs.map((div) => div.style.fontSize),
50
+ spanInline: spans.map((span) => span.style.fontSize),
51
+ };
52
+ }
53
+ function restoreInlineFontSizes(divs, spans, snapshot) {
54
+ for (let index = 0; index < divs.length; index += 1) {
55
+ divs[index].style.fontSize = snapshot.divInline[index];
56
+ spans[index].style.fontSize = snapshot.spanInline[index];
57
+ }
58
+ }
59
+ function captureBaseFontSizes(divs, spans) {
60
+ return {
61
+ divs: divs.map((div, index) => readFontSize(div, `div ${index}`)),
62
+ spans: spans.map((span, index) => readFontSize(span, `span ${index}`)),
63
+ };
64
+ }
65
+ function readFontSize(element, label) {
66
+ const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
67
+ if (!Number.isFinite(fontSize) || fontSize <= 0) {
68
+ throw new Error(`squeezeText requires a positive computed font size for ${label}.`);
69
+ }
70
+ return fontSize;
71
+ }
72
+ function applyScalar(divs, spans, baseFontSizes, appliedScalar, scalar) {
73
+ if (appliedScalar.value === scalar) {
74
+ return;
75
+ }
76
+ for (let index = 0; index < divs.length; index += 1) {
77
+ divs[index].style.fontSize = `${baseFontSizes.divs[index] * scalar}px`;
78
+ }
79
+ for (let index = 0; index < spans.length; index += 1) {
80
+ spans[index].style.fontSize = `${baseFontSizes.spans[index] * scalar}px`;
81
+ }
82
+ appliedScalar.value = scalar;
83
+ }
84
+ function runExponentialSweep(divs, spans, baseFontSizes, divTargets, appliedScalar, axis) {
85
+ let scalar = 1;
86
+ let assessment = measureFit(divs, spans, divTargets, axis);
87
+ for (let step = 0; step < MAX_SWEEP_STEPS; step += 1) {
88
+ if (assessment.fits) {
89
+ const nextScalar = scalar * 2;
90
+ applyScalar(divs, spans, baseFontSizes, appliedScalar, nextScalar);
91
+ assertDivsStable(divs, divTargets, axis);
92
+ const nextAssessment = measureFit(divs, spans, divTargets, axis);
93
+ if (!nextAssessment.fits) {
94
+ return {
95
+ insideScalar: scalar,
96
+ outsideScalar: nextScalar,
97
+ initialInsideGap: assessment.minGap,
98
+ };
99
+ }
100
+ scalar = nextScalar;
101
+ assessment = nextAssessment;
102
+ continue;
103
+ }
104
+ const nextScalar = scalar / 2;
105
+ if (nextScalar < MIN_SCALAR) {
106
+ throw new Error("squeezeText could not find a fitting font size during the sweep.");
107
+ }
108
+ applyScalar(divs, spans, baseFontSizes, appliedScalar, nextScalar);
109
+ assertDivsStable(divs, divTargets, axis);
110
+ const nextAssessment = measureFit(divs, spans, divTargets, axis);
111
+ if (nextAssessment.fits) {
112
+ return {
113
+ insideScalar: nextScalar,
114
+ outsideScalar: scalar,
115
+ initialInsideGap: nextAssessment.minGap,
116
+ };
117
+ }
118
+ scalar = nextScalar;
119
+ assessment = nextAssessment;
120
+ }
121
+ throw new Error("squeezeText could not bracket a fit during the sweep.");
122
+ }
123
+ function runBinarySearch(divs, spans, baseFontSizes, divTargets, bracket, appliedScalar, axis) {
124
+ let inside = bracket.insideScalar;
125
+ let outside = bracket.outsideScalar;
126
+ let bestInside = inside;
127
+ let bestGap = bracket.initialInsideGap;
128
+ let epsilon = SEARCH_EPSILON_START_PX;
129
+ for (let step = 0; step < MAX_BINARY_STEPS; step += 1) {
130
+ const midpoint = (inside + outside) / 2;
131
+ if (Math.abs(midpoint - inside) <= SCALAR_STALL_EPSILON ||
132
+ Math.abs(outside - midpoint) <= SCALAR_STALL_EPSILON) {
133
+ break;
134
+ }
135
+ applyScalar(divs, spans, baseFontSizes, appliedScalar, midpoint);
136
+ const assessment = measureFit(divs, spans, divTargets, axis);
137
+ if (assessment.fits) {
138
+ inside = midpoint;
139
+ bestInside = midpoint;
140
+ bestGap = assessment.minGap;
141
+ if (assessment.minGap <= epsilon) {
142
+ return;
143
+ }
144
+ }
145
+ else {
146
+ outside = midpoint;
147
+ }
148
+ epsilon += 1;
149
+ }
150
+ applyScalar(divs, spans, baseFontSizes, appliedScalar, bestInside);
151
+ if (bestGap < 0) {
152
+ throw new Error("squeezeText could not preserve a non-clipping fit.");
153
+ }
154
+ }
@@ -0,0 +1,18 @@
1
+ export declare const DIV_STABILITY_TOLERANCE_PX = 1;
2
+ export type FitAxis = "both" | "width" | "height";
3
+ export type DivTarget = {
4
+ initialWidth: number;
5
+ initialHeight: number;
6
+ insetLeft: number;
7
+ insetRight: number;
8
+ insetTop: number;
9
+ insetBottom: number;
10
+ };
11
+ export type FitAssessment = {
12
+ fits: boolean;
13
+ minGap: number;
14
+ };
15
+ export declare function measureDivTarget(div: HTMLDivElement, axis: FitAxis): DivTarget;
16
+ export declare function assertAtLeastOneSpanRenders(spans: HTMLSpanElement[]): void;
17
+ export declare function assertDivsStable(divs: HTMLDivElement[], divTargets: DivTarget[], axis: FitAxis): void;
18
+ export declare function measureFit(divs: HTMLDivElement[], spans: HTMLSpanElement[], divTargets: DivTarget[], axis: FitAxis): FitAssessment;
@@ -0,0 +1,79 @@
1
+ export const DIV_STABILITY_TOLERANCE_PX = 1;
2
+ export function measureDivTarget(div, axis) {
3
+ const rect = div.getBoundingClientRect();
4
+ const style = getComputedStyle(div);
5
+ const insetLeft = parsePixelValue(style.borderLeftWidth) + parsePixelValue(style.paddingLeft);
6
+ const insetRight = parsePixelValue(style.borderRightWidth) + parsePixelValue(style.paddingRight);
7
+ const insetTop = parsePixelValue(style.borderTopWidth) + parsePixelValue(style.paddingTop);
8
+ const insetBottom = parsePixelValue(style.borderBottomWidth) + parsePixelValue(style.paddingBottom);
9
+ const contentWidth = rect.width - insetLeft - insetRight;
10
+ const contentHeight = rect.height - insetTop - insetBottom;
11
+ if ((axis === "both" || axis === "width") && contentWidth <= 0) {
12
+ throw new Error("squeezeText requires each div to have a positive content width.");
13
+ }
14
+ if ((axis === "both" || axis === "height") && contentHeight <= 0) {
15
+ throw new Error("squeezeText requires each div to have a positive content box.");
16
+ }
17
+ return {
18
+ initialWidth: rect.width,
19
+ initialHeight: rect.height,
20
+ insetLeft,
21
+ insetRight,
22
+ insetTop,
23
+ insetBottom,
24
+ };
25
+ }
26
+ export function assertAtLeastOneSpanRenders(spans) {
27
+ const anyRenderableSpan = spans.some((span) => {
28
+ const rect = span.getBoundingClientRect();
29
+ return rect.width > 0 || rect.height > 0;
30
+ });
31
+ if (!anyRenderableSpan) {
32
+ throw new Error("squeezeText requires at least one span to render measurable text.");
33
+ }
34
+ }
35
+ export function assertDivsStable(divs, divTargets, axis) {
36
+ for (let index = 0; index < divs.length; index += 1) {
37
+ const rect = divs[index].getBoundingClientRect();
38
+ const target = divTargets[index];
39
+ const widthDelta = Math.abs(rect.width - target.initialWidth);
40
+ const heightDelta = Math.abs(rect.height - target.initialHeight);
41
+ if ((axis === "both" || axis === "width") && widthDelta > DIV_STABILITY_TOLERANCE_PX) {
42
+ throw new Error(`squeezeText requires div ${index} to stay size-stable while fitting text.`);
43
+ }
44
+ if ((axis === "both" || axis === "height") && heightDelta > DIV_STABILITY_TOLERANCE_PX) {
45
+ throw new Error(`squeezeText requires div ${index} to stay size-stable while fitting text.`);
46
+ }
47
+ }
48
+ }
49
+ export function measureFit(divs, spans, divTargets, axis) {
50
+ let minGap = Number.POSITIVE_INFINITY;
51
+ for (let index = 0; index < divs.length; index += 1) {
52
+ const divRect = divs[index].getBoundingClientRect();
53
+ const spanRect = spans[index].getBoundingClientRect();
54
+ const target = divTargets[index];
55
+ const contentLeft = divRect.left + target.insetLeft;
56
+ const contentRight = divRect.right - target.insetRight;
57
+ const contentTop = divRect.top + target.insetTop;
58
+ const contentBottom = divRect.bottom - target.insetBottom;
59
+ const pairMinGap = measurePairGap(spanRect, contentLeft, contentRight, contentTop, contentBottom, axis);
60
+ minGap = Math.min(minGap, pairMinGap);
61
+ }
62
+ return {
63
+ fits: minGap >= 0,
64
+ minGap,
65
+ };
66
+ }
67
+ function measurePairGap(spanRect, contentLeft, contentRight, contentTop, contentBottom, axis) {
68
+ if (axis === "width") {
69
+ return Math.min(spanRect.left - contentLeft, contentRight - spanRect.right);
70
+ }
71
+ if (axis === "height") {
72
+ return Math.min(spanRect.top - contentTop, contentBottom - spanRect.bottom);
73
+ }
74
+ return Math.min(spanRect.left - contentLeft, contentRight - spanRect.right, spanRect.top - contentTop, contentBottom - spanRect.bottom);
75
+ }
76
+ function parsePixelValue(value) {
77
+ const parsed = Number.parseFloat(value);
78
+ return Number.isFinite(parsed) ? parsed : 0;
79
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "psychic-potato",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/hcubasd/psychic-potato"
10
+ },
11
+ "engines": {
12
+ "node": ">=22.12.0 <25.0.0"
13
+ },
14
+ "scripts": {
15
+ "test": "vitest --environment jsdom",
16
+ "build": "tsc --rootDir src --outDir dist"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "devDependencies": {
22
+ "@tsconfig/node22": "^22.0.5",
23
+ "jsdom": "^26.1.0",
24
+ "typescript": "^6.0.3",
25
+ "vitest": "^4.1.7"
26
+ }
27
+ }