From 71a3341289b86b7b628dd40d5d69c1e686a181b0 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Mon, 14 Sep 2026 23:47:47 +0500 Subject: [PATCH] bake: the anchor must have matched one element on the page The lasting rules a bake appends apply to every element the anchor matches, so a `tag.class` anchor shared by siblings (three cards from one JSX element, say) restyled all of them, not the element the user picked. The overlay now journals, with the generate event, the anchor it would bake on (`element.anchor`: the id, else the tag with its classes) and how many elements matched it when Go fired (`element.anchorMatches`). The planner bakes only when the source anchor is that same selector and the count is one; otherwise it leaves the carbonize block with the count in `bakeSkipped`, and the agent integrates the variant by hand. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5.1 --- crates/live/src/bake.rs | 100 +++++++++++++++++++++++++++-- crates/live/src/live_accept.rs | 56 +++++++++++++++- docs/CLI-CONTRACT.md | 4 +- skill/scripts/live-browser.js | 11 ++++ tests/live-browser-source.test.mjs | 7 ++ 5 files changed, 168 insertions(+), 10 deletions(-) diff --git a/crates/live/src/bake.rs b/crates/live/src/bake.rs index 954940b55..ce1be4ae0 100644 --- a/crates/live/src/bake.rs +++ b/crates/live/src/bake.rs @@ -109,6 +109,55 @@ fn is_css_ident(s: &str) -> bool { && !s.starts_with(|c: char| c.is_ascii_digit()) } +/// The lasting rules apply to every element the anchor matches, so a bake is +/// only right when that is the accepted element alone. The overlay counted +/// the anchor's matches on the page when Go fired (`element.anchor` and +/// `element.anchorMatches` on the generate event, for the anchor it built +/// from the element's own id or tag and classes); the source anchor must be +/// that same selector, and the count must be one. +fn verify_anchor_unique(anchor: &str, element: Option<&Map>) -> Result<(), String> { + let Some(element) = element else { + return Err(format!( + "{} cannot be verified unique on the page: the session's generate event carries no element descriptor", + anchor + )); + }; + let page_anchor = element.get("anchor").and_then(Value::as_str); + let matches = element.get("anchorMatches").and_then(Value::as_i64); + match (page_anchor, matches) { + (Some(page), Some(1)) if same_anchor(page, anchor) => Ok(()), + (Some(page), Some(n)) if same_anchor(page, anchor) => Err(format!( + "{} matches {} elements on the page; a lasting rule on it would restyle them all", + anchor, n + )), + (Some(page), Some(_)) => Err(format!( + "the element on the page is {} while the source anchors {}; the anchor cannot be verified unique", + page, anchor + )), + _ => Err(format!( + "{} cannot be verified unique on the page: the generate event has no anchor count", + anchor + )), + } +} + +/// `#id` anchors match exactly; `tag.class…` anchors match on the tag and +/// the class set, whatever order the two sides list the classes in. +fn same_anchor(a: &str, b: &str) -> bool { + if a.starts_with('#') || b.starts_with('#') { + return a == b; + } + let parts = |s: &str| -> (String, Vec) { + let mut it = s.split('.'); + let tag = it.next().unwrap_or("").to_string(); + let mut classes: Vec = it.map(String::from).collect(); + classes.sort(); + classes.dedup(); + (tag, classes) + }; + parts(a) == parts(b) +} + /// The first compound selector of `s` and what follows it (the following /// combinator or whitespace included), honouring brackets, parens, and /// quotes. `(s, "")` when there is no combinator. @@ -436,9 +485,11 @@ static HTML_STYLE_BLOCK_RE: Lazy = /// Plan the bake, or say why it is not mechanical. `css_lines` is the whole /// preview stylesheet (JSX template wrap already stripped), `restored` the -/// accepted variant at the wrapper's indentation, `source_after_unwrap` the -/// source file with the variant unwrapped (to find its own ``, `{/* … */}` 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`): 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:`) 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 (``, ``: 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` → `` 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` → ` .x`, `:scope:hover > .x` → `.x:hover`, 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 `