diff --git a/crates/live/src/bake.rs b/crates/live/src/bake.rs index 305b3b61c..3ed98ae57 100644 --- a/crates/live/src/bake.rs +++ b/crates/live/src/bake.rs @@ -49,19 +49,34 @@ static SCOPE_PRELUDE_RE: Lazy = static VARIANT_PREFIX_RE: Lazy = Lazy::new(|| Regex::new(r#"^\[data-impeccable-variant\s*=\s*["']?\d+["']?\]"#).unwrap()); -/// The variant's root tag, as `#id`, `tag.class.class`, or `.class.class` -/// when the root is a JSX component (``, ``) whose -/// name is no element in the rendered page: the selector every `:scope` -/// rule is rewritten against. None when neither an id nor a static class -/// is on the tag (a JSX expression, a bare `
`). +/// The variant's root tag as written in source: `div`, `my-card`, +/// `PricingGrid`, `Card.Root`. +pub fn root_tag(restored: &[String]) -> Option { + let text = restored.join("\n"); + ROOT_TAG_RE.captures(&text).map(|c| c[1].to_string()) +} + +/// Whether a root tag names an element of the rendered page: a lowercase +/// name (custom elements included). A component (`PricingGrid`, +/// `Card.Root`) renders whatever it likes, and its `className` or `id` +/// prop may never reach that element. +pub fn is_element_tag(tag: &str) -> bool { + tag.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) && !tag.contains('.') +} + +/// The variant's root element, as `#id` or `tag.class.class`: the selector +/// every `:scope` rule is rewritten against. None when the root is a +/// component (what it renders is unknown, so nothing mechanical can anchor +/// on it) or when neither an id nor a static class is on the tag (a JSX +/// expression, a bare `
`). pub fn element_anchor(restored: &[String]) -> Option { let text = restored.join("\n"); let caps = ROOT_TAG_RE.captures(&text)?; let raw_tag = &caps[1]; - // A component renders some element the page decides; only a lowercase - // name is an element (custom elements included). - let is_element = raw_tag.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) && !raw_tag.contains('.'); - let tag = if is_element { raw_tag.to_ascii_lowercase() } else { String::new() }; + if !is_element_tag(raw_tag) { + return None; + } + let tag = raw_tag.to_ascii_lowercase(); let attrs = caps.get(2).map(|m| m.as_str()).unwrap_or(""); let mut id: Option = None; let mut classes: Vec = Vec::new(); @@ -371,7 +386,13 @@ pub fn plan( if css.contains("var(--p-") || css.contains("data-p-") || css.contains("data-impeccable-params") { return Err("the preview CSS is authored against knobs".into()); } - let anchor = element_anchor(restored).ok_or_else(|| "the variant's root tag has no id or static class to anchor selectors on".to_string())?; + let anchor = element_anchor(restored).ok_or_else(|| match root_tag(restored) { + Some(tag) if !is_element_tag(&tag) => format!( + "the variant's root is the component <{}>; what it renders is unknown, so no selector can anchor on it", + tag + ), + _ => "the variant's root tag has no id or static class to anchor selectors on".to_string(), + })?; let (rules_css, rules) = extract_variant_css(&css, variant_num, &anchor)?; let css_file = if is_jsx { Some(find_owning_stylesheet(cwd, &anchor).ok_or_else(|| "no stylesheet under the app root names the element".to_string())?) @@ -467,12 +488,14 @@ mod tests { assert_eq!(element_anchor(&["
".into()]), Some("#pricing".into())); assert_eq!(element_anchor(&["
".into()]), None); assert_eq!(element_anchor(&["

Hi

".into()]), Some("h1.hero-heading".into())); - // A JSX component root is no element in the page: its classes alone anchor the rules. - assert_eq!(element_anchor(&["".into()]), Some(".pricing-grid.wide".into())); - assert_eq!(element_anchor(&["".into()]), Some(".card".into())); - assert_eq!(element_anchor(&["".into()]), Some("#pricing".into())); - assert_eq!(element_anchor(&["".into()]), None); + // A component root renders an unknown element: its className or id + // prop may never reach it, so nothing anchors on it. + assert_eq!(element_anchor(&["".into()]), None); + assert_eq!(element_anchor(&["".into()]), None); + assert_eq!(element_anchor(&["".into()]), None); assert_eq!(element_anchor(&["".into()]), Some("my-card.card".into())); + assert_eq!(root_tag(&["".into()]), Some("Card.Root".into())); + assert!(is_element_tag("my-card") && !is_element_tag("PricingGrid") && !is_element_tag("Card.Root")); } #[test] @@ -533,6 +556,11 @@ mod tests { let plumbing = vec!["
".to_string()]; let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &plumbing, None, "").unwrap_err(); assert!(err.contains("plumbing"), "{err}"); + // A component root: its className prop may never reach the rendered element. + let plain = vec!["@scope ([data-impeccable-variant=\"1\"]) { :scope { gap: 8px; } }".to_string()]; + let component = vec!["".to_string(), "".to_string()]; + let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&plain), &component, None, "").unwrap_err(); + assert!(err.contains("component "), "{err}"); } #[test] diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 8aba082b8..20bea0e10 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1686,7 +1686,7 @@ Order in `live-accept.mjs`: receipt check → find `impeccable-variants-start
` … `
` with body indented 2 more, ``, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div. Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in . See reference/live.md "Required after accept".', bakeSkipped?}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`. -**Mechanical bake** (`bake.rs`): for a session whose snapshot carries `origin:'agent'` (a Go fired by `live-generate`) or on `--bake` (never on `--no-bake`; plain live sessions are untouched), a knob-free HTML/JSX accept is made permanent instead of leaving the carbonize block. Refused (falls back to the carbonize block, with `bakeSkipped:`) 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 tag 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` → ` .x`, `:scope:hover > .x` → `:hover > .x`, bare `:scope` → ``; 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 `