/** * Managed breakpoint media block for the Design Studio (§6.4). * * Responsive class prefixes can't express arbitrary values (exact px * positions from canvas drags, rgb() colors, calc() sizes, …). Those * overrides persist into a single managed * `` : ""; if (openMatch) { const bodyStart = openMatch.index + openMatch[0].length; const afterOpen = html.slice(bodyStart); const closeMatch = /<\s*\/\s*style\b[^>]*>/i.exec(afterOpen); if (closeMatch) { const closeEnd = bodyStart + closeMatch.index + closeMatch[0].length; // Removing the block: also swallow one trailing newline so repeated // add/remove cycles don't accumulate blank lines. if (block === "") { const after = html.slice(closeEnd); return html.slice(0, openMatch.index) + after.replace(/^\n/, ""); } return html.slice(0, openMatch.index) + block + html.slice(closeEnd); } } if (block === "") return html; const headClose = html.lastIndexOf(""); if (headClose !== -1) { return html.slice(0, headClose) + block + "\n" + html.slice(headClose); } return block + "\n" + html; } // ─── CSS body parse / serialize ────────────────────────────────────────────── /** * Parse a managed CSS body into the model. Tolerant of hand edits made in * the Code panel: unknown at-rules and selectors are skipped, not errors. */ export function parseBreakpointMediaCss(css: string): BreakpointMediaModel { const model: BreakpointMediaModel = {}; const mediaRe = /@media\s*\(\s*max-width\s*:\s*(\d+(?:\.\d+)?)px\s*\)\s*\{/g; let mediaMatch: RegExpExecArray | null; while ((mediaMatch = mediaRe.exec(css)) !== null) { const maxWidthPx = Math.round(Number.parseFloat(mediaMatch[1])); if (!Number.isFinite(maxWidthPx) || maxWidthPx <= 0) continue; const bodyStart = mediaMatch.index + mediaMatch[0].length; const body = extractBlock(css, bodyStart); if (body === null) continue; mediaRe.lastIndex = bodyStart + body.length + 1; // Matches the LAST [data-agent-native-node-id="…"] attribute directly // before the `{`, so it accepts BOTH the legacy single-attribute selector // and the current doubled-attribute selector (see // serializeBreakpointMediaModel below) without double-counting: on a // doubled selector the scan skips the first attribute occurrence (no `{` // after it) and captures the nodeId exactly once from the second. const ruleRe = /\[data-agent-native-node-id="((?:\\.|[^"\\])*)"\]\s*\{([^}]*)\}/g; let ruleMatch: RegExpExecArray | null; while ((ruleMatch = ruleRe.exec(body)) !== null) { const nodeId = unescAttr(ruleMatch[1]); if (!nodeId) continue; const declarations = ruleMatch[2] .split(";") .map((decl) => decl.trim()) .filter(Boolean); for (const declaration of declarations) { const colon = declaration.indexOf(":"); if (colon <= 0) continue; const property = declaration.slice(0, colon).trim(); const value = declaration.slice(colon + 1).trim(); if (!isSafeBreakpointCssProperty(property)) continue; if (!isSafeBreakpointCssValueForProperty(property, value)) continue; const bucketKey = String(maxWidthPx); model[bucketKey] ??= {}; model[bucketKey][nodeId] ??= {}; model[bucketKey][nodeId][property] = value; } } } return model; } /** * Serialize the model into a deterministic CSS body: buckets widest bound * FIRST (narrower ranges later in source so they win the desktop-down * cascade), node ids and properties sorted alphabetically. */ export function serializeBreakpointMediaModel( model: BreakpointMediaModel, ): string { const buckets = Object.keys(model) .map((key) => Number.parseInt(key, 10)) .filter((width) => Number.isFinite(width) && width > 0) .sort((a, b) => b - a); const blocks: string[] = []; for (const maxWidthPx of buckets) { const nodes = model[String(maxWidthPx)]; if (!nodes) continue; const nodeIds = Object.keys(nodes).sort((a, b) => a.localeCompare(b)); const rules: string[] = []; for (const nodeId of nodeIds) { const declarations = nodes[nodeId]; const properties = Object.keys(declarations).sort((a, b) => a.localeCompare(b), ); if (properties.length === 0) continue; const lines = properties.map( (property) => ` ${property}: ${declarations[property]};`, ); // Doubled attribute selector → specificity (0,2,0), beating any single // Tailwind utility class (0,1,0) REGARDLESS of stylesheet order. This // matters because designs load Tailwind via the Play CDN script, which // injects its generated utility sheet at RUNTIME — always after this // static managed block — so an equal-specificity (0,1,0) single // attribute selector always lost by source order and the breakpoint // override silently never rendered (verified on real designs: the // @media rule was live and matching, but .px-5 won). Doubling the // attribute survives DOM reorder, Tailwind re-injection, and any // future stylesheet ordering change; parseBreakpointMediaCss above // accepts both formats, and because every write re-serializes the // whole block, legacy single-attribute rules are upgraded to this // format on the next edit automatically. const attrSelector = `[data-agent-native-node-id="${escAttr(nodeId)}"]`; rules.push( ` ${attrSelector}${attrSelector} {\n${lines.join("\n")}\n }`, ); } if (rules.length === 0) continue; blocks.push( `@media (max-width: ${maxWidthPx}px) {\n${rules.join("\n")}\n}`, ); } return blocks.join("\n\n"); } // ─── High-level document operations ────────────────────────────────────────── /** * Set (or overwrite) one managed declaration for a node at a max-width * scope, returning the updated HTML. Throws on unsafe property/value — the * caller decides how to surface that. */ export function setBreakpointMediaDeclaration( html: string, args: { nodeId: string; maxWidthPx: number; property: string; value: string; }, ): string { const property = normalizeCssPropertyName(args.property.trim()); if (!isSafeBreakpointCssProperty(property)) { throw new Error( `Invalid breakpoint override property: "${args.property}".`, ); } if (!isSafeBreakpointCssValueForProperty(property, args.value)) { throw new Error( `Invalid breakpoint override value for "${property}": semicolons, braces, comments, angle brackets, control characters, and unsafe url(...) references are not allowed.`, ); } if (!Number.isFinite(args.maxWidthPx) || args.maxWidthPx <= 0) { throw new Error(`Invalid breakpoint bound: ${args.maxWidthPx}px.`); } const model = parseBreakpointMediaCss( extractManagedBreakpointCss(html) ?? "", ); const bucketKey = String(Math.round(args.maxWidthPx)); model[bucketKey] ??= {}; model[bucketKey][args.nodeId] ??= {}; model[bucketKey][args.nodeId][property] = args.value.trim(); return injectManagedBreakpointCss(html, serializeBreakpointMediaModel(model)); } /** * Remove one managed declaration (reset the property back to the base / * wider-scope value). Prunes empty rules, buckets, and — when nothing is * left — the whole managed block. */ export function removeBreakpointMediaDeclaration( html: string, args: { nodeId: string; maxWidthPx: number; property: string }, ): string { const css = extractManagedBreakpointCss(html); if (css === null) return html; const model = parseBreakpointMediaCss(css); const bucketKey = String(Math.round(args.maxWidthPx)); const property = normalizeCssPropertyName(args.property.trim()); const node = model[bucketKey]?.[args.nodeId]; if (!node || !(property in node)) return html; delete node[property]; if (Object.keys(node).length === 0) { delete model[bucketKey][args.nodeId]; } if (Object.keys(model[bucketKey]).length === 0) { delete model[bucketKey]; } return injectManagedBreakpointCss(html, serializeBreakpointMediaModel(model)); } /** * All managed declarations for one node (every bound), flattened and sorted * widest bound first. Pass `null` nodeId to list every declaration. */ export function getBreakpointMediaDeclarations( html: string, nodeId?: string | null, ): BreakpointMediaDeclaration[] { const css = extractManagedBreakpointCss(html); if (css === null) return []; const model = parseBreakpointMediaCss(css); const declarations: BreakpointMediaDeclaration[] = []; for (const bucketKey of Object.keys(model)) { const maxWidthPx = Number.parseInt(bucketKey, 10); for (const [ruleNodeId, props] of Object.entries(model[bucketKey])) { if (nodeId != null && ruleNodeId !== nodeId) continue; for (const [property, value] of Object.entries(props)) { declarations.push({ maxWidthPx, nodeId: ruleNodeId, property, value }); } } } return declarations.sort( (a, b) => b.maxWidthPx - a.maxWidthPx || a.nodeId.localeCompare(b.nodeId) || a.property.localeCompare(b.property), ); } // ─── Override state (inspector indicator contract) ─────────────────────────── /** One breakpoint override of a property, from either persistence layer. */ export interface BreakpointPropertyOverride { /** Inclusive upper viewport bound the override applies below. */ maxWidthPx: number; /** Where the override persists. */ source: "class" | "media"; /** Utility token (class source) or CSS value (media source). */ value: string; } /** Result of {@link getBreakpointOverrideState}. */ export interface BreakpointOverrideState { /** Every override of the property, widest bound first. */ overrides: BreakpointPropertyOverride[]; /** True when an override exists exactly at the active scope's bound. */ overriddenAtActive: boolean; /** * The active scope's upper bound (from `breakpointUpperBoundPx`), or null * when the active frame is the widest context (base editing). */ activeUpperBoundPx: number | null; } /** * EditPanel contract: per-property override indicators for the active * breakpoint. Aggregates BOTH persistence layers — max-width-scoped * responsive classes on the element and managed `@media` rules for its * node id. * * @param args.className The element's class attribute value. * @param args.html The screen HTML (for the managed media block). * Optional — omit to check classes only. * @param args.nodeId The element's `data-agent-native-node-id`. * @param args.property CSS property (camelCase or kebab-case). * @param args.breakpointWidths Widths of the design's breakpoint frames. * @param args.baseWidthPx The primary frame's width (base context). * @param args.activeWidthPx The active breakpoint frame width, or null * when editing the base. */ export function getBreakpointOverrideState(args: { className: string; html?: string | null; nodeId?: string | null; property: string; breakpointWidths: readonly number[]; baseWidthPx?: number | null; activeWidthPx?: number | null; }): BreakpointOverrideState { const property = normalizeCssPropertyName(args.property.trim()); const stems = utilityStemsForCssProperty(property); const overrides: BreakpointPropertyOverride[] = []; for (const stem of stems) { for (const override of maxWidthOverridesForStem(args.className, stem)) { overrides.push({ maxWidthPx: override.boundPx, source: "class", value: override.utility, }); } } if (args.html && args.nodeId) { for (const declaration of getBreakpointMediaDeclarations( args.html, args.nodeId, )) { if (declaration.property !== property) continue; overrides.push({ maxWidthPx: declaration.maxWidthPx, source: "media", value: declaration.value, }); } } overrides.sort((a, b) => b.maxWidthPx - a.maxWidthPx); const activeUpperBoundPx = args.activeWidthPx != null ? breakpointUpperBoundPx( args.breakpointWidths, args.activeWidthPx, args.baseWidthPx, ) : null; return { overrides, overriddenAtActive: activeUpperBoundPx !== null && overrides.some((override) => override.maxWidthPx === activeUpperBoundPx), activeUpperBoundPx, }; } // ─── Internal helpers ──────────────────────────────────────────────────────── /** Escape a string for a CSS attribute selector value (`\` and `"`). */ function escAttr(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function unescAttr(value: string): string { return value.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } /** * Find the content of the CSS block that starts just after position `start` * (just after the opening `{`). Returns `null` on unbalanced braces. */ function extractBlock(css: string, start: number): string | null { let depth = 1; let i = start; while (i < css.length && depth > 0) { if (css[i] === "{") depth++; else if (css[i] === "}") depth--; i++; } if (depth !== 0) return null; return css.slice(start, i - 1); }