bake: a :scope child compound merges into the element anchor

`:scope > .card` describes the wrapper's only child, the accepted element
itself, so the lasting rule is the anchor with what the compound adds
(a class the anchor lacks, an attribute, a state), never bare `.card`,
which after the append would style every card on the page. A type in the
compound must be the anchor's own; an id anchor takes any. Pinned in the
rewrite and accept tests; contract updated.

Written with AI assistance (Claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-15 05:45:49 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent 695d1bd515
commit 4b3a0e932d
3 changed files with 92 additions and 14 deletions
+90 -12
View File
@@ -139,11 +139,81 @@ fn split_first_compound(s: &str) -> (String, String) {
(chars[..i].iter().collect(), chars[i..].iter().collect())
}
/// The simple selectors of one compound (`div.card[open]:hover` ->
/// `div`, `.card`, `[open]`, `:hover`): the leading type selector, if any,
/// and the rest as tokens. Brackets, parentheses and quotes keep their
/// contents together (`:not([hidden])`, `[data-x="a.b"]`).
fn compound_parts(compound: &str) -> (Option<String>, Vec<String>) {
let chars: Vec<char> = compound.chars().collect();
let mut i = 0;
let mut tag = String::new();
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
tag.push(chars[i]);
i += 1;
}
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
let mut quote: Option<char> = None;
while i < chars.len() {
let c = chars[i];
if let Some(q) = quote {
cur.push(c);
if c == q {
quote = None;
}
} else if c == '"' || c == '\'' {
quote = Some(c);
cur.push(c);
} else if c == '[' || c == '(' {
depth += 1;
cur.push(c);
} else if c == ']' || c == ')' {
depth -= 1;
cur.push(c);
} else if depth == 0 && (c == '.' || c == '#' || c == '[' || c == ':') && !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
cur.push(c);
} else {
cur.push(c);
}
i += 1;
}
if !cur.is_empty() {
tokens.push(cur);
}
(if tag.is_empty() { None } else { Some(tag) }, tokens)
}
/// The wrapper's only child is the element itself, so a `:scope > X`
/// rule describes that element: the lasting selector is the anchor with
/// whatever X adds (a class the anchor lacks, an attribute, a state), never
/// bare X, which would style every X on the page. A type in X must be the
/// anchor's own (an id anchor carries no type, so any is fine there).
fn anchor_with_child(anchor: &str, child: &str) -> Result<String, String> {
let (anchor_tag, anchor_tokens) = compound_parts(anchor);
let (child_tag, child_tokens) = compound_parts(child);
if let (Some(a), Some(c)) = (&anchor_tag, &child_tag) {
if !a.eq_ignore_ascii_case(c) {
return Err(format!("`:scope > {}` names a {} but the variant's root is a {}", child, c, a));
}
}
let mut out = anchor.to_string();
for token in child_tokens {
if !anchor_tokens.iter().any(|t| t == &token) {
out.push_str(&token);
}
}
Ok(out)
}
/// One selector out of a `:scope` (or `[data-impeccable-variant="N"]`)
/// prefixed rule, anchored on the element. A state on the wrapper
/// (`:scope:hover`, `:scope[open]`) lands on the element, which is the
/// wrapper's only child and takes its place after the unwrap. Err when
/// `:scope` survives or the rewrite has no meaning.
/// wrapper's only child and takes its place after the unwrap; a `:scope >`
/// child compound is that element too, so it merges into the anchor
/// instead of standing alone. Err when `:scope` survives or the rewrite
/// has no meaning.
pub fn rewrite_selector(selector: &str, anchor: &str) -> Result<String, String> {
let s = trim(selector).to_string();
let s = VARIANT_PREFIX_RE.replace(&s, ":scope").into_owned();
@@ -155,12 +225,13 @@ pub fn rewrite_selector(selector: &str, anchor: &str) -> Result<String, String>
format!("{}{}", anchor, state)
} else if let Some(child) = after_trim.strip_prefix('>') {
// `:scope > .x`, `:scope:hover > .x`: the wrapper's child is the
// element itself, so the wrapper's state is the element's.
// element itself, so the child compound merges into the anchor
// and the wrapper's state is the element's.
let (first, remainder) = split_first_compound(child.trim_start());
if first.is_empty() {
return Err(format!("selector has no child after :scope: {}", selector));
}
format!("{}{}{}", first, state, remainder)
format!("{}{}{}", anchor_with_child(anchor, &first)?, state, remainder)
} else if after_trim.starts_with(['+', '~']) {
return Err(format!("sibling combinator on :scope has no meaning after unwrap: {}", selector));
} else {
@@ -467,17 +538,24 @@ mod tests {
#[test]
fn scope_selectors_rewrite_onto_the_element() {
let a = "div.pricing-grid";
assert_eq!(rewrite_selector(":scope > .pricing-grid", a).unwrap(), ".pricing-grid");
assert_eq!(rewrite_selector(":scope > .pricing-grid .pricing-card", a).unwrap(), ".pricing-grid .pricing-card");
assert_eq!(rewrite_selector(":scope > .pricing-grid", a).unwrap(), "div.pricing-grid");
assert_eq!(rewrite_selector(":scope > .pricing-grid .pricing-card", a).unwrap(), "div.pricing-grid .pricing-card");
assert_eq!(rewrite_selector(":scope .pricing-card", a).unwrap(), "div.pricing-grid .pricing-card");
assert_eq!(rewrite_selector(":scope", a).unwrap(), "div.pricing-grid");
assert_eq!(rewrite_selector(":scope:hover > .pricing-grid", a).unwrap(), ".pricing-grid:hover");
assert_eq!(rewrite_selector(":scope:focus-within > .pricing-grid .card", a).unwrap(), ".pricing-grid:focus-within .card");
assert_eq!(rewrite_selector(":scope[open] > .pricing-grid > .card", a).unwrap(), ".pricing-grid[open] > .card");
assert_eq!(rewrite_selector(":scope:hover > .pricing-grid", a).unwrap(), "div.pricing-grid:hover");
assert_eq!(rewrite_selector(":scope:focus-within > .pricing-grid .card", a).unwrap(), "div.pricing-grid:focus-within .card");
assert_eq!(rewrite_selector(":scope[open] > .pricing-grid > .card", a).unwrap(), "div.pricing-grid[open] > .card");
assert_eq!(rewrite_selector(":scope:hover .card", a).unwrap(), "div.pricing-grid:hover .card");
assert_eq!(rewrite_selector(":scope:not([hidden])", a).unwrap(), "div.pricing-grid:not([hidden])");
assert!(rewrite_selector(":scope:hover >", a).is_err());
assert_eq!(rewrite_selector("[data-impeccable-variant=\"2\"] > .x", a).unwrap(), ".x");
assert_eq!(rewrite_selector("[data-impeccable-variant=\"2\"] > .x", a).unwrap(), "div.pricing-grid.x");
// The child compound is the element: a class it adds rides on the
// anchor, a type must be the anchor's own, an id anchor takes any.
assert_eq!(rewrite_selector(":scope > div.pricing-grid.wide[open]", a).unwrap(), "div.pricing-grid.wide[open]");
assert_eq!(rewrite_selector(":scope > div", a).unwrap(), "div.pricing-grid");
assert!(rewrite_selector(":scope > section.pricing-grid", a).is_err());
assert_eq!(rewrite_selector(":scope > section.pricing", "#pricing").unwrap(), "#pricing.pricing");
assert_eq!(rewrite_selector(":scope > .card:not([hidden])", a).unwrap(), "div.pricing-grid.card:not([hidden])");
assert!(rewrite_selector(":scope + .x", a).is_err());
assert!(rewrite_selector(".a :scope", a).is_err());
}
@@ -512,7 +590,7 @@ mod tests {
"#;
let (out, rules) = extract_variant_css(css, "2", "div.pricing-grid").unwrap();
assert_eq!(rules, 3, "{out}");
assert!(out.contains(".pricing-grid { gap: 32px; }"), "{out}");
assert!(out.contains("div.pricing-grid { gap: 32px; }"), "{out}");
assert!(out.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{out}");
assert!(out.contains("@media (max-width: 600px)"), "{out}");
assert!(out.contains("@keyframes rise"), "{out}");
@@ -537,7 +615,7 @@ mod tests {
assert_eq!(rules, 4, "{out}");
assert!(out.contains("@media (max-width: 600px)"), "{out}");
assert!(out.contains("gap: 12px"), "the variant's breakpoint survives: {out}");
assert!(out.contains(".pricing-grid:hover .card { border-color: #111; }"), "{out}");
assert!(out.contains("div.pricing-grid:hover .card { border-color: #111; }"), "{out}");
assert!(out.contains(".site-wide { color: red; }"), "{out}");
assert!(!out.contains("8px") && !out.contains("4px"), "the other variant's rules are gone: {out}");
assert!(!out.contains("data-impeccable"), "{out}");
+1 -1
View File
@@ -1300,7 +1300,7 @@ mod bake_tests {
let css = std::fs::read_to_string(dir.join("src/styles.css")).unwrap();
assert!(css.starts_with(".pricing-grid { display: grid; gap: 20px; }\n"), "existing rules untouched: {css}");
assert!(css.contains("/* impeccable generate ab12cd34: accepted variant 2 */"), "{css}");
assert!(css.contains(".pricing-grid { gap: 32px; }"), "{css}");
assert!(css.contains("div.pricing-grid { gap: 32px; }"), "{css}");
assert!(css.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{css}");
assert!(!css.contains("8px") && !css.contains("gap: 0"), "other variants dropped: {css}");
assert!(!css.contains(":scope") && !css.contains("data-impeccable"), "{css}");
+1 -1
View File
@@ -1686,7 +1686,7 @@ Order in `live-accept.mjs`: receipt check → find `impeccable-variants-start <i
- JSX: everything above wrapped in `<indent><div data-impeccable-carbonize="ID" style={{ display: "contents" }}>``</div>` with body indented 2 more, `<style …>{\`` / `\`}</style>`, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div.
Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in <file>. See reference/live.md "Required after accept".', bakeSkipped?}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`.
**Mechanical bake** (`bake.rs`): only on `--bake` (never by default and never on `--no-bake`; the generate lane's accept carbonizes exactly like plain live's, so the agent integrates the accepted variant per live.md), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:<reason>`) when: `--param-values` is non-empty; the accepted variant carries `data-impeccable-*` or `data-p-*` inside it; the preview CSS uses `var(--p-*)`, `data-p-*`, or `data-impeccable-params`; the variant's root is a component (`<PricingGrid>`, `<Card.Root>`: what it renders is unknown, and its `className` or `id` prop may never reach that element) or has neither an id nor a static class (`className={expr}`); a `:scope` cannot be rewritten (sibling combinators, `:scope` not at the front, nested `@scope`); the accepted variant declares no rule; or no destination stylesheet exists. The rewrite: the accepted `@scope ([data-impeccable-variant="N"])` block is flattened and every selector re-anchored on the root tag's selector (`#id`, else `tag.class.class`): `:scope > .x``.x`, `:scope .x``<anchor> .x`, `:scope:hover > .x``<anchor>:hover > .x`, bare `:scope``<anchor>`; Astro's `[data-impeccable-variant="N"] > .x` prefix the same way; nested `@media`/`@supports` inside the block keep their prelude; top-level `@keyframes`/`@font-face` are kept, other variants' blocks dropped. Destination: for `.jsx`/`.tsx` the `.css` file under the app root (skipping node_modules/.git/.impeccable/dist/build/coverage/framework caches, depth ≤ 6, `.min.css` and generated or git-ignored files excluded) with the most rules naming the anchor's id or classes, else the only `.css` file; for other files the page's own last `<style>` block when it has one, else the same search. The rules are **appended** under `/* impeccable generate <id>: accepted variant N */` (existing rules are never rewritten; a same-selector rule later in the cascade overrides declaration by declaration). The source is verified clean (`verifyAcceptedSource`) before anything is written; the stylesheet is written first, then the source with the variant unwrapped at the wrapper's indentation. Result `{handled:true, file, carbonize:false, baked:true, variant:'N', css:{file: rel|null (null = the page's own <style>), rules, anchor}, verify:{clean, findings}}`; the poll's completion for it is `complete`, so the session ends without `live-complete`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
**Mechanical bake** (`bake.rs`): only on `--bake` (never by default and never on `--no-bake`; the generate lane's accept carbonizes exactly like plain live's, so the agent integrates the accepted variant per live.md), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:<reason>`) when: `--param-values` is non-empty; the accepted variant carries `data-impeccable-*` or `data-p-*` inside it; the preview CSS uses `var(--p-*)`, `data-p-*`, or `data-impeccable-params`; the variant's root is a component (`<PricingGrid>`, `<Card.Root>`: what it renders is unknown, and its `className` or `id` prop may never reach that element) or has neither an id nor a static class (`className={expr}`); a `:scope` cannot be rewritten (sibling combinators, `:scope` not at the front, nested `@scope`); the accepted variant declares no rule; or no destination stylesheet exists. The rewrite: the accepted `@scope ([data-impeccable-variant="N"])` block is flattened and every selector re-anchored on the root tag's selector (`#id`, else `tag.class.class`): `:scope > .x``<anchor>` merged with `.x` (the wrapper's only child is the element itself, so the child compound rides on the anchor, a class the anchor already has once, a type only when it is the anchor's own; `#id` anchors take any type), `:scope .x``<anchor> .x`, `:scope:hover > .x``<anchor>.x:hover`, bare `:scope``<anchor>`; Astro's `[data-impeccable-variant="N"] > .x` prefix the same way; nested `@media`/`@supports` inside the block keep their prelude; top-level `@keyframes`/`@font-face` are kept, other variants' blocks dropped. Destination: for `.jsx`/`.tsx` the `.css` file under the app root (skipping node_modules/.git/.impeccable/dist/build/coverage/framework caches, depth ≤ 6, `.min.css` and generated or git-ignored files excluded) with the most rules naming the anchor's id or classes, else the only `.css` file; for other files the page's own last `<style>` block when it has one, else the same search. The rules are **appended** under `/* impeccable generate <id>: accepted variant N */` (existing rules are never rewritten; a same-selector rule later in the cascade overrides declaration by declaration). The source is verified clean (`verifyAcceptedSource`) before anything is written; the stylesheet is written first, then the source with the variant unwrapped at the wrapper's indentation. Result `{handled:true, file, carbonize:false, baked:true, variant:'N', css:{file: rel|null (null = the page's own <style>), rules, anchor}, verify:{clean, findings}}`; the poll's completion for it is `complete`, so the session ends without `live-complete`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`.
Receipt: on any `handled!==false` result write `accept-receipts/<id>.json` = `{id, operation:'accept'|'discard', variantId:'N'|null, result, completedAt}` (tmp+rename). Re-run with same op/variant → prior `result` + `{handled:true, alreadyApplied:true}`; different → `{handled:false, mode:'error', error:'accept_receipt_conflict', priorOperation, priorVariantId}`.