/**
* 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()}`);
}