mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
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 <noreply@anthropic.com>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5.1
parent
0166cbb870
commit
71a3341289
+93
-7
@@ -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<String, Value>>) -> 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<String>) {
|
||||
let mut it = s.split('.');
|
||||
let tag = it.next().unwrap_or("").to_string();
|
||||
let mut classes: Vec<String> = 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<Regex> =
|
||||
|
||||
/// 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 `<style>` block
|
||||
/// when the file is HTML-like).
|
||||
/// accepted variant at the wrapper's indentation, `element` the descriptor
|
||||
/// the overlay journaled with the generate event (the anchor it saw and how
|
||||
/// many elements matched it), `source_after_unwrap` the source file with the
|
||||
/// variant unwrapped (to find its own `<style>` block when the file is
|
||||
/// HTML-like).
|
||||
pub fn plan(
|
||||
cwd: &str,
|
||||
target_file: &str,
|
||||
@@ -447,6 +498,7 @@ pub fn plan(
|
||||
css_lines: Option<&[String]>,
|
||||
restored: &[String],
|
||||
param_values: Option<&Map<String, Value>>,
|
||||
element: Option<&Map<String, Value>>,
|
||||
source_after_unwrap: &str,
|
||||
) -> Result<BakePlan, String> {
|
||||
if param_values.map(|p| !p.is_empty()).unwrap_or(false) {
|
||||
@@ -470,6 +522,7 @@ pub fn plan(
|
||||
),
|
||||
_ => "the variant's root tag has no id or static class to anchor selectors on".to_string(),
|
||||
})?;
|
||||
verify_anchor_unique(&anchor, element)?;
|
||||
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())?)
|
||||
@@ -635,22 +688,55 @@ mod tests {
|
||||
fn knobs_and_plumbing_refuse_the_bake() {
|
||||
let restored = vec!["<div className=\"pricing-grid\">".to_string(), "</div>".to_string()];
|
||||
let css = vec!["@scope ([data-impeccable-variant=\"1\"]) { :scope > .pricing-grid { gap: var(--p-gap, 8px); } }".to_string()];
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, None, "").unwrap_err();
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, None, None, "").unwrap_err();
|
||||
assert!(err.contains("knobs"), "{err}");
|
||||
let mut pv = Map::new();
|
||||
pv.insert("gap".into(), json!(1));
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, Some(&pv), "").unwrap_err();
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, Some(&pv), None, "").unwrap_err();
|
||||
assert!(err.contains("paramValues"), "{err}");
|
||||
let plumbing = vec!["<div className=\"pricing-grid\" data-impeccable-x=\"1\">".to_string()];
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &plumbing, None, "").unwrap_err();
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &plumbing, None, 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!["<PricingGrid className=\"pricing-grid\">".to_string(), "</PricingGrid>".to_string()];
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&plain), &component, None, "").unwrap_err();
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&plain), &component, None, None, "").unwrap_err();
|
||||
assert!(err.contains("component <PricingGrid>"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_anchor_bakes_only_when_the_page_showed_one_match() {
|
||||
let restored = vec!["<div className=\"pricing-grid featured\">".to_string(), "</div>".to_string()];
|
||||
let css = vec!["@scope ([data-impeccable-variant=\"1\"]) { :scope { gap: 8px; } }".to_string()];
|
||||
let attempt = |element: Option<Map<String, Value>>| {
|
||||
plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &restored, None, element.as_ref(), "").unwrap_err()
|
||||
};
|
||||
let seen = |anchor: &str, matches: Value| {
|
||||
let mut m = Map::new();
|
||||
m.insert("anchor".into(), json!(anchor));
|
||||
m.insert("anchorMatches".into(), matches);
|
||||
m
|
||||
};
|
||||
// Nothing to verify against: no descriptor, no count, another anchor.
|
||||
let err = attempt(None);
|
||||
assert!(err.contains("no element descriptor"), "{err}");
|
||||
let err = attempt(Some(seen("div.pricing-grid.featured", Value::Null)));
|
||||
assert!(err.contains("no anchor count"), "{err}");
|
||||
let err = attempt(Some(seen("div.pricing-grid.featured.open", json!(1))));
|
||||
assert!(err.contains("div.pricing-grid.featured.open") && err.contains("cannot be verified"), "{err}");
|
||||
// Siblings share the classes: the count says so.
|
||||
let err = attempt(Some(seen("div.featured.pricing-grid", json!(3))));
|
||||
assert!(err.contains("div.pricing-grid.featured matches 3 elements"), "{err}");
|
||||
// One match, the classes in the page's own order: the check passes
|
||||
// and the plan moves on to the stylesheet search.
|
||||
let err = attempt(Some(seen("div.featured.pricing-grid", json!(1))));
|
||||
assert!(err.contains("stylesheet"), "{err}");
|
||||
// An id anchor is compared as written.
|
||||
let by_id = vec!["<section id=\"pricing\" className=\"pricing\">".to_string(), "</section>".to_string()];
|
||||
let err = plan("/nonexistent", "src/App.jsx", true, "1", Some(&css), &by_id, None, Some(&seen("#pricing", json!(2))), "").unwrap_err();
|
||||
assert!(err.contains("#pricing matches 2 elements"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owning_stylesheet_is_the_one_naming_the_element() {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-bake-{}", std::process::id()));
|
||||
|
||||
@@ -49,6 +49,25 @@ Output (JSON):
|
||||
struct BakeRequest {
|
||||
cwd: String,
|
||||
session_id: String,
|
||||
/// The element descriptor the overlay journaled with the session's
|
||||
/// generate event: the anchor it saw and how many elements matched it.
|
||||
element: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// The `element` of the session's journaled generate event, if any.
|
||||
fn journaled_element(cwd: &str, env: &Env, id: &str) -> Option<Map<String, Value>> {
|
||||
let state = crate::session::create_live_session_store(cwd, env, Some(id)).read_state(id, true).ok()?;
|
||||
let journal = safe_read(&state.journal_path)?;
|
||||
journal
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.find_map(|entry| {
|
||||
let event = entry.get("event")?;
|
||||
if event.get("type").and_then(Value::as_str) != Some("generate") {
|
||||
return None;
|
||||
}
|
||||
event.get("element").and_then(Value::as_object).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// The two ways an HTML/JSX accept can end.
|
||||
@@ -411,7 +430,7 @@ fn accept_cli(args: &[String], io: &mut Io) -> i32 {
|
||||
// which is what makes the result read as designed rather than
|
||||
// appended.
|
||||
let bake = if !no_bake && bake_flag {
|
||||
Some(BakeRequest { cwd: cwd.clone(), session_id: id.clone() })
|
||||
Some(BakeRequest { cwd: cwd.clone(), session_id: id.clone(), element: journaled_element(&cwd, &env, &id) })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -787,6 +806,7 @@ fn handle_accept_unlocked(
|
||||
css_content.as_deref(),
|
||||
&restored,
|
||||
param_values,
|
||||
req.element.as_ref(),
|
||||
&source_after,
|
||||
);
|
||||
match planned {
|
||||
@@ -1254,7 +1274,13 @@ mod bake_tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// A project whose session journal says the overlay saw the anchor
|
||||
/// `div.pricing-grid` match exactly one element.
|
||||
fn project(tag: &str) -> PathBuf {
|
||||
project_with(tag, Some(json!({ "tagName": "div", "id": null, "classes": ["pricing-grid"], "anchor": "div.pricing-grid", "anchorMatches": 1 })))
|
||||
}
|
||||
|
||||
fn project_with(tag: &str, element: Option<Value>) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-accept-bake-{}-{}", tag, std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("src")).unwrap();
|
||||
@@ -1263,6 +1289,14 @@ mod bake_tests {
|
||||
std::fs::write(dir.join("src/styles.css"), ".pricing-grid { display: grid; gap: 20px; }\n.pricing-card { padding: 20px; }\n").unwrap();
|
||||
std::fs::write(dir.join("index.html"), "<html><body><div id=\"root\"></div></body></html>").unwrap();
|
||||
std::fs::write(dir.join("package.json"), "{\"name\":\"t\"}").unwrap();
|
||||
let env: Env = std::env::vars().collect();
|
||||
let mut generate = json!({ "type": "generate", "id": SESSION, "count": 3, "pageUrl": "/", "action": "bolder" });
|
||||
if let Some(element) = element {
|
||||
generate["element"] = element;
|
||||
}
|
||||
crate::session::create_live_session_store(&dir.to_string_lossy(), &env, Some(SESSION))
|
||||
.append_event(&generate)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
@@ -1345,6 +1379,26 @@ mod bake_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_anchor_the_page_showed_more_than_once_refuses_the_bake() {
|
||||
// Three elements matched the anchor when Go fired (three cards from
|
||||
// one JSX element, say): lasting rules on it would restyle them all.
|
||||
let dir = project_with("siblings", Some(json!({ "anchor": "div.pricing-grid", "anchorMatches": 3 })));
|
||||
let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]);
|
||||
assert_eq!(result["carbonize"], json!(true), "{result}");
|
||||
assert!(result["bakeSkipped"].as_str().unwrap().contains("div.pricing-grid matches 3 elements"), "{result}");
|
||||
assert!(std::fs::read_to_string(dir.join("src/App.jsx")).unwrap().contains("impeccable-carbonize-start"));
|
||||
assert!(!std::fs::read_to_string(dir.join("src/styles.css")).unwrap().contains("32px"));
|
||||
// Without the overlay's descriptor there is nothing to verify against.
|
||||
let dir2 = project_with("nodesc", None);
|
||||
let result = accept(&dir2, &["--id", SESSION, "--variant", "2", "--bake"]);
|
||||
assert_eq!(result["carbonize"], json!(true), "{result}");
|
||||
assert!(result["bakeSkipped"].as_str().unwrap().contains("no element descriptor"), "{result}");
|
||||
for d in [dir, dir2] {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knob_session_falls_back_to_the_carbonize_block_with_the_reason() {
|
||||
let dir = project("knobs");
|
||||
|
||||
@@ -1524,7 +1524,7 @@ Body JSON `{token, type, …}`. Order of checks: JSON parse → token → `type=
|
||||
VISUAL_ACTIONS (order): `impeccable, bolder, quieter, distill, polish, typeset, colorize, layout, adapt, animate, delight, overdrive` (labels Freeform, Bolder, …).
|
||||
|
||||
Browser payloads (exact fields):
|
||||
- `generate` (replace): `{type:'generate', id, action, freeformPrompt?, count, pageUrl: location.pathname, element: extractContext(el), comments?:[{x,y,text}], strokes?:[{points:[[x,y],…]}], clientSentAt, screenshotPath?}`. `extractContext` = `{tagName(lower), id|null, classes:[…], textContent (≤500), outerHTML (sanitized ≤10000), computedStyles:{'font-family','font-size','font-weight','line-height','color','background','background-color','padding','margin','display','position','gap','border-radius','box-shadow'}, cssCustomProperties:{--x:v}, parentContext:'<tag id="" class="">'|null, boundingRect:{width,height}}`. Without annotations the event is POSTed before screenshot capture; with annotations the PNG is first `POST /annotation`ed and `screenshotPath` (abs path returned) is added.
|
||||
- `generate` (replace): `{type:'generate', id, action, freeformPrompt?, count, pageUrl: location.pathname, element: extractContext(el), comments?:[{x,y,text}], strokes?:[{points:[[x,y],…]}], clientSentAt, screenshotPath?}`. `extractContext` = `{tagName(lower), id|null, classes:[…], anchor ('#id' when the id is a CSS identifier, else 'tag.class…' over the classes that are, else null: the selector a mechanical bake would anchor lasting rules on), anchorMatches (how many elements matched `anchor` when the event was built; null without one), textContent (≤500), outerHTML (sanitized ≤10000), computedStyles:{'font-family','font-size','font-weight','line-height','color','background','background-color','padding','margin','display','position','gap','border-radius','box-shadow'}, cssCustomProperties:{--x:v}, parentContext:'<tag id="" class="">'|null, boundingRect:{width,height}}`. Without annotations the event is POSTed before screenshot capture; with annotations the PNG is first `POST /annotation`ed and `screenshotPath` (abs path returned) is added.
|
||||
- `generate` (insert): `{type:'generate', mode:'insert', id, count, pageUrl, insert:{position, anchor: extractContext(anchor)}, placeholder:{width,height}, freeformPrompt?, comments?, strokes?, clientSentAt}`.
|
||||
- `checkpoint`: `{type:'checkpoint', id, revision (monotone per browser, persisted in localStorage), revisionDomain:'browser', owner:<8-hex browser owner id>, phase: state.toLowerCase(), reason, pageUrl, expectedVariants, arrivedVariants, visibleVariant, sourceFile?, previewFile?, previewMode?, paramValues:{…}}`. Steer checkpoints: `{type:'checkpoint', id, revision, revisionDomain:'browser', owner, phase:'steer', reason, pageUrl, …extra}`. Reasons: `generate_started, variants_progress, variants_ready, browser_resumed, browser_resumed_deferred_wrapper, browser_resumed_svelte_component, param_changed, variant_anchor_missing, component_preview_anchor_missing, steer_input_focused, steer_submitted, steer_send_failed, steer_done, steer_error`. Only `variants_progress|variants_ready` count as publication progress.
|
||||
- `accept`: `{type:'accept', id, variantId: String(n), pageUrl, clientSentAt, paramValues?}`.
|
||||
@@ -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` → `<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`.
|
||||
**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}`); the anchor cannot be shown to match the accepted element alone (the journaled generate event's `element.anchor` must be the same selector, the id or the tag and class set, and its `element.anchorMatches` must be 1: `<anchor> matches N elements on the page; a lasting rule on it would restyle them all`, a different anchor, and a missing descriptor or count each name themselves); 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}`.
|
||||
|
||||
|
||||
@@ -991,9 +991,20 @@
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
||||
// many elements it matches right now: the bake refuses anything but one,
|
||||
// since its rules would restyle every match, not just this element.
|
||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
||||
const anchor = el.id && cssIdent(el.id)
|
||||
? '#' + el.id
|
||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
||||
let anchorMatches = null;
|
||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
||||
return {
|
||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||
classes: [...el.classList],
|
||||
anchor, anchorMatches,
|
||||
textContent: (el.textContent || '').slice(0, 500),
|
||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||
computedStyles: {
|
||||
|
||||
@@ -17,6 +17,13 @@ describe('live-browser source contracts', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('describes the picked element with the anchor a bake would use and its match count', () => {
|
||||
const body = SOURCE.match(/function extractContext\(el\) \{[\s\S]*?\n \}/)?.[0];
|
||||
assert.ok(body);
|
||||
assert.match(body, /anchorMatches = document\.querySelectorAll\(anchor\)\.length/);
|
||||
assert.match(body, /anchor, anchorMatches,/);
|
||||
});
|
||||
|
||||
for (const annotated of [false, true]) {
|
||||
for (const outcome of ['created', 'failed', 'superseded']) {
|
||||
it(`${annotated ? 'annotated' : 'plain'} generation checkpoints only its acknowledged current session (${outcome})`, async () => {
|
||||
|
||||
Reference in New Issue
Block a user