diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 6a4753f..6e74e9e 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -182,6 +182,41 @@ export function looksLikeVideoFile(bytes: Buffer): boolean { return false; } +/** + * A `` candidate for the HTML media resource-selection algorithm. `media`/`type` are the + * raw attribute strings (null/undefined when absent, matching a missing attribute). + */ +export interface VideoSourceCandidate { + media?: string | null; + type?: string | null; +} + +/** + * Pure re-implementation of the source-selection step of the HTML media resource-selection + * algorithm: return the index of the FIRST `` (document order) that is eligible NOW, or -1 + * when none is. A source is eligible when its `media` query matches (a missing/empty media matches + * unconditionally) AND the UA can play its `type` (a missing/empty type is not a disqualifier — + * `canPlayType` is only consulted when a type is present). + * + * The predicates are injected so this is testable in Node (the in-page caller passes the real + * `matchMedia`/`canPlayType`). Kept deterministic: no state, first-match wins in document order. + */ +export function selectVideoSourceIndex( + sources: VideoSourceCandidate[], + mediaMatches: (media: string) => boolean, + canPlay: (type: string) => boolean, +): number { + for (let i = 0; i < sources.length; i++) { + const s = sources[i]!; + const media = (s.media ?? "").trim(); + if (media && !mediaMatches(media)) continue; + const type = (s.type ?? "").trim(); + if (type && !canPlay(type)) continue; + return i; + } + return -1; +} + async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise { // Scroll through the page to trigger lazy-loaded images/backgrounds, then return // to the top so document-coordinate bboxes are measured from a settled layout. @@ -804,12 +839,57 @@ async function captureScreenshot( * diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot * removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor * has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang. + * + * FIRST it re-runs `` resource selection for THIS viewport. The capture pipeline loads + * the page once at the canonical width and reaches every other viewport by resizing — but the HTML + * media resource-selection algorithm evaluates `` only at load, so an aspect/orientation + * -gated hero video stays frozen on whatever variant matched at load width across all resized shots. + * The validator fresh-loads per viewport and correctly re-selects, so the two channels disagree on + * which variant is on screen (a large phantom diff on full-bleed portrait/landscape hero videos). + * Re-selecting here keeps the channels symmetric; it is a no-op on the fresh-loading validator side + * and — the common case — a no-op whenever the current selection is still the right one, so the fast + * path never touches `video.load()`. */ export async function normalizeVideoTime(page: import("playwright").Page): Promise { try { await page.evaluate(async () => { const vids = Array.from(document.querySelectorAll("video")); const raf = () => new Promise((r) => requestAnimationFrame(() => r())); + + // Re-select the the resource-selection algorithm would pick at the CURRENT viewport, + // mirroring selectVideoSourceIndex (Node-side, unit-tested): first source in document order + // whose media matches (missing media matches unconditionally) and whose type is playable + // (missing type is never disqualifying). Only reload when the winner differs from what is + // currently loaded — the fast path leaves untouched videos alone. + const reloadWaits: Promise[] = []; + for (const v of vids) { + try { + const sources = Array.from(v.querySelectorAll("source")); + if (sources.length === 0) continue; // src attribute (no children): nothing to re-select + let winner: HTMLSourceElement | null = null; + for (const s of sources) { + const media = (s.getAttribute("media") ?? "").trim(); + if (media && !window.matchMedia(media).matches) continue; + const type = (s.getAttribute("type") ?? "").trim(); + if (type && !v.canPlayType(type)) continue; + winner = s; + break; + } + if (!winner || !winner.src) continue; + // Compare resolved URLs; currentSrc is absolute, winner.src is already resolved. + if (v.currentSrc === winner.src) continue; // no change — fast path, do not reload + v.load(); + reloadWaits.push(new Promise((resolve) => { + let settled = false; + const done = () => { if (settled) return; settled = true; v.removeEventListener("loadeddata", done); resolve(); }; + if (v.readyState >= 2 /* HAVE_CURRENT_DATA */) { done(); return; } + v.addEventListener("loadeddata", done, { once: true }); + setTimeout(done, 4000); // bound: a stalled reload resolves anyway so the shot never hangs + })); + } catch { /* a malformed /media query must not block the others */ } + } + if (reloadWaits.length) await Promise.all(reloadWaits); + const waits: Promise[] = []; for (const v of vids) { try { v.pause(); } catch { /* ignore */ } diff --git a/compiler/src/capture/interactions.ts b/compiler/src/capture/interactions.ts index 3f216c5..48ffeda 100644 --- a/compiler/src/capture/interactions.ts +++ b/compiler/src/capture/interactions.ts @@ -24,6 +24,13 @@ const PSEUDO_PROPS = [ export type StyleDelta = Record; +/** Changed-properties delta: every key in `b` whose value differs from `a`. Pure (unit-tested). */ +export function diffStyle(a: StyleDelta, b: StyleDelta): StyleDelta { + const d: StyleDelta = {}; + for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!; + return d; +} + // Properties that distinguish a panel's shown/hidden state and a trigger's // active/inactive state. Captured per discrete state so the generated controller // can toggle between them faithfully (M2: tabs + accordion). @@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n return o; }, { capId, props: PSEUDO_PROPS as unknown as string[] }); - const diff = (a: StyleDelta, b: StyleDelta): StyleDelta => { - const d: StyleDelta = {}; - for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!; - return d; + const diff = diffStyle; + + // Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover) + // moves the REAL cursor to the element's box centre, so the browser applies `:hover` to + // whatever sits under that point — on a page with a transparent full-viewport overlay (a + // fixed page-wrapper / scroll layer, common on modern builder stacks) the point lands on the + // overlay and the target never enters `:hover`, silently capturing ZERO authored hover states + // even though page.hover throws nothing. Forcing the pseudo-class on the node itself is + // geometry-independent (occlusion-immune), applies pure-CSS `:hover` rules directly, and — for + // `.card:hover .overlay` — also styles descendants of the forced node, so reveals still show. + // Resolves each cap → CDP nodeId; a live map is rebuilt per element (cheap) so it survives + // any DOM churn. Falls back to no forcing if a CDP session can't be opened (then hover/focus + // capture is simply empty rather than wrong). + const client = await page.context().newCDPSession(page).catch(() => null); + if (client) { + try { await client.send("DOM.enable"); await client.send("CSS.enable"); await client.send("DOM.getDocument", {}); } + catch { /* CDP unavailable — force() below no-ops */ } + } + const nodeIdFor = async (capId: string): Promise => { + if (!client) return null; + let objectId: string | undefined; + try { + const ev = await client.send("Runtime.evaluate", { expression: `document.querySelector('[data-cid-cap="${capId}"]')` }) as { result?: { objectId?: string } }; + objectId = ev.result?.objectId; if (!objectId) return null; + const r = await client.send("DOM.requestNode", { objectId }) as { nodeId?: number }; + return r.nodeId ?? null; + } catch { return null; } + finally { if (objectId) await client.send("Runtime.releaseObject", { objectId }).catch(() => {}); } }; + // Force (or clear, with []) a pseudo-class on a node. Deterministic and reversible. + const force = async (nodeId: number | null, classes: string[]): Promise => { + if (!client || nodeId == null) return false; + try { await client.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: classes }); return true; } + catch { return false; } + }; + const settlePseudo = () => page.waitForTimeout(180); // let a `:hover`/`:focus` transition land // Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only // clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky @@ -413,21 +451,21 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n const hoverDesc: Record> = {}; for (const capId of candidates) { - const sel = `[data-cid-cap="${capId}"]`; const base = await read(capId); if (!base) continue; + const nodeId = await nodeIdFor(capId); const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates const hiddenCaps = Object.keys(hiddenBase); - // :hover — settle before reading: framer-style hover is driven by JS (Framer Motion) - // with a short transition, so an immediate read catches the pre-transition frame and - // sees no delta (the nav-link hover was missed this way). Wait for the transition to land. - try { - await page.hover(sel, { timeout: 1200, force: true }); - await page.waitForTimeout(180); + // :hover — force the pseudo-class on the node (geometry-independent; see `force` above), + // then settle before reading: an authored `:hover`/JS transition animates from the resting + // frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it. + if (await force(nodeId, ["hover"])) { + await settlePseudo(); const h = await read(capId); if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; } // Descendant reveals (while still hovered): a hidden child shown on hover — the card's - // OWN style is unchanged, so this is the only signal (framer's "Read story" overlay). + // OWN style is unchanged, so this is the only signal (a "Read story" overlay). Forcing + // `:hover` on the card also matches `.card:hover .overlay` rules on its descendants. if (hiddenCaps.length) { await page.waitForTimeout(380); // let the reveal transition fully finish before reading const after = await readDescProps(hiddenCaps); @@ -444,16 +482,17 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n } if (Object.keys(revealed).length) hoverDesc[capId] = revealed; } - } catch { /* not hoverable (covered/offscreen) — skip */ } - await page.mouse.move(1, 1).catch(() => {}); - // :focus (only for focusable-ish; cheap to attempt, guarded) - try { - await page.focus(sel, { timeout: 800 }); + await force(nodeId, []); // clear :hover before probing :focus + } + // :focus — force the pseudo-class the same way (also occlusion-independent). + if (await force(nodeId, ["focus"])) { + await settlePseudo(); const f = await read(capId); if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; } - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - } catch { /* not focusable — skip */ } + await force(nodeId, []); // restore the resting state + } } + await client?.detach().catch(() => {}); log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length }); diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts index 54edb3f..818efaa 100644 --- a/compiler/src/capture/walker.ts +++ b/compiler/src/capture/walker.ts @@ -55,6 +55,10 @@ export type RawNode = { // clone renders the browser's default gray, losing the authored placeholder color/type. placeholder?: RawStyle; rawHTML?: string; // set for inline + // Computed paint of an inline root (fill/stroke/color). A raw `fill="none"` attribute may + // still resolve to a real paint via site CSS (`fill: currentColor`); these resolved values let + // codegen recover a paint the extraction stripped. Set only for svg roots. + svgPaint?: { fill: string; stroke: string; color: string }; children: RawChild[]; }; @@ -138,6 +142,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { "letterSpacing", "wordSpacing", "textAlign", "textTransform", "textDecorationLine", "textDecorationColor", "textDecorationStyle", "whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent", + // Modern line-wrapping: `text-wrap: balance/pretty` rebalances heading line breaks + // (getComputedStyle reports the shorthand — "balance"/"pretty"/"wrap"). Without it a + // balanced heading wraps differently in the clone (an even two-line title collapses + // to a lopsided break). Default "wrap" is elided downstream. + "textWrap", "textShadow", "fontVariantCaps", "fontFeatureSettings", // Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`): // the mechanism that keeps cards equal height regardless of text length. Without it the engine @@ -464,6 +473,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { // Inline SVG → raw markup, no recursion. if (tag === "svg") { node.rawHTML = el.outerHTML; + // Capture the svg root's COMPUTED paint (fill/stroke/color) separately from the general + // computed-prop list. A raw `fill="none"` presentation attribute paints nothing on its own; a + // wordmark/icon is visible on the source only because site CSS (a `fill: currentColor` class, + // an inherited `color`) overrides it. Extraction strips that CSS, so the raw attribute alone is + // misleading — codegen consults these resolved values to decide whether the root truly paints. + try { + node.svgPaint = { fill: cs.fill, stroke: cs.stroke, color: cs.color }; + } catch { /* getComputedStyle already read above; guard against exotic UAs */ } return node; } diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index f5376ce..5b5f021 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -401,18 +401,34 @@ export function propsList(node: IRNode, assetMap: Map, sourceUrl // surface verbatim in the markup and (post-extraction) as a bogus data field. const inner = svgInnerForNode(node, ctx); const svgAttrs = extractSvgAttrs(node.rawHTML); - let hasFillAttr = false; + let rawFill: string | undefined; for (const [k, v] of svgAttrs) { - if (k.toLowerCase() === "fill") hasFillAttr = true; + if (k.toLowerCase() === "fill") { rawFill = v; continue; } // the root fill is resolved below, not copied verbatim if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue; const reactName = SVG_ATTR_RENAME[k] ?? k; const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName); if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]); } - // Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is - // stripped during extraction. If the root didn't declare a fill, inherit the surrounding text - // color so monochrome wordmarks/icons don't fall back to the browser's black default. - if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]); + // Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks + // unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually + // paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real + // paint — as currentColor when the computed fill tracks the element's color (emit that color + // too), else the literal value — and leaves a genuinely unfilled svg as fill="none". When the + // root declared no fill at all we fall back to currentColor so monochrome icons don't drop to + // the browser's black default. + if (!props.some(([pk]) => pk === "fill")) { + const resolved = resolveSvgRootFill(rawFill, node.svgPaint); + if (resolved.mode === "keep") { + if (rawFill !== undefined) props.push(["fill", JSON.stringify(rawFill)]); + } else if (resolved.mode === "emit") { + props.push(["fill", JSON.stringify(resolved.value!)]); + if (resolved.emitColor && !props.some(([pk]) => pk === "color")) { + props.push(["color", JSON.stringify(resolved.emitColor)]); + } + } else { + props.push(["fill", JSON.stringify("currentColor")]); + } + } props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]); } @@ -460,6 +476,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> { return out; } +/** A resolved paint value is "real" (paints something) when it is neither absent nor an explicit + * non-paint (`none`) nor fully transparent. `currentColor` counts as real (it paints the inherited + * color). Case/whitespace-insensitive. */ +export function isRealPaint(value: string | null | undefined): boolean { + const v = (value ?? "").trim().toLowerCase(); + if (!v || v === "none" || v === "transparent") return false; + if (v === "rgba(0, 0, 0, 0)" || v === "rgba(0,0,0,0)") return false; + return true; +} + +/** + * Decide what `fill` an inline ROOT should emit, reconciling the raw `fill` attribute against + * the svg's COMPUTED paint (captured from the live source). Pure and deterministic. + * + * - Raw `fill` present and itself a real paint → keep it (`keep`): the author meant that fill. + * - Raw `fill="none"` but the computed fill IS a real paint → the root only looked unfilled because + * `fill="none"` is a presentation attribute the site's CSS overrode. Recover the real paint: + * emit `currentColor` when the computed fill equals the computed `color` (a `fill: currentColor` + * class — the common wordmark case; caller must also emit `color`), else the literal computed fill. + * - Raw `fill="none"` and computed fill also not a real paint → genuinely unfilled: keep `none`. + * - Raw `fill` absent → `fallback`: caller applies its existing currentColor default. + */ +export function resolveSvgRootFill( + rawFill: string | null | undefined, + computed?: { fill?: string; color?: string } | null, +): { mode: "keep" | "fallback" | "emit"; value?: string; emitColor?: string } { + const raw = (rawFill ?? "").trim(); + if (raw && raw.toLowerCase() !== "none") return { mode: "keep" }; + if (raw.toLowerCase() === "none") { + const cf = computed?.fill; + if (isRealPaint(cf)) { + const color = (computed?.color ?? "").trim(); + // fill:currentColor resolves to the element's color; recover it as currentColor so the clone + // tracks whatever color the surrounding CSS supplies, and signal the caller to emit that color. + if (color && cf!.trim().toLowerCase() === color.toLowerCase() && isRealPaint(color)) { + return { mode: "emit", value: "currentColor", emitColor: color }; + } + return { mode: "emit", value: cf!.trim() }; + } + return { mode: "keep" }; // genuinely unfilled — leave fill="none" + } + return { mode: "fallback" }; +} + // SVG presentation attributes that React requires in camelCase (kebab in JSX is silently // dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not // here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index a23569e..c0dc660 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -74,7 +74,7 @@ function isViewportHeight(value: string, width: number): boolean { const INHERITED = new Set([ "color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing", "textAlign", "textTransform", "whiteSpace", - "wordBreak", "overflowWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings", + "wordBreak", "overflowWrap", "textWrap", "textIndent", "fontVariantCaps", "fontFeatureSettings", "listStyleType", "listStylePosition", "writingMode", "direction", "cursor", "textShadow", "visibility", "textDecorationColor", "webkitTextStroke", "webkitTextFillColor", @@ -139,6 +139,8 @@ const GENERIC: Array<{ prop: string; def: string | string[] }> = [ { prop: "textDecorationStyle", def: "solid" }, { prop: "whiteSpace", def: "__never__" }, { prop: "wordBreak", def: "__never__" }, { prop: "overflowWrap", def: "__never__" }, { prop: "textIndent", def: "__never__" }, + // text-wrap: elide the initial `wrap`; emit `balance`/`pretty`/`nowrap`/`stable`. + { prop: "textWrap", def: "wrap" }, { prop: "textShadow", def: "__never__" }, { prop: "fontVariantCaps", def: "__never__" }, { prop: "fontFeatureSettings", def: "__never__" }, { prop: "listStyleType", def: "__never_list__" }, { prop: "listStylePosition", def: "__never__" }, { prop: "writingMode", def: "__never__" }, diff --git a/compiler/src/generate/sectionSplit.ts b/compiler/src/generate/sectionSplit.ts index 5835654..7ae5e92 100644 --- a/compiler/src/generate/sectionSplit.ts +++ b/compiler/src/generate/sectionSplit.ts @@ -100,6 +100,83 @@ function identName(name: string): string { return /^[A-Za-z_$]/.test(name) ? name : "S" + name; } +// Generic structural words that carry no section identity — dropped from source-derived names. +const GENERIC_NAME_WORDS = new Set([ + "section", "sections", "wrapper", "container", "block", "blocks", "template", "templates", + "group", "inner", "outer", "content", "contents", "row", "col", "column", "grid", "layout", + "shopify", "js", "tvg", "elementor", "wp", "widget", "module", "region", "main", "page", + "component", "components", "el", "root", "body", "area", "box", "item", "items", "wrap", +]); + +/** A trailing token looks like a build hash (mixed-case or long alnum entropy, e.g. + * `JtTWTt`, `RbEALJ`, `x19f2a`, `dDMm2q`) rather than a real word — strip it. A token is + * hashy when it is ≥4 chars and either mixes upper+lower case or is a long digit-bearing run. */ +function looksHashy(tok: string): boolean { + if (tok.length < 4) return false; + const hasUpper = /[A-Z]/.test(tok), hasLower = /[a-z]/.test(tok), hasDigit = /\d/.test(tok); + if (hasUpper && hasLower) return true; // camel/entropy hash: JtTWTt, dDMm2q + if (hasDigit && tok.length >= 6) return true; // long id run: 19797275672650 + if (!/[aeiou]/i.test(tok) && tok.length >= 5) return true; // vowelless run: bcdfgh + return false; +} + +/** Turn a source id/class token stream into ≤3 semantic PascalCase words, or "" when the + * evidence is all-generic or hashy. Strips CMS prefixes (shopify-section-…__), leading + * numeric template ids, generic structural words, and trailing hash suffixes. Exported for + * tests (the hashy-suffix stripping + generic-word filtering is the load-bearing part). */ +export function nameFromSourceToken(raw: string): string { + // Peel known CMS section-id prefixes, keeping the semantic slug after `__` / the hook name. + // Case is preserved through prefix-stripping + tokenizing so `looksHashy` can see the + // mixed-case entropy of build hashes (RbEALJ, JtTWTt) BEFORE we normalize to lowercase. + const s = raw.trim() + .replace(/^shopify-section-(?:template|sections)--\d+__/i, "") + .replace(/^shopify-(?:section|block)-/i, "") + .replace(/^(?:js|tvg|elementor|wp|et|elementor-element)[-_]/i, ""); + const tokens = s.split(/[\s\-_]+/).filter(Boolean); + const kept: string[] = []; + for (const t of tokens) { + if (kept.length >= 3) break; + if (/^\d+$/.test(t)) continue; // pure numeric template ids + if (GENERIC_NAME_WORDS.has(t.toLowerCase())) continue; // structural noise + if (looksHashy(t)) continue; // trailing entropy suffix (mixed-case aware) + if (t.length < 2) continue; + const lc = t.toLowerCase(); + kept.push(lc[0]!.toUpperCase() + lc.slice(1)); + } + return kept.join(""); +} + +// A source `id` (or `data-section-type`) is a DELIBERATE, developer-authored block name only +// when it carries a recognized CMS section prefix — Shopify's `shopify-section-…__split_callout`, +// or a generic `section-…`/`…-section`. Arbitrary utility classes (`g_section_space`, +// `duraldar-cta_section`) are styling noise, so we do NOT mine class names — a truncated heading +// slug reads better than a made-up name. `js-*` behaviour hooks ARE intentional and allowed. +const CMS_SECTION_ID = /shopify-section|^section[-_]|[-_]section(?:[-_]|$)|data-section/i; + +/** Best semantic name derivable from a section subtree's source *ids* + `js-*` hooks — the + * only source signals reliable enough to beat a heading slug. Scans the root + shallow + * descendants. Returns "" when no trustworthy semantic evidence exists. */ +function sourceNameForSection(sec: IRNode): string { + const candidates: string[] = []; + const collect = (n: IRNode, depth: number): void => { + const id = n.attrs.id; + if (id && CMS_SECTION_ID.test(id)) candidates.push(id); + const sectionType = n.attrs["data-section-type"] ?? n.attrs["data-section"]; + if (sectionType) candidates.push(sectionType); + if (n.srcClass) { + // Only explicit `js-…` behaviour hooks — a deliberate semantic handle, not a utility class. + for (const cls of n.srcClass.split(/\s+/)) if (/^js[-_][a-z]/i.test(cls)) candidates.push(cls); + } + if (depth > 0) for (const c of elementChildren(n)) collect(c, depth - 1); + }; + collect(sec, 2); + for (const c of candidates) { + const name = nameFromSourceToken(c); + if (name && name.length >= 3) return name; + } + return ""; +} + function looksLikeNav(n: IRNode, cw: number): boolean { if (n.tag === "nav" || n.tag === "header") return true; if (subtreeHasTag(n, "nav")) return true; @@ -279,8 +356,13 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan { } else if (recipeName) { name = recipeName; } else { + // Prefer a clean semantic name from the source markup (Shopify/CMS section ids + + // `js-*` hooks, hash suffixes stripped) over a truncated heading slug — the source + // slug (`split_callout` → SplitCalloutSection) reads more like a hand-authored name. + const sourceName = sourceNameForSection(sec); const slug = slugWords(titleText(sec)); - name = slug ? `${slug}Section` + name = sourceName ? `${sourceName}Section` + : slug ? `${slug}Section` : subtreeHasTag(sec, "form", 6) ? "ContactSection" : subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection" : `Section${i + 1}`; diff --git a/compiler/src/generate/tailwind.ts b/compiler/src/generate/tailwind.ts index c3d09bf..6849ca8 100644 --- a/compiler/src/generate/tailwind.ts +++ b/compiler/src/generate/tailwind.ts @@ -18,6 +18,7 @@ import type { IR, IRNode } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js"; import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js"; +import { colorClusterKey } from "../infer/semanticTokens.js"; import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"; // ---- arbitrary-value escaping ---- @@ -72,6 +73,7 @@ const KW: Record> = { "font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" }, "text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" }, "white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" }, + "text-wrap": { wrap: "text-wrap", nowrap: "text-nowrap", balance: "text-balance", pretty: "text-pretty" }, "overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" }, "overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" }, "object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" }, @@ -1039,11 +1041,12 @@ export type TailwindOutput = { export type ColorInterner = { defs: Map; // minted token name → literal value byValue: Map; // literal value → minted token name + byKey: Map; // rounded-sRGB cluster key → minted token name (visual dedup) tokens: Set; // ALL referenced color token names (palette + minted) seq: { n: number }; // monotonic counter for clr-N names }; export function createColorInterner(): ColorInterner { - return { defs: new Map(), byValue: new Map(), tokens: new Set(), seq: { n: 0 } }; + return { defs: new Map(), byValue: new Map(), byKey: new Map(), tokens: new Set(), seq: { n: 0 } }; } /** `:root { --clr-N: ; … }` for the interner's minted tokens (empty if none). */ export function colorDefsCssOf(it: ColorInterner): string { @@ -1126,12 +1129,31 @@ export function buildTailwind(ir: IR, assetMap: Map, colorVar?: indexNode(ir.root); // Color interner: every distinct color value → a stable theme token referenced as - // var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other - // color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup. - // The token holds the literal value, minted in deterministic first-encounter order. + // var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2 + // sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses + // that SEMANTIC name (--primary, --surface, --color-001…), so oklab/lch colours reached + // only through decoration/gradient/shadow props no longer each mint a fresh opaque + // --clr-N. Only colours with NO palette role fall through to a numbered --clr-N token, + // minted in deterministic first-encounter order (the literal is kept, byte-exact). const internColor = (literal: string): string => { + const semantic = colorVar?.(literal); + if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; } let name = interner.byValue.get(literal); - if (!name) { name = `clr-${interner.seq.n++}`; interner.byValue.set(literal, name); interner.defs.set(name, literal); } + if (!name) { + // Dedup visually-identical literals (many oklab()/lch() forms round to the same sRGB): + // reuse the token minted for that colour rather than a fresh --clr-N. Fidelity-neutral — + // the grader compares in sRGB and the shared token holds the FIRST literal (within ±0 of + // its own colour). Values that don't parse fall back to exact-literal keying. + const key = colorClusterKey(literal); + const existing = key ? interner.byKey.get(key) : undefined; + if (existing) { name = existing; interner.byValue.set(literal, name); } + else { + name = `clr-${interner.seq.n++}`; + interner.byValue.set(literal, name); + interner.defs.set(name, literal); + if (key) interner.byKey.set(key, name); + } + } colorTokens.add(name); return `var(--${name})`; }; diff --git a/compiler/src/infer/semanticTokens.ts b/compiler/src/infer/semanticTokens.ts index 10bc0bb..a68eaa0 100644 --- a/compiler/src/infer/semanticTokens.ts +++ b/compiler/src/infer/semanticTokens.ts @@ -27,12 +27,117 @@ export type ColorPalette = { const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]); type RGBA = [number, number, number, number]; + +/** Parse the numeric args of a `fn(a b c / d)` / `fn(a,b,c,d)` color into [a,b,c,alpha]. + * Percentages resolve against `pctBase` (255 for rgb channels, 1 for lab/lch/oklab/oklch + * L and the c/ab axes where the caller passes their own scale). `none` → 0. */ +function parseColorArgs(inner: string): { nums: number[]; alpha: number } | null { + const parts = inner.split("/"); + const main = parts[0]!.trim().split(/[\s,]+/).filter(Boolean); + const nums = main.map((s) => (s === "none" ? 0 : parseFloat(s))); + if (nums.length < 3 || nums.slice(0, 3).some(Number.isNaN)) return null; + let alpha = 1; + const alphaTok = parts.length > 1 ? parts[1]!.trim() : main[3]; + if (alphaTok !== undefined && alphaTok !== "none") { + const a = alphaTok.endsWith("%") ? parseFloat(alphaTok) / 100 : parseFloat(alphaTok); + if (!Number.isNaN(a)) alpha = a; + } + return { nums, alpha }; +} + +function pctOr(tok: string, base: number): number { + return tok.endsWith("%") ? (parseFloat(tok) / 100) * base : parseFloat(tok); +} + +/** Linear-light sRGB channel → gamma-encoded 0–255. */ +function lin2srgb(c: number): number { + const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.round(Math.min(1, Math.max(0, v)) * 255); +} + +/** OKLab → sRGB (CSS Color 4 reference matrices). Returns gamma-encoded 0–255. */ +function oklabToRgb(L: number, a: number, b: number): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3; + const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)]; +} + +/** CIE Lab (D50) → sRGB, gamma-encoded 0–255. */ +function labToRgb(L: number, a: number, b: number): [number, number, number] { + const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200; + const d = 6 / 29; + const inv = (t: number): number => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29)); + // D50 white point. + const X = 0.9642956 * inv(fx), Y = 1.0 * inv(fy), Z = 0.8251046 * inv(fz); + // XYZ(D50) → linear sRGB (Bradford-adapted D50→D65 folded in). + const r = +3.1341359 * X - 1.6172206 * Y - 0.4906860 * Z; + const g = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z; + const bl = +0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z; + return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)]; +} + +/** Parse any CSS color value (`rgb`/`rgba`/`hsl`/`hsla`/`oklab`/`oklch`/`lab`/`lch`/hex/ + * named-ish) into rounded sRGB `[r,g,b,alpha]`, else null. The literal value is kept + * verbatim in emitted CSS; this RGB is used ONLY for clustering (grader works in sRGB + * ±2), so oklab/lch colours cluster + earn semantic roles instead of falling to --clr-N. */ +function parseColor(v: string): RGBA | null { + const s = v.trim().toLowerCase(); + if (s === "white") return [255, 255, 255, 1]; + if (s === "black") return [0, 0, 0, 1]; + const hex = /^#([0-9a-f]{3,8})$/i.exec(s); + if (hex) { + let h = hex[1]!; + if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join(""); + const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); + const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; + return [r, g, b, a]; + } + const fn = /^(rgba?|hsla?|oklab|oklch|lab|lch)\(([^)]*)\)$/i.exec(s); + if (!fn) return null; + const kind = fn[1]!, parsed = parseColorArgs(fn[2]!); + if (!parsed) return null; + const { nums, alpha } = parsed; + const raw = fn[2]!.split("/")[0]!.trim().split(/[\s,]+/).filter(Boolean); + if (kind.startsWith("rgb")) { + return [Math.round(pctOr(raw[0]!, 255)), Math.round(pctOr(raw[1]!, 255)), Math.round(pctOr(raw[2]!, 255)), alpha]; + } + if (kind.startsWith("hsl")) { + const H = ((nums[0]! % 360) + 360) % 360, S = pctOr(raw[1]!, 1), Lh = pctOr(raw[2]!, 1); + const c = (1 - Math.abs(2 * Lh - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = Lh - c / 2; + const [r1, g1, b1] = H < 60 ? [c, x, 0] : H < 120 ? [x, c, 0] : H < 180 ? [0, c, x] : H < 240 ? [0, x, c] : H < 300 ? [x, 0, c] : [c, 0, x]; + return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255), alpha]; + } + if (kind === "oklab") { const [r, g, b] = oklabToRgb(pctOr(raw[0]!, 1), nums[1]!, nums[2]!); return [r, g, b, alpha]; } + if (kind === "oklch") { + const L = pctOr(raw[0]!, 1), C = nums[1]!, h = (nums[2]! * Math.PI) / 180; + const [r, g, b] = oklabToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha]; + } + if (kind === "lab") { const [r, g, b] = labToRgb(pctOr(raw[0]!, 100), nums[1]!, nums[2]!); return [r, g, b, alpha]; } + if (kind === "lch") { + const L = pctOr(raw[0]!, 100), C = nums[1]!, h = (nums[2]! * Math.PI) / 180; + const [r, g, b] = labToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha]; + } + return null; +} + +/** Back-compat alias: the palette parses every colour space now, not just rgb(). */ function parseRgb(v: string): RGBA | null { - const m = v.match(/rgba?\(([^)]+)\)/i); - if (!m) return null; - const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim())); - if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null; - return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1]; + return parseColor(v); +} + +/** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals + * that render the same colour (`oklab(0.988242 …)` and `oklab(0.988371 …)` → rgb(251,251,251)) + * collapse to ONE minted token instead of a wall of near-identical --clr-N. Null when the + * value can't be parsed (keeps the raw literal as its own key). Alpha bucketed to ±0.02. */ +export function colorClusterKey(v: string): string | null { + const c = parseColor(v); + if (!c) return null; + return `${c[0]},${c[1]},${c[2]},${Math.round(c[3] * 50)}`; } function within2(a: RGBA, b: RGBA): boolean { return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04; @@ -140,33 +245,57 @@ function paletteFrom(usage: Map, roleIr: IR, minCount: number): C const fgCluster = findCluster(pv?.bodyColor); if (fgCluster) assign("--foreground", fgCluster); - // 2) Primary/accent: most-used chromatic color, weighted toward interactive usage. - const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m))); + // Luminance (0–1) for deterministic tiebreaks (Rec.709). Ties in every ranking below + // break by count, then luminance (dark→light), then the literal value — so the palette + // is byte-stable for a given capture (determinism gate 6). + const lum = (c: Usage): number => (0.2126 * c.rgba[0] + 0.7152 * c.rgba[1] + 0.0722 * c.rgba[2]) / 255; + const unnamed = (c: { canon: Usage; members: string[] }): boolean => !c.members.some((m) => valueToName.has(m)); + const byCount = (score: (c: Usage) => number) => (a: { canon: Usage }, b: { canon: Usage }): number => + score(b.canon) - score(a.canon) || b.canon.count - a.canon.count || lum(a.canon) - lum(b.canon) || a.canon.value.localeCompare(b.canon.value); + + // 2) Brand + accent: the most-used chromatic colours, weighted toward interactive usage + // (buttons/links). `--primary` (a.k.a. brand) is the strongest; `--accent` the next. + // A colour is "chromatic" only with real hue: HSL saturation ≥ 0.25 AND an absolute + // channel spread ≥ 24, so near-neutral off-whites/creams (max−min ≈ 10) that merely + // tip over the ratio threshold stay neutrals → --surface, not a fake --accent. + const chroma = (c: RGBA): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]); + const remaining = clusters.filter(unnamed); const chromatic = remaining - .filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; }) - .sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count)); + .filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24) + .sort(byCount((c) => c.interactive * 3 + c.count)); if (chromatic[0]) assign("--primary", chromatic[0]); if (chromatic[1]) assign("--accent", chromatic[1]); - // 3) Border: most-used color that appears predominantly as a border. + // 3) Border: most-used colour that appears predominantly as a border. const borderC = remaining - .filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0) - .sort((a, b) => b.canon.border - a.canon.border)[0]; + .filter((c) => unnamed(c) && c.canon.border > 0) + .sort(byCount((c) => c.border))[0]; if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC); - // 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted. - for (const c of remaining) { - if (c.members.some((m) => valueToName.has(m))) continue; - if (c.canon.count < minCount) continue; - const { s, l } = satLight(c.canon.rgba); - if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c); - else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c); + // 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as + // backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds); + // mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the + // complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread), + // so no colour falls through the gap between the two thresholds. + const neutrals = remaining + .filter((c) => unnamed(c) && c.canon.count >= minCount && (satLight(c.canon.rgba).s < 0.25 || chroma(c.canon.rgba) < 24)) + .sort(byCount((c) => c.count)); + let surfaceN = 0; + for (const c of neutrals) { + if (!unnamed(c)) continue; + const { l } = satLight(c.canon.rgba); + if (c.canon.bg >= c.canon.text && l > 0.85) { + assign(surfaceN === 0 ? "--surface" : `--surface-${surfaceN + 1}`, c); + surfaceN++; + } else if (c.canon.text >= c.canon.bg && l > 0.2 && l < 0.7) { + assign(!taken.has("--muted-foreground") ? "--muted-foreground" : "--muted", c); + } } - // 5) Everything else used >= minCount → numbered. + // 5) Everything else used >= minCount → numbered, in a deterministic order. let ci = 1; - for (const c of remaining) { - if (c.members.some((m) => valueToName.has(m))) continue; + for (const c of [...remaining].sort(byCount(() => 0))) { + if (!unnamed(c)) continue; if (c.canon.count < minCount) continue; assign(`--color-${String(ci++).padStart(3, "0")}`, c); } diff --git a/compiler/src/normalize/ir.ts b/compiler/src/normalize/ir.ts index 982e296..839a595 100644 --- a/compiler/src/normalize/ir.ts +++ b/compiler/src/normalize/ir.ts @@ -25,6 +25,9 @@ export type IRNode = { // element had no class. srcClass?: string; rawHTML?: string; // inline svg + // Computed paint of an inline root (fill/stroke/color), carried from capture. Lets codegen + // recover a paint that a raw `fill="none"` attribute hides but site CSS actually supplied. + svgPaint?: { fill: string; stroke: string; color: string }; visibleByVp: Record; bboxByVp: Record; computedByVp: Record; @@ -456,6 +459,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? children: [], }; if (raw.rawHTML) node.rawHTML = raw.rawHTML; + if (raw.svgPaint) node.svgPaint = raw.svgPaint; const srcClass = (raw.attrs?.class ?? "").trim(); if (srcClass) node.srcClass = srcClass; if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp; diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index d0c9547..237fe7c 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -784,3 +784,39 @@ describe("generateCss breakpoint-gated marquee transform band suppression (Defec assert.ok(/matrix\(1, ?0, ?0, ?1, ?-40/.test(allRulesX(css, "n1")), `a non-animated per-band offset must be kept, got: ${allRulesX(css, "n1")}`); }); }); + +// text-wrap emission: a heading authored `text-wrap:balance` must survive to the clone, or the +// title wraps differently. The initial `wrap` is elided (it's the default), so only authored +// balance/pretty/nowrap ship. +describe("generateCss text-wrap", () => { + it("emits an authored text-wrap:balance on a heading", () => { + const h1 = node("n1", "h1", computed({ textWrap: "balance" })); + const root = node("n0", "body", computed(), [h1]); + const css = generateCss(irWith(root), new Map()); + assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), `text-wrap:balance emitted (got: ${baseRule(css, "n1")})`); + }); + + it("emits text-wrap:pretty", () => { + const p = node("n1", "p", computed({ textWrap: "pretty" })); + const root = node("n0", "body", computed(), [p]); + const css = generateCss(irWith(root), new Map()); + assert.ok(/text-wrap:pretty/.test(baseRule(css, "n1")), `text-wrap:pretty emitted (got: ${baseRule(css, "n1")})`); + }); + + it("elides the default text-wrap:wrap (no noise)", () => { + const h1 = node("n1", "h1", computed({ textWrap: "wrap" })); + const root = node("n0", "body", computed(), [h1]); + const css = generateCss(irWith(root), new Map()); + assert.ok(!/text-wrap/.test(baseRule(css, "n1")), `default text-wrap:wrap must not be emitted (got: ${baseRule(css, "n1")})`); + }); + + it("skips text-wrap on a child when it equals the parent (inherited)", () => { + // text-wrap is inherited: a child matching the parent's balance relies on inheritance. + const child = node("n2", "span", computed({ textWrap: "balance" })); + const h1 = node("n1", "h1", computed({ textWrap: "balance" }), [child]); + const root = node("n0", "body", computed(), [h1]); + const css = generateCss(irWith(root), new Map()); + assert.ok(/text-wrap:balance/.test(baseRule(css, "n1")), "parent heading emits balance"); + assert.ok(!/text-wrap/.test(baseRule(css, "n2")), `inherited child does not re-emit (got: ${baseRule(css, "n2")})`); + }); +}); diff --git a/compiler/test/interactions.test.ts b/compiler/test/interactions.test.ts new file mode 100644 index 0000000..7d387c9 --- /dev/null +++ b/compiler/test/interactions.test.ts @@ -0,0 +1,90 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium, type Browser, type Page } from "playwright"; +import { captureInteractions, tagElements, diffStyle } from "../src/capture/interactions.js"; + +describe("diffStyle", () => { + it("returns only the changed keys, with the b-side value", () => { + const a = { color: "rgb(0, 0, 0)", opacity: "1", transform: "none" }; + const b = { color: "rgb(255, 0, 0)", opacity: "1", transform: "scale(1.1)" }; + assert.deepEqual(diffStyle(a, b), { color: "rgb(255, 0, 0)", transform: "scale(1.1)" }); + }); + it("is empty when nothing changed", () => { + const a = { color: "rgb(0, 0, 0)", opacity: "1" }; + assert.deepEqual(diffStyle(a, { ...a }), {}); + }); + it("only reports keys present in b (b drives the comparison)", () => { + // a resting style that carries an extra key does not fabricate a delta. + assert.deepEqual(diffStyle({ color: "red", extra: "x" }, { color: "red" }), {}); + }); +}); + +describe("captureInteractions hover capture (occlusion-immune)", () => { + let browser: Browser; + let page: Page; + before(async () => { + browser = await chromium.launch(); + page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + }); + after(async () => { + await browser.close(); + }); + + const setup = async (html: string) => { + await page.setContent(html); + await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); + await tagElements(page); + }; + + it("captures an authored :hover state even when a transparent full-viewport overlay covers the target", async () => { + // Exact regression for the empty-hover-capture bug: a modern builder stack parks a + // transparent fixed layer over the whole page. page.hover moves the real cursor to the + // link's centre, the point lands on the overlay, and `:hover` never reaches the link — so + // pointer-based probing captured ZERO hover states. Forcing the pseudo-class fixes it. + await setup(` + + Shop now +
`); + const cap = await captureInteractions(page, { maxCandidates: 50 }); + const hoverCaps = Object.keys(cap.hover); + assert.ok(hoverCaps.length >= 1, "at least one hover state is captured through the overlay"); + const delta = cap.hover[hoverCaps[0]!]!; + assert.equal(delta.color, "rgb(255, 0, 0)", "hover color change is captured"); + assert.equal(delta.backgroundColor, "rgb(0, 0, 255)", "hover background change is captured"); + }); + + it("captures :hover on a cursor:pointer element that is not a native interactive", async () => { + await setup(` + +
Card
`); + const cap = await captureInteractions(page, { maxCandidates: 50 }); + const found = Object.values(cap.hover).some((d) => d.borderTopColor === "rgb(9, 9, 9)"); + assert.ok(found, "a cursor:pointer card's authored :hover border change is captured"); + }); + + it("records no hover delta for an element with no authored :hover (self-limiting)", async () => { + await setup(` + No hover`); + const cap = await captureInteractions(page, { maxCandidates: 50 }); + assert.equal(Object.keys(cap.hover).length, 0, "no authored hover -> empty hover map"); + }); + + it("restores the resting state after probing (forced pseudo-state cleared)", async () => { + await setup(` + + Link`); + await captureInteractions(page, { maxCandidates: 50 }); + const resting = await page.evaluate(() => getComputedStyle(document.querySelector("a.cta")!).color); + assert.equal(resting, "rgb(0, 0, 0)", "the page is left in its resting (non-hover) state"); + }); +}); diff --git a/compiler/test/semanticNaming.test.ts b/compiler/test/semanticNaming.test.ts new file mode 100644 index 0000000..762f839 --- /dev/null +++ b/compiler/test/semanticNaming.test.ts @@ -0,0 +1,177 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildColorPalette, colorClusterKey } from "../src/infer/semanticTokens.js"; +import { planSections, nameFromSourceToken } from "../src/generate/sectionSplit.js"; +import type { IR, IRNode, IRChild, StyleMap } from "../src/normalize/ir.js"; + +const CW = 1280; + +type Box = { x?: number; y: number; width?: number; height: number }; +function el(tag: string, box: Box, computed: StyleMap, children: IRChild[] = [], attrs: Record = {}, srcClass?: string): IRNode { + const n: IRNode = { + id: "", tag, attrs, + visibleByVp: { [CW]: true }, + bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } }, + computedByVp: { [CW]: { display: "block", ...computed } }, + children, + }; + if (srcClass) n.srcClass = srcClass; + return n; +} +function text(t: string): IRChild { return { text: t }; } + +function page(children: IRNode[], pageH: number, body?: { bodyBg?: string; bodyColor?: string }): IR { + // The body carries its own bg/fg computed style (as a real capture does), so the palette's + // usage histogram actually sees the page background/foreground colours it will name. + const root = el("body", { y: 0, height: pageH }, { + ...(body?.bodyBg ? { backgroundColor: body.bodyBg } : {}), + ...(body?.bodyColor ? { color: body.bodyColor } : {}), + }, children); + let i = 0; + const assign = (n: IRNode): void => { n.id = `n${i++}`; for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode); }; + assign(root); + return { + doc: { + sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8", + metaViewport: "width=device-width, initial-scale=1", + viewports: [CW], sampleViewports: [CW], canonicalViewport: CW, + perViewport: { [CW]: { scrollHeight: pageH, scrollWidth: CW, htmlBg: "", bodyBg: body?.bodyBg ?? "", bodyColor: body?.bodyColor ?? "", bodyFont: "" } }, + nodeCount: i, keyframes: [], + }, + root, + } as IR; +} + +// ---------------------------------------------------------------------------- +// 1) Semantic role assignment — a fixture IR with known roles must produce the +// expected named tokens (deterministically), leftovers only for role-less colours. +// ---------------------------------------------------------------------------- +describe("semantic color palette: role assignment", () => { + // page bg = cream; body text = near-black; a saturated red brand used on buttons/links; + // a light-gray surface used as several card backgrounds; a border gray; one odd accent. + function siteIr(): IR { + const kids: IRNode[] = []; + // Body text (near-black) x8 + for (let k = 0; k < 8; k++) kids.push(el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("copy")])); + // Brand red on buttons/links x6 (interactive → --primary) + for (let k = 0; k < 6; k++) kids.push(el("a", { y: 400 + k * 30, height: 40 }, { backgroundColor: "rgb(188, 0, 0)", color: "rgb(255,255,255)" }, [text("Buy")])); + // Light-gray card surfaces x5 (bg, light, low-sat → --surface) + for (let k = 0; k < 5; k++) kids.push(el("div", { y: 800 + k * 60, height: 50 }, { backgroundColor: "rgb(240, 238, 230)" })); + // Border gray x4 (border only → --border) + for (let k = 0; k < 4; k++) kids.push(el("div", { y: 1200 + k * 30, height: 20 }, { borderTopColor: "rgb(200, 200, 200)", borderTopWidth: "1px" })); + return page(kids, 1600, { bodyBg: "rgb(252, 251, 246)", bodyColor: "rgb(20, 20, 19)" }); + } + + it("names background / foreground / primary / surface / border from usage evidence", () => { + const p = buildColorPalette(siteIr()); + const byName = new Map(p.tokens.map((t) => [t.name, t.value])); + assert.equal(byName.get("--background"), "rgb(252, 251, 246)"); + assert.equal(byName.get("--foreground"), "rgb(20, 20, 19)"); + assert.equal(byName.get("--primary"), "rgb(188, 0, 0)"); + assert.equal(byName.get("--surface"), "rgb(240, 238, 230)"); + assert.equal(byName.get("--border"), "rgb(200, 200, 200)"); + // Every named token resolves back to itself. + assert.equal(p.varForColor("rgb(188, 0, 0)"), "var(--primary)"); + }); + + it("is deterministic: same IR → byte-identical token list", () => { + const a = buildColorPalette(siteIr()).css; + const b = buildColorPalette(siteIr()).css; + assert.equal(a, b); + }); + + it("resolves oklab/oklch forms of a named colour to the SAME semantic token (±2 sRGB)", () => { + // oklch(0.987… 97°) ≈ rgb(252,251,246) — the page background. Reached only via a + // gradient/decoration property, it must still map to --background, not a fresh --clr-N. + const p = buildColorPalette(page([ + ...Array.from({ length: 4 }, (_, k) => el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("x")])), + ], 400, { bodyBg: "oklch(0.987472 0.00667657 97.3497)", bodyColor: "rgb(20, 20, 19)" })); + const bg = p.tokens.find((t) => t.name === "--background"); + assert.ok(bg, "background named"); + // The rgb() equivalent within tolerance resolves to --background via the ±2 fallback. + assert.equal(p.varForColor("rgb(252, 251, 246)"), "var(--background)"); + }); +}); + +// ---------------------------------------------------------------------------- +// 2) colorClusterKey — visually identical literals share a key; distinct colours don't. +// ---------------------------------------------------------------------------- +describe("colorClusterKey (interner visual dedup)", () => { + it("collapses oklab forms that round to the same sRGB", () => { + // Both oklab(0.988…) whites → rgb(251,251,251). + const a = colorClusterKey("oklab(0.988242 -0.0000812355 0.00000757745)"); + const b = colorClusterKey("oklab(0.988371 -0.0000803481 0.00000749468)"); + assert.ok(a); + assert.equal(a, b); + }); + it("keeps genuinely different colours on different keys", () => { + assert.notEqual(colorClusterKey("rgb(0,0,0)"), colorClusterKey("rgb(255,255,255)")); + assert.notEqual(colorClusterKey("rgb(188,0,0)"), colorClusterKey("rgb(0,0,188)")); + }); + it("separates alpha variants", () => { + assert.notEqual(colorClusterKey("rgba(0,0,0,0.5)"), colorClusterKey("rgba(0,0,0,0.75)")); + }); + it("returns null for unparseable values (keeps raw-literal keying)", () => { + assert.equal(colorClusterKey("var(--x)"), null); + assert.equal(colorClusterKey("currentColor"), null); + }); +}); + +// ---------------------------------------------------------------------------- +// 3) Name sanitization — hashy-suffix stripping + generic-word filtering. +// ---------------------------------------------------------------------------- +describe("nameFromSourceToken (source-id sanitization)", () => { + it("strips Shopify template prefix + trailing hash → semantic slug", () => { + assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__split_callout_JtTWTt"), "SplitCallout"); + // `grid` is a generic structural word (dropped); the hash `RbEALJ` is stripped. + assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__media_card_grid_RbEALJ"), "MediaCard"); + }); + it("strips mixed-case build hashes (JtTWTt / dDMm2q / RbEALJ)", () => { + assert.equal(nameFromSourceToken("split_callout_JtTWTt"), "SplitCallout"); + assert.equal(nameFromSourceToken("hero_hD9krx"), "Hero"); + }); + it("drops generic structural words entirely", () => { + assert.equal(nameFromSourceToken("g_section_wrap"), ""); + assert.equal(nameFromSourceToken("section-inner-content"), ""); + assert.equal(nameFromSourceToken("shopify-section-group-header"), "Header"); + }); + it("handles js-* hooks and long numeric ids", () => { + assert.equal(nameFromSourceToken("js-media-banner-section"), "MediaBanner"); + assert.equal(nameFromSourceToken("template--19797275672650"), ""); + }); +}); + +// ---------------------------------------------------------------------------- +// 4) planSections — a Shopify-id section beats a heading slug; noisy classes don't. +// ---------------------------------------------------------------------------- +describe("planSections: source-id naming precedence", () => { + it("names a section from its CMS section id (hash stripped) over generic evidence", () => { + const nav = el("nav", { y: 0, height: 62 }, {}); + const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome")])]); + // A one-off band with NO heading but a clean Shopify id → SplitCalloutSection. + const callout = el("div", { y: 862, height: 600 }, {}, [ + el("p", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Some marketing copy here")]), + ], { id: "shopify-section-template--19797275672650__split_callout_JtTWTt" }, "shopify-section js-split-callout-section"); + const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need today")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("extra")])]); + const b3 = el("section", { y: 1962, height: 500 }, {}, [el("h2", { y: 2000, height: 40, x: 120, width: 600 }, {}, [text("What customers say now")]), el("p", { y: 2060, height: 24, x: 120, width: 600 }, {}, [text("a")]), el("p", { y: 2090, height: 24, x: 120, width: 600 }, {}, [text("b")])]); + const footer = el("footer", { y: 2462, height: 400 }, {}, [el("a", { y: 2500, height: 20, x: 120, width: 200 }, {}, [text("Privacy")], {}, undefined)]); + const ir = page([nav, hero, callout, b2, b3, footer], 2862); + const names = [...planSections(ir).roots.values()]; + assert.ok(names.includes("SplitCalloutSection"), `expected SplitCalloutSection in ${names.join(", ")}`); + }); + + it("does NOT mine arbitrary utility classes (falls back to heading slug)", () => { + const nav = el("nav", { y: 0, height: 62 }, {}); + const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome home")])]); + // Heading present, but the only source class is a noisy Webflow utility → use the heading. + const band = el("section", { y: 862, height: 600 }, {}, [ + el("h2", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Latest releases from us")]), + ], {}, "g_section_space duraldar-cta_section w-variant-60a7ad7d"); + const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("x")])]); + const footer = el("footer", { y: 1962, height: 400 }, {}, [el("a", { y: 2000, height: 20, x: 120, width: 200 }, {}, [text("Privacy")])]); + const ir = page([nav, hero, band, b2, footer], 2362); + const names = [...planSections(ir).roots.values()]; + assert.ok(names.some((n) => /^LatestReleases/.test(n)), `expected heading slug, got ${names.join(", ")}`); + assert.ok(!names.some((n) => /Duraldar|Space/.test(n)), `must not mine utility classes: ${names.join(", ")}`); + }); +}); diff --git a/compiler/test/svgRootFill.test.ts b/compiler/test/svgRootFill.test.ts new file mode 100644 index 0000000..35a8d97 --- /dev/null +++ b/compiler/test/svgRootFill.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveSvgRootFill, isRealPaint } from "../src/generate/app.js"; + +describe("isRealPaint", () => { + it("treats none / empty / transparent as non-paint", () => { + for (const v of [undefined, null, "", " ", "none", "NONE", "transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"]) { + assert.equal(isRealPaint(v), false, `${String(v)} should not paint`); + } + }); + it("treats a color / currentColor as a real paint", () => { + for (const v of ["rgb(255, 255, 255)", "#000", "currentColor", "red", "rgb(0, 0, 0)"]) { + assert.equal(isRealPaint(v), true, `${v} should paint`); + } + }); +}); + +describe("resolveSvgRootFill", () => { + it("keeps a raw fill that is itself a real paint", () => { + assert.deepEqual(resolveSvgRootFill("#123456", { fill: "rgb(255,255,255)", color: "rgb(0,0,0)" }), { mode: "keep" }); + assert.deepEqual(resolveSvgRootFill("red", null), { mode: "keep" }); + }); + + it("falls back when no raw fill is declared (existing currentColor default)", () => { + assert.deepEqual(resolveSvgRootFill(undefined, { fill: "rgb(255,255,255)", color: "rgb(255,255,255)" }), { mode: "fallback" }); + assert.deepEqual(resolveSvgRootFill(null, null), { mode: "fallback" }); + }); + + it("recovers currentColor when fill=none but computed fill tracks the element color (wordmark case)", () => { + // The a16z logo case: fill="none" attribute, but CSS `fill: currentColor` with white color. + const r = resolveSvgRootFill("none", { fill: "rgb(255, 255, 255)", color: "rgb(255, 255, 255)" }); + assert.equal(r.mode, "emit"); + assert.equal(r.value, "currentColor"); + assert.equal(r.emitColor, "rgb(255, 255, 255)"); + }); + + it("emits the literal computed fill when it differs from the element color", () => { + const r = resolveSvgRootFill("none", { fill: "rgb(255, 0, 0)", color: "rgb(0, 0, 0)" }); + assert.equal(r.mode, "emit"); + assert.equal(r.value, "rgb(255, 0, 0)"); + assert.equal(r.emitColor, undefined); + }); + + it("leaves a genuinely unfilled svg as none (computed fill also none)", () => { + assert.deepEqual(resolveSvgRootFill("none", { fill: "none", color: "rgb(0,0,0)" }), { mode: "keep" }); + assert.deepEqual(resolveSvgRootFill("none", { fill: "rgba(0, 0, 0, 0)", color: "rgb(0,0,0)" }), { mode: "keep" }); + // No computed paint captured at all → cannot prove it paints → stays none. + assert.deepEqual(resolveSvgRootFill("none", null), { mode: "keep" }); + assert.deepEqual(resolveSvgRootFill("none", undefined), { mode: "keep" }); + }); + + it("does not emit color when the recovered fill is currentColor but color is not a real paint", () => { + // fill == color but color is transparent → cannot be a meaningful currentColor recovery; + // the equality branch is guarded on isRealPaint(color), so it emits the literal fill instead. + const r = resolveSvgRootFill("none", { fill: "rgb(0, 128, 0)", color: "transparent" }); + assert.equal(r.mode, "emit"); + assert.equal(r.value, "rgb(0, 128, 0)"); + assert.equal(r.emitColor, undefined); + }); +}); diff --git a/compiler/test/tailwind.test.ts b/compiler/test/tailwind.test.ts index 9f5721f..d65cb4b 100644 --- a/compiler/test/tailwind.test.ts +++ b/compiler/test/tailwind.test.ts @@ -133,3 +133,21 @@ describe("declToUtil letter-spacing sub-0.1px preservation", () => { assert.equal(declToUtil("width", "204.9994px"), "w-[205px]"); }); }); + +// text-wrap: modern heading line-balancing. `balance`/`pretty` rebalance where a title wraps; +// without emitting them a two-line heading breaks differently in the clone. Tailwind v4 has the +// named utilities text-balance / text-pretty / text-nowrap / text-wrap; anything else (e.g. +// `stable`) falls through to the arbitrary property escape. +describe("declToUtil text-wrap", () => { + it("maps balance and pretty to the named Tailwind v4 utilities", () => { + assert.equal(declToUtil("text-wrap", "balance"), "text-balance"); + assert.equal(declToUtil("text-wrap", "pretty"), "text-pretty"); + }); + it("maps wrap and nowrap to their named utilities", () => { + assert.equal(declToUtil("text-wrap", "wrap"), "text-wrap"); + assert.equal(declToUtil("text-wrap", "nowrap"), "text-nowrap"); + }); + it("falls back to the arbitrary property for an unmapped value", () => { + assert.equal(declToUtil("text-wrap", "stable"), "[text-wrap:stable]"); + }); +}); diff --git a/compiler/test/videoSourceSelect.test.ts b/compiler/test/videoSourceSelect.test.ts new file mode 100644 index 0000000..b7b6c27 --- /dev/null +++ b/compiler/test/videoSourceSelect.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { selectVideoSourceIndex, type VideoSourceCandidate } from "../src/capture/capture.js"; + +// Predicate builders for the injected matchMedia / canPlayType. +const matchesAny = (matching: Set) => (m: string) => matching.has(m); +const canPlayAll = () => true; +const canPlayNone = () => false; + +describe("selectVideoSourceIndex (video resource-selection)", () => { + it("picks the first source whose media matches; missing media matches unconditionally", () => { + // Aspect-gated hero: landscape sources first, portrait after, plain fall-through last. + const sources: VideoSourceCandidate[] = [ + { media: "(min-aspect-ratio: 21/9)", type: "video/webm" }, + { media: "(min-aspect-ratio: 16/9)", type: "video/webm" }, + { media: "(max-aspect-ratio: 4/5)", type: "video/webm" }, + { media: null, type: "video/webm" }, // plain fall-through + ]; + // Portrait viewport: only the max-aspect-ratio query matches → index 2. + assert.equal( + selectVideoSourceIndex(sources, matchesAny(new Set(["(max-aspect-ratio: 4/5)"])), canPlayAll), + 2, + ); + // Landscape/wide viewport: the min-aspect 16/9 query matches → index 1. + assert.equal( + selectVideoSourceIndex(sources, matchesAny(new Set(["(min-aspect-ratio: 16/9)"])), canPlayAll), + 1, + ); + // Nothing matches → falls through to the plain no-media source (index 3). + assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayAll), 3); + }); + + it("skips a source whose type the UA cannot play", () => { + const sources: VideoSourceCandidate[] = [ + { media: null, type: "video/webm" }, // unplayable here + { media: null, type: "video/mp4" }, + ]; + const canPlayMp4 = (t: string) => t === "video/mp4"; + assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayMp4), 1); + }); + + it("treats a missing/empty type as never disqualifying", () => { + const sources: VideoSourceCandidate[] = [ + { media: null, type: "" }, + { media: null }, + ]; + // canPlay is never consulted when type is absent, even if it would reject everything. + assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), 0); + }); + + it("returns -1 when no source is eligible", () => { + const sources: VideoSourceCandidate[] = [ + { media: "(max-aspect-ratio: 4/5)", type: "video/webm" }, + ]; + assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), -1); + assert.equal(selectVideoSourceIndex([], matchesAny(new Set()), canPlayAll), -1); + }); + + it("is deterministic and first-match-wins in document order", () => { + const sources: VideoSourceCandidate[] = [ + { media: "(min-width: 100px)", type: "video/mp4" }, + { media: "(min-width: 100px)", type: "video/mp4" }, // also eligible, but later + ]; + assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set(["(min-width: 100px)"])), canPlayAll), 0); + }); +}); diff --git a/compiler/test/walker.test.ts b/compiler/test/walker.test.ts index a288787..4172c04 100644 --- a/compiler/test/walker.test.ts +++ b/compiler/test/walker.test.ts @@ -317,3 +317,35 @@ describe("walker sizing probe: circular authored-height guard", () => { assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized"); }); }); + +describe("walker text-wrap capture", () => { + let browser: Browser; + let page: Page; + before(async () => { + browser = await chromium.launch(); + page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + }); + after(async () => { + await browser.close(); + }); + + const capture = async (html: string) => { + await page.setContent(html); + await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); + return page.evaluate(collectPage); + }; + + it("captures text-wrap:balance on a heading (modern line-balancing)", async () => { + // Real case: a hero heading authored `text-wrap:balance` wraps its two lines evenly; without + // capturing the prop the clone wraps it lopsidedly. + const snap = await capture(`

BUILT RUGGED. WORN DAILY.

`); + const h1 = findByTag(snap.root, "h1")!; + assert.equal(h1.computed.textWrap, "balance", "text-wrap:balance is captured"); + }); + + it("captures text-wrap:pretty", async () => { + const snap = await capture(`

Some flowing paragraph text here.

`); + const p = findByTag(snap.root, "p")!; + assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured"); + }); +});