/** * Managed element interaction-state block for the Design Studio. * * Element-level pseudo-class states (hover / focus / focus-visible / active / * disabled) — NOT to be confused with `design-state.ts` / `StatesPanel`, * which model whole-*screen* app states (Loading / Empty / Error, data * fixtures, live captures). This module edits ONE element's interactive * styling; that one edits which named app-level scenario the whole canvas is * showing. Both features coexist: a screen state (e.g. "Empty") and an * element interaction state (e.g. "Hover" on a button) are independent axes. * * Base overrides persist into a 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; } /** Extract the responsive interaction-state managed block. */ export function extractManagedResponsiveInteractionStateCss( html: string, ): string | null { return extractManagedStyleBody(html, RESPONSIVE_OPEN_RE); } /** Inject, replace, or remove the responsive interaction-state block. */ export function injectManagedResponsiveInteractionStateCss( html: string, css: string, ): string { return injectManagedStyleBody( html, css, RESPONSIVE_OPEN_RE, "data-agent-native-state-breakpoints", ); } // ─── CSS body parse / serialize ────────────────────────────────────────────── /** * Matches ONE real pseudo-class state rule for a node id, e.g. * `[data-agent-native-node-id="btn_1"]:focus-visible { ... }`. Deliberately * does NOT match the `[data-an-state-preview="..."]` twin rules — those are * derived output, not source of truth, and are re-generated by * {@link duplicateStatePreviewRules} rather than parsed back in, so hand * edits to a twin rule in the Code panel don't fork state. */ function stateRuleRegex(): RegExp { return /\[data-agent-native-node-id="((?:\\.|[^"\\])*)"\]:(hover|focus-visible|focus|active|disabled)\s*\{([^}]*)\}/g; } /** * Parse a managed CSS body into the model. Tolerant of hand edits made in * the Code panel: unknown selectors (including the derived preview twins) * and invalid declarations are skipped, not errors. */ export function parseInteractionStatesCss(css: string): InteractionStatesModel { const model: InteractionStatesModel = {}; const ruleRe = stateRuleRegex(); let match: RegExpExecArray | null; while ((match = ruleRe.exec(css)) !== null) { const nodeId = unescAttr(match[1]); const state = match[2]; if (!nodeId || !isInteractionState(state)) continue; const declarations = match[3] .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(); // Managed declarations are serialized with `!important` so they can // override the inline base styles Design authors on canvas elements. // Keep that cascade implementation detail out of the persisted model: // inspector fields, diffs, and subsequent writes all operate on the // authored value (`red`), never `red !important`. const value = stripManagedImportant(declaration.slice(colon + 1)); if (!isSafeInteractionStateCssProperty(property)) continue; if (!isSafeInteractionStateCssValue(value)) continue; model[nodeId] ??= {}; const node = model[nodeId]; node[state] ??= {}; node[state]![property] = value; } } return model; } /** * Serialize the real pseudo-class rules (model → CSS body), sorted node id * then fixed state order then property, for byte-identical output. Does NOT * include the forced-preview twins — call {@link duplicateStatePreviewRules} * on the result (or use {@link serializeInteractionStatesModelWithPreviews}) * to append those. */ export function serializeInteractionStatesModel( model: InteractionStatesModel, ): string { const nodeIds = Object.keys(model).sort((a, b) => a.localeCompare(b)); const rules: string[] = []; for (const nodeId of nodeIds) { const states = model[nodeId]; for (const state of INTERACTION_STATES) { const declarations = states[state]; if (!declarations) continue; const properties = Object.keys(declarations).sort((a, b) => a.localeCompare(b), ); if (properties.length === 0) continue; const lines = properties.map( (property) => ` ${property}: ${stripManagedImportant(declarations[property])} !important;`, ); rules.push( `[data-agent-native-node-id="${escAttr(nodeId)}"]:${state} {\n` + `${lines.join("\n")}\n` + `}`, ); } } return rules.join("\n\n"); } /** * Rebuild a managed CSS body's real rules + forced-preview twins from * scratch, given the ALREADY-PARSED model. Re-serializing the real rules * (rather than reusing the raw input body verbatim) is what keeps this * idempotent: a body that already contains twins from a previous run parses * back to the same model (twins are excluded by {@link stateRuleRegex}), so * rebuilding from that model reproduces byte-identical output instead of * accumulating duplicate twin rules on every call. */ function rebuildCssWithPreviews(model: InteractionStatesModel): string { const baseBody = serializeInteractionStatesModel(model); const previewRules: string[] = []; const nodeIds = Object.keys(model).sort((a, b) => a.localeCompare(b)); for (const nodeId of nodeIds) { const states = model[nodeId]; for (const state of INTERACTION_STATES) { const declarations = states[state]; if (!declarations) continue; const properties = Object.keys(declarations).sort((a, b) => a.localeCompare(b), ); if (properties.length === 0) continue; const lines = properties.map( (property) => ` ${property}: ${stripManagedImportant(declarations[property])} !important;`, ); previewRules.push( `[data-agent-native-node-id="${escAttr(nodeId)}"][data-an-state-preview="${state}"] {\n` + `${lines.join("\n")}\n` + `}`, ); } } if (previewRules.length === 0) return baseBody; return baseBody === "" ? previewRules.join("\n\n") : `${baseBody}\n\n${previewRules.join("\n\n")}`; } /** * Convenience: model → complete CSS body (real rules + forced-preview * twins), ready to pass to {@link injectManagedInteractionStateCss}. */ export function serializeInteractionStatesModelWithPreviews( model: InteractionStatesModel, ): string { return rebuildCssWithPreviews(model); } /** Parse responsive `@media (max-width)` state rules from their own block. */ export function parseResponsiveInteractionStatesCss( css: string, ): ResponsiveInteractionStatesModel { const model: ResponsiveInteractionStatesModel = {}; const mediaRe = /@media\s*\(\s*max-width\s*:\s*(\d+(?:\.\d+)?)px\s*\)\s*\{/g; let match: RegExpExecArray | null; while ((match = mediaRe.exec(css)) !== null) { const maxWidthPx = Math.round(Number.parseFloat(match[1])); if (!Number.isFinite(maxWidthPx) || maxWidthPx <= 0) continue; const bodyStart = match.index + match[0].length; const body = extractCssBlock(css, bodyStart); if (body === null) continue; mediaRe.lastIndex = bodyStart + body.length + 1; // Responsive rules deliberately double the node attribute selector so // they beat a base state rule even if later editing moves/reinserts the // base managed block after this block. Collapse that deterministic // specificity boost before feeding the normal state parser. const parsed = parseInteractionStatesCss( body.replace( /(\[data-agent-native-node-id="(?:\\.|[^"\\])*"\])\1/g, "$1", ), ); if (Object.keys(parsed).length > 0) model[String(maxWidthPx)] = parsed; } return model; } /** * Serialize responsive state rules widest-first so every narrower matching * bucket appears later and wins, mirroring the normal breakpoint cascade. */ export function serializeResponsiveInteractionStatesModel( model: ResponsiveInteractionStatesModel, ): string { return Object.keys(model) .map((key) => Number.parseInt(key, 10)) .filter((bound) => Number.isFinite(bound) && bound > 0) .sort((a, b) => b - a) .flatMap((bound) => { const body = serializeResponsiveStateBucket(model[String(bound)] ?? {}); if (!body) return []; return [ `@media (max-width: ${bound}px) {\n${body .split("\n") .map((line) => ` ${line}`) .join("\n")}\n}`, ]; }) .join("\n\n"); } /** * Regenerate the forced-preview twin rules for a whole document from its * current real state rules, and write the refreshed managed block back. * Idempotent: re-running it just recomputes byte-identical twins (rebuilt * from the parsed real-rule model, not by appending onto whatever twins were * already present). This is the function phase-2 callers reach for after any * state-rule mutation (or to backfill twins for a document that already has * real rules but was authored before this mechanism existed) — it never * touches anything outside the 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; if (!block) { return ( html.slice(0, openMatch.index) + html.slice(closeEnd).replace(/^\n/, "") ); } return html.slice(0, openMatch.index) + block + html.slice(closeEnd); } } if (!block) return html; const headClose = /<\/head\s*>/gi; let close: RegExpExecArray | null = null; let match: RegExpExecArray | null; while ((match = headClose.exec(html)) !== null) close = match; const index = close?.index ?? -1; return index >= 0 ? html.slice(0, index) + block + "\n" + html.slice(index) : block + "\n" + html; } function extractCssBlock(css: string, start: number): string | null { let depth = 1; let index = start; while (index < css.length && depth > 0) { if (css[index] === "{") depth++; else if (css[index] === "}") depth--; index++; } return depth === 0 ? css.slice(start, index - 1) : null; } /** * Normalize a CSS property name to kebab-case (accepts either `fontSize` or * `font-size`). Local copy of the same normalization * `responsive-classes.ts`/`breakpoint-media.ts` apply, kept dependency-free * here since this module must stay a pure, standalone string transform. */ function normalizeCssPropertyName(property: string): string { if (property.startsWith("--")) return property; return property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); }