Capture & emission fidelity wave: dotLottie support, scroll-state isolation, authored-geometry trust, honest quality scoring

Capture:
- Preserve real asset extensions (.lottie no longer truncated); extract the
  default animation JSON from dotLottie ZIP containers at materialization
  (minimal built-in ZIP reader, no new deps)
- Reset scroll + settle before every viewport snapshot so scroll-linked
  styles can't bake into captured computed values
- Marquee detection now requires content overflow (>1.35x), sustained
  velocity across separated windows, scroll independence (jiggle test), and
  genuine content repetition — kills scroll-settle-lerp false positives
- Sizing probe trusts authored explicit heights (px/vh/calc) over circular
  parent/child reflow verdicts, fixing dropped 100vh heroes

Normalize/generate:
- Canonicalize identity transforms to "none" per viewport; emit explicit
  transform resets across bands when any band has a real transform
- DittoLottie keeps the captured placeholder until DOMLoaded; failed loads
  no longer blank the container
- mx-auto only for true auto-margin centering; literal per-band margins are
  preserved (fixes full-bleed regressions)
- Spacing-scale snap tightened to 0.25px (3.5px stays p-[3.5px]); emit
  whitespace-nowrap for wrap-vulnerable single-line text leaves
- Grid recipes no longer override captured track counts or column spans;
  responsive re-flow plans apply only when geometry agrees

Quality scoring:
- Rewritten 6-dimension rubric (payload, decomposition, duplication,
  semantics, hygiene, runtime) with catastrophe caps and per-dimension
  reporting; thresholds are documented calibration guides

218 tests pass (74 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 23:09:16 -07:00
co-authored by Claude Fable 5
parent 77be868ee3
commit d3ec154bae
19 changed files with 2345 additions and 386 deletions
+69 -5
View File
@@ -1,7 +1,7 @@
import { join } from "node:path";
import { rmSync } from "node:fs";
import { writeText } from "../util/fsx.js";
import type { IR, IRNode, IRChild, IRTextNode } from "../normalize/ir.js";
import type { IR, IRNode, IRChild, IRTextNode, StyleMap } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import { generateCss, RESET_CSS } from "./css.js";
import { generateInteractionCss } from "./interactionCss.js";
@@ -2384,17 +2384,75 @@ ${input}
`;
}
function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
// Number of explicit column tracks in a computed `grid-template-columns` value. The capture
// resolves the property to a used track list (e.g. `284px 284px 284px`), so track count is the
// whitespace-separated token count; `none`/empty means the element is not a track grid.
function computedTrackCount(value: string | undefined): number {
if (!value || value === "none") return 0;
return value.trim().split(/\s+/).filter(Boolean).length;
}
// Track span of a grid item from its resolved `grid-column-start`/`grid-column-end`. A spanning
// item (`span 2`, or an explicit line pair `1 / 3`) means row-length item-counting under-reports
// the real track count, so the column-count heuristic is not trustworthy for this container.
function computedColumnSpan(cs: StyleMap | undefined): number {
if (!cs) return 1;
const end = cs["gridColumnEnd"];
const start = cs["gridColumnStart"];
const span = /^span\s+(\d+)$/.exec(end ?? "") ?? /^span\s+(\d+)$/.exec(start ?? "");
if (span) return Math.max(1, Number(span[1]));
if (start && end && /^-?\d+$/.test(start) && /^-?\d+$/.test(end)) {
const diff = Number(end) - Number(start);
if (Number.isFinite(diff) && diff > 1) return diff;
}
return 1;
}
export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
const recipeParents = new Set<string>();
const repeatedGridParents = new Set<string>();
const fillGridParents = new Set<string>();
// Containers whose computed geometry agreed with the item-count heuristic — a genuinely uniform
// repeated grid the recipe may re-flow. Row-track utilities are only stripped here; on containers
// where the geometry disagreed (spanning items / differing track count) the authored row and
// column tracks are ground truth and must survive.
const uniformGridParents = new Set<string>();
const columnPlans = new Map<string, string[]>();
const nodeByCid = new Map<string, IRNode>();
const index = (n: IRNode): void => { nodeByCid.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) index(c); };
index(ir.root);
// The captured/computed grid geometry is ground truth. A recipe may add semantic structure, but
// its column count is inferred by grouping item bounding boxes into rows — which under-reports the
// real track count whenever an item spans multiple columns. Before letting a recipe's synthesized
// column plan override the authored grid, cross-check it against the computed `grid-template-columns`
// track count and per-item `grid-column` spans at each regime viewport. On disagreement, keep the
// emitted (computed) geometry and drop the plan so spans and track count survive.
const planAgreesWithComputed = (c: RecipeReport["candidates"][number], regimeVps: number[]): boolean => {
const parent = c.itemParentCid ? nodeByCid.get(c.itemParentCid) : undefined;
if (!parent) return false;
const itemCids = (c.repeatedItems ?? []).map((i) => i.cid);
for (const vp of regimeVps) {
const tracks = computedTrackCount(parent.computedByVp[vp]?.["gridTemplateColumns"]);
// Only trust the heuristic where the computed grid actually is a track grid at this viewport.
if (tracks === 0) continue;
const regime = c.responsiveRegimes.find((r) => r.viewport === vp);
const heuristicCols = regime?.columns ?? 0;
if (heuristicCols > 0 && heuristicCols !== tracks) return false;
for (const cid of itemCids) {
if (computedColumnSpan(nodeByCid.get(cid)?.computedByVp[vp]) > 1) return false;
}
}
return true;
};
const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => {
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
const regimes = c.responsiveRegimes
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
.sort((a, b) => a.viewport - b.viewport);
if (regimes.length < 2) return null;
// The computed track geometry disagrees with the item-count heuristic (spanning items or a
// differing track count) — defer to the authored grid rather than synthesize a column plan.
if (!planAgreesWithComputed(c, regimes.map((r) => r.viewport))) return null;
const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : "";
const tokens: string[] = [];
let last: number | undefined;
@@ -2416,6 +2474,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
for (const c of recipes?.candidates ?? []) {
if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) {
recipeParents.add(c.itemParentCid);
// A container is a uniform repeated grid only if its computed track geometry agrees with the
// item-count heuristic at every sampled regime viewport (no spanning items, matching track
// count). Non-grid containers (flex/stack) have no computed tracks to disagree with, so they
// stay eligible; a track grid whose geometry disagrees is excluded and keeps its authored rows.
const sampledVps = c.responsiveRegimes.map((r) => r.viewport);
if (planAgreesWithComputed(c, sampledVps)) uniformGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid);
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid);
const columns = gridColumnTokens(c);
@@ -2429,8 +2493,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
const keep = tokens.filter((token) => {
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
if (repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
if (uniformGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
if (uniformGridParents.has(cid) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token);
if (initialCols) {
const prefix = initialCols[1] ? `${initialCols[1]}:` : "";
@@ -2477,7 +2541,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
// CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral.
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined;
const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined;
const cleanRecipeClass = recipeResponsiveClassCleaner(input.recipeReport, { tailwind: !!tw });
const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw });
const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined;
const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
// Section split (single-page humanized): plan section roots, render each into its own
+82 -3
View File
@@ -2208,6 +2208,8 @@ function declsForViewport(
geometry: GeometryPlan = GEOMETRY_NONE,
dropGridRows = false,
dropViewportMaxWidth = false,
nowrapText = false,
keepIdentityTransform = false,
): Map<string, string> {
const cs = node.computedByVp[vp];
const out = new Map<string, string>();
@@ -2478,6 +2480,13 @@ function declsForViewport(
// list reset is none; emit whatever the source uses (incl. none).
} else if (prop === "minWidth") {
if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue;
} else if (prop === "transform") {
// Emit the identity `transform:none` explicitly at a viewport whose value is `none` WHEN the node
// carries a non-identity transform at some OTHER band — otherwise the non-identity value cascades
// across bands and freezes at a width the source left untransformed. (Identity matrices are already
// canonicalized to the literal "none" upstream, so the string compare is reliable.) When the node
// is identity everywhere, this stays a normal default elision.
if (value === "none" && !keepIdentityTransform) continue;
} else if (def === "__never__") {
// always emit (display/color/fontFamily/fontSize handled below for inherit)
} else if (isDefault(def, value)) {
@@ -2506,6 +2515,15 @@ function declsForViewport(
out.set(kebab(prop), outValue);
}
// Wrap-vulnerable single-line text: the text renders on one line at every captured width and its
// unwrapped width nearly equals the container's available width, so a sub-pixel width shortfall in
// the clone (a column resolving ~0.6px narrower) tips it onto a second line, shifting everything
// below. `white-space:nowrap` holds it to one line — identical to the capture (already single-line
// at every width) but immune to the rounding shortfall. Only the source `whiteSpace` value that
// already collapses runs (`normal`/`nowrap`) is overridden; `pre`/`pre-wrap`/`break-spaces` preserve
// authored whitespace and are left untouched (the caller's detector also excludes them).
if (nowrapText) out.set("white-space", "nowrap");
// Centered max-width container: emit auto side margins so the browser centers
// it at every width. getComputedStyle sometimes reports 0 for resolved auto
// margins at wide viewports, so replaying px would left-align the clone.
@@ -2641,7 +2659,14 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
// marquee). Converting fixed spacing to `auto` deletes it when the parent shrink-wraps the child
// (auto resolves to 0) — exactly how the testimonial inter-card gap vanished. Literal margins
// reproduce that spacing at every width regardless of the parent's resolved width.
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node)) return true;
// The width must ALSO be genuinely constrained: a box the sizing probe reads as a container-FILL
// (`width:100%` reproduces it) has no free space for auto margins to absorb — the emitted width
// is set to 100% (fill/fillcap), so `margin:auto` resolves to 0 and silently deletes real literal
// px side margins, blowing a padded pill/section out to full-bleed. Equal literal margins are the
// geometric TWIN of centering slack (both split the free space symmetrically), so this probe read
// is the only reliable discriminator between them; when the box fills, keep the literal margins.
const fillsAtVp = node.sizingByVp?.[vp]?.wFill === true;
if (ml > 0.5 && Math.abs(ml - mr) < 1 && nb.width < pb.width - 4 && marginsVaryAcrossVps(node) && !fillsAtVp) return true;
if (parentFlexGrid) return false;
// Signal 2: bbox-centered within the parent content box with ~0 reported margins.
if (Math.abs(ml) > 0.5 || Math.abs(mr) > 0.5) return false; // margins already explain position
@@ -2654,6 +2679,45 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
return leftGap > 1 && rightGap > 1 && Math.abs(leftGap - rightGap) < 1.5 && nb.width < contentRight - contentLeft - 2;
}
/** A single-line text leaf whose unwrapped width nearly fills its container's available width at
* EVERY captured viewport where it's painted — so any sub-pixel width shortfall in the clone (a
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
* Conservative by construction — every guard must hold at every painted viewport:
* • text leaf (direct text, no element children), whitespace not already preserved (`pre*`);
* • single line: box height ≈ one line box (line-height + vertical padding/border), so a genuinely
* wrapping paragraph (≥2 line boxes tall) is excluded;
* • wrap-vulnerable: max-content (unwrapped) width ≥ available container width 2 and ≤ it + 1 —
* the text needs essentially all the available width, with no slack for a rounding shortfall;
* • genuinely wrappable: min-content < max-content 2, so a single unbreakable token (which can
* never wrap, making nowrap a redundant no-op) is skipped to keep the emission minimal.
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent ⇒ no emit. */
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode) return false;
if (hasElementChild(node)) return false;
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
let painted = 0;
for (const vp of viewports) {
const cs = node.computedByVp[vp]; const nb = node.bboxByVp[vp];
if (!cs || !nb || !node.visibleByVp[vp] || (cs.display || "") === "none") continue;
if (!(nb.width > 0) || !(nb.height > 0)) continue;
const ws = cs.whiteSpace || "normal";
if (ws !== "normal" && ws !== "nowrap") return false; // pre/pre-wrap/break-spaces preserve authored whitespace
const sz = node.sizingByVp?.[vp];
const wMax = sz?.wMax; const wMin = sz?.wMin;
if (wMax == null || wMin == null) return false;
const lineBox = pf(cs.lineHeight) + pf(cs.paddingTop) + pf(cs.paddingBottom) + pf(cs.borderTopWidth) + pf(cs.borderBottomWidth);
if (!(lineBox > 0) || Math.abs(nb.height - lineBox) > 2.5) return false; // not single-line
const avail = containingWidthAt(node, parentNode, vp);
if (avail == null) return false;
if (!(wMax >= avail - 2 && wMax <= avail + 1)) return false; // not right at the container edge
if (!(wMin < wMax - 2)) return false; // single unbreakable token — can't wrap
painted++;
}
return painted >= 1;
}
function hasPxMaxWidthCap(node: IRNode, viewports: number[]): boolean {
const caps: number[] = [];
for (const vp of viewports) {
@@ -3106,6 +3170,21 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
: inferred.plan;
const centerAlways = inferred.centerAlways;
const centeredAtAnySample = !!layoutParent && sampleVps.some((vp) => centeredAtVp(node, layoutParent, vp));
// A single-line text leaf sitting flush against its container's available width at every painted
// width → `white-space:nowrap` so a sub-pixel column shortfall in the clone can't wrap it. Decided
// once per node (uniform across bands); measured against layoutParent to see through contents wrappers.
const nowrapText = !isContents && nowrapWrapVulnerable(node, layoutParent, sampleVps);
// If this node has a NON-identity transform at any painted band, the identity `none` must be
// emitted at the bands that have it so the transform can't cascade across bands (freezing a
// width the source left untransformed). Identity matrices are canonicalized to "none" upstream.
let hasNonIdentityTransform = false, hasIdentityTransform = false;
for (const vp of sampleVps) {
const t = node.computedByVp[vp]?.transform;
if (t === undefined) continue;
if (t === "none") hasIdentityTransform = true;
else hasNonIdentityTransform = true;
}
const keepIdentityTransform = hasNonIdentityTransform && hasIdentityTransform;
const stableCenter =
centerAlways ||
sourceMarginAutoIntent(node) ||
@@ -3175,7 +3254,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
dropInsets.delete("left");
}
const animOwned = animOwnedProps(node, baseVp); // opacity/transform driven by an infinite animation
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
const nr: NodeRule = { base: baseDecls, bands: [] };
// Per-band overrides (delta vs base), using the parent's value AT THAT viewport.
@@ -3247,7 +3326,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
}
}
const centeredVp = stableCenter || (layoutParent ? centeredAtVp(node, layoutParent, b.vp) : false);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth), tokenResolver);
const vpDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[b.vp], b.vp, assetMap, centeredVp, colorVar, ir.doc.perViewport[b.vp]?.scrollHeight, widthPlan, gridColsByVp?.get(b.vp), gridRowsByVp?.get(b.vp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform), tokenResolver);
const delta = new Map<string, string>();
for (const [k, v] of vpDecls) if (baseDecls.get(k) !== v) delta.set(k, v);
for (const [k] of baseDecls) if (!vpDecls.has(k)) delta.set(k, resetValue(k));
+25 -4
View File
@@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
export default function DittoLottie({ spec }: { spec: LottieSpec }) {
useEffect(() => {
const stopped = (window as any).__dittoMotionStopped === true;
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = [];
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void; addEventListener: (name: string, cb: () => void) => void }> = [];
let cancelled = false;
void (async () => {
const lottie = (await import("lottie-web")).default;
@@ -134,17 +134,38 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
for (const it of spec.items) {
const el = byCid(it.cid);
if (!el) continue;
// clear the captured placeholder frame so it never stacks with the live render
el.innerHTML = "";
try {
// Mount the live animation into an OVERLAY child, keeping the captured placeholder
// frame in the DOM until lottie signals a successful load — a failed load (bad JSON,
// network) then leaves the placeholder intact instead of erasing the container to blank.
const mount = document.createElement("div");
mount.style.position = "absolute";
mount.style.inset = "0";
mount.style.opacity = "0";
const cs = getComputedStyle(el);
if (cs.position === "static") el.style.position = "relative";
el.appendChild(mount);
const anim = lottie.loadAnimation({
container: el,
container: mount,
renderer: it.renderer === "canvas" ? "canvas" : "svg",
loop: it.loop,
autoplay: it.autoplay && !stopped,
...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
});
// Swap only once the animation is genuinely ready: reveal the live render and remove
// every original child (the placeholder) so the two never stack.
const reveal = () => {
mount.style.opacity = "1";
for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child);
mount.style.position = "";
mount.style.inset = "";
};
// DOMLoaded fires only after the JSON parsed and the first frame rendered — the
// right moment to reveal the live render and drop the placeholder. data_failed
// (bad JSON / fetch error) tears the empty mount back out, leaving the placeholder.
anim.addEventListener("DOMLoaded", reveal);
anim.addEventListener("data_failed", () => { try { el.removeChild(mount); } catch {} });
if (stopped) { try { anim.goToAndStop(0, true); } catch {} }
anims.push(anim);
} catch {
+9 -4
View File
@@ -563,7 +563,6 @@ function namedText(base: string): string | null {
// nearest scale step / integer px when comfortably INSIDE that budget (ε well under the gate tol), so
// the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation;
// values not near a step stay arbitrary because they are genuine one-offs.
const SNAP_SPACE_PX = 1.0; // padding/margin/gap/inset/size → nearest 0.25rem scale step within this
const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this
const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
function nearestNamedK(k: number): number {
@@ -571,7 +570,7 @@ function nearestNamedK(k: number): number {
for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c;
return best;
}
function snapBase(base: string): string {
export function snapBase(base: string): string {
const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base);
if (!m) return base;
const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1);
@@ -587,10 +586,16 @@ function snapBase(base: string): string {
if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`;
return base;
}
// spacing / size → nearest named scale step (0.25rem grid)
// spacing / size → nearest named scale step (0.25rem grid). Only snap a value that lands ESSENTIALLY
// ON a step (≤0.25px): the scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so a captured value ≥0.25px
// off the nearest step is a genuine sub-step measurement (3.5px is BETWEEN 2px and 4px — not on the
// scale). Snapping such a value up by +0.5px per side accumulates across the links of a fixed-width
// flex row until the items overflow and wrap to a second line; the exact `p-[3.5px]` cannot. The wider
// ±1px budget the gate tolerates in isolation is unsafe here because the error is systematic, not
// random. Values genuinely on a step (2px→`p-0.5`, 4px→`p-1`) still snap.
if (SCALE_PREFIXES.has(prefix)) {
const k = nearestNamedK(px / 4);
if (Math.abs(k * 4 - px) <= SNAP_SPACE_PX) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
if (Math.abs(k * 4 - px) <= 0.25) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
}
return base;
}