fix(web): design-mode requests stop naming levers that do nothing - #67
Conversation
Two design-mode change requests in a row cost more time than making the edits by hand, for two separate reasons. A css bullet named a Tailwind utility by scanning the element's class list, which only proves a prefix-matching class is present — not that it is what the browser resolved the property from. On the composer's runtime-mode chip that produced "padding-inline: 8px -> 4px — change `px-2.5` -> `px-1`", in which 8px and px-2.5 (10px) already contradict each other: a ComposerShell.css rule outranked the utility, so editing the class was a silent no-op. The new cssOrigin.ts proves the lever empirically, by removing the class and re-measuring while the element shows its original cascade. When the utility does not win, the bullet names the rule that does and the file it lives in (recovered from Vite's data-vite-dev-id, the only place an injected <style> keeps its authored filename) and drops the utility suggestion entirely. The other request pointed at ComposerControl.tsx:135 in a 71-line file — a real position in the module Vite served, not in the source. react-grab already flags this via StackFrame.isSymbolicated and the fork was discarding it. Locations from an unsymbolicated frame are now rejected, which costs the line but not the file: data-t3-source-file keeps the authored path and data-t3-component carries the fiber's component name, so the request reads "Rendered by `<ComposerSelectControl>` in ... (line not resolvable)". Also drops the NO_PREVIEW guardrail's claim that "The Forge verifies the changes automatically". True upstream, false here — client/verifier.ts was never vendored into this fork, and the sentence told the agent to stop looking exactly where a no-op edit would have surfaced. Not covered by tests: the probe and CSSOM walk need a live DOM, which this repo's test setup does not provide. The pure ranking/classification logic is tested; the probe is verified by typecheck and the engine bundle build only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Stale comment
Thermo-nuclear code quality review — REQUEST_CHANGES
Intent is right: empirical utility probing and rejecting unsymbolicated lines are the correct accuracy fixes, and the guardrail honesty change is clean. Approval fails on type-contract murk and spaghetti growth in the request builder —
Partial<DesignSourceResult>erases the real wire shapes, and origin probing was bolted intobuildChangeRequestWithElementsas a second loop + optional Map lookup with a deadfindExistingUtilityfallback.
cssOrigin.tsas a module is the right extraction; simplify its culprit API to “one rule” and make the resolver boundary a real union. No file crosses 1k lines.Findings below are ordered by structural severity.
Sent by Cursor Automation: Thermo-nuclear PR review
| afterCss: isKeyword ? draft.value : afterCss.get(prop)!, | ||
| }) | ||
| } | ||
| // t3-fork: still inside `compare(el, true)` — the element is showing its ORIGINAL |
There was a problem hiding this comment.
important: Origin probing tangled into an already busy measurement flow.
The timing constraint is real (probe only while compare(el, true) shows the original cascade). That does not require owning the probe loop, an origins Map, a second className derivation, and a later optional lookup inside buildChangeRequestWithElements.
This adds another special-case branch into an already busy flow. Move “probe while original cascade is showing” behind its own abstraction — e.g. buildCssChanges(el, collapsed, theme, tokens) called inside the compare window — so this function stays “measure → build → restore” and the fork policy lives in one place. Right now the fork logic is split across the try-block, the item loop, ChangeItem.origin, and renderMarkdown.
Review feedback, all of it fair:
- `describeResolvedSource` returned `Partial<DesignSourceResult> &
{ componentName?: string }`, which permitted `{ line }` with no file and
made `componentName?` redundant. Split into discrete `DesignSourceResult`
and `DesignSourceHint` shapes behind a `ResolvedDesignSource` union, with
`line`/`column` typed `never` on the hint arm — a hint carrying a position
is the exact bug this path exists to prevent, and it now fails to compile
rather than resting on a comment. `DesignSourceResolver`'s local
`ResolvedPayload` alias is gone in favour of the shared type.
- `beforeUtility` had a `findExistingUtility` fallback for an "unprobed
property" case that cannot occur: every property reaching the item loop was
probed above under the same no-op skip. Dropped, along with the comment
documenting the imaginary case.
- `DeclarationOrigin.rules` was an ordered array whose only consumer took the
last element. It is now `culprit: OriginRule | null`, and the ranking
machinery collapses to `pickCulprit` with an `outranks` comparator.
- The twin validators either side of the page-shared global now name each
other. The duplication is deliberate — each side must hold on its own — but
nothing was pointing that out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed four of the five in d113a27; pushing back on one. blocker — important — dead important — ranked array for a single hint. Correct. note — twin validators drift. Fair. No shared package fits (the whole point is that each side of the page-shared global validates independently), so each twin now names the other by path with a line on why the duplication is deliberate. important — extract |
…r of `Lint fork-owned code` caught it: taking out the dead `findExistingUtility` fallback orphaned the `className` derivation that fed it. The compare window already derives the same value as `probeClassName` for the probe, which was the duplicate derivation review flagged — now there is one. Committed with --no-verify: the only file here lives under engine/vendor/, which the fork fmt-ignores, so the pre-commit `vp fmt` hook exits non-zero on an empty target list. Lint, typecheck, and the guard suite were run by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review — head
|
Review found both headline mechanisms were dead code in the environments this
targets. Verified each against the installed dist and the CSSOM spec before
changing anything.
Symbolication gate never fired. react-grab 0.1.44 / bippy 0.5.41 symbolicate as
`return mapped ? {...frame, isSymbolicated: true} : frame` — a FAILED frame comes
back untouched with no flag at all, and there is no `isSymbolicated: false`
anywhere in either dist. Testing `!== false` therefore read every failure as a
success and passed generated coordinates straight through. Now `=== true`, and
the reporting frame is paired by position first, because the context's filePath
is normalized while a frame's fileName is a raw served URL — the old string
compare missed precisely in the failure case and fell back to frames[0], which
is not necessarily the reporting frame. Unidentifiable frame now fails closed.
The old tests passed only because their fixtures hand-wrote a shape the library
never emits; they now use the real one.
CSSOM walk collected nothing. Since CSS Nesting, CSSStyleRule HAS a `cssRules`
list — empty, but an object, so truthy. `if (nested)` took the grouping branch
for every ordinary rule and `continue`d past its own declarations, so `culprit`
was always null and the bullet silently fell back to the utility suggestion.
Style rules are now handled first and then descended into. Separately,
`matchMedia` never throws, so the `try/catch` meant to let `@supports` and
`@container` through was dead and those blocks were dropped instead; only
CSSMediaRule is condition-gated now.
Also from review:
- The probe cannot decide a TIE. `px-2` over `[data-chip]{padding-inline:8px}`
moves nothing on removal, and "inert" and "wins but shadowed" are
indistinguishable. Reported as ambiguous and names BOTH instead of confidently
naming the loser — the same defect class this PR exists to kill, inverted.
- Ranking is layer-aware: unlayered beats layered above specificity. Without it
the named culprit could itself be a cascade loser.
- `isBareClassSelector` let unescaped `.` and `:` into its character class, so
`.a.b` and `.chip:hover` were dismissed as utilities, and every single-class
rule was dropped — hiding the likeliest culprit in plain-CSS projects. Now
narrowed to "a lone class the element itself carries".
- Phrasing matches what was established: `overrides` / `ambiguous` / `plain` /
`inline`. The "outranks your utility classes" claim is no longer printed when
no probe ran, and an inline style is named as itself.
- Hint results get the retry TTL; caching them as successes pinned a transient
symbolication failure for the element's lifetime.
- One CSSOM walk per element for all changed properties, `beforeCss` reused as
the probe baseline, cheap rejects before CSSOM reads, memoised matchMedia.
- data-t3-component now overwrites (HMR renames) and is cleared when a real tag
lands; the hint path re-checks dcSource after its await; `Rendered by` only
renders when no location resolved. Attribute constants imported, not literal.
- Vendor README lists these edits and a guard pins the NO_PREVIEW wording, so a
faithful Forge re-sync cannot quietly restore the false verifier claim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This was a genuinely excellent review — it found that both headline mechanisms were dead code, which I'd missed because my tests asserted against shapes the real APIs never produce. Fixed in d857afc. I verified each blocker independently rather than taking them on trust; all three reproduce. Blockers1. Symbolication gate never fired. Confirmed against the installed dist. You were also right that my "older builds omit the flag" grace was exactly inverted — this build omits it on failure, so the grace was defeating the gate rather than protecting anything. I've documented the consequence explicitly: hosts emitting unflagged frames now lose locations, which is the safe direction. Frame pairing rebuilt too: position ( My tests passed only because the fixtures hand-wrote 2. 3. Should fix — all applied5 is the sharpest catch: the probe genuinely cannot decide a tie, and demoting on 6 — narrowed to "a lone class the element itself carries", which keeps 9, 10, 12, 13 applied: overwrite-not-first-write for 11 (bundle-graph scan) I've left — it's a pre-existing gap that Verification: 245 fork guards, 100 desktop preview tests, The standing caveat is unchanged and now matters more: the probe and CSSOM walk still have no DOM test, which is exactly why blockers 2 and 3 survived to review. Everything above is verified by inspection and against the dist, not by executing the walk. It wants one live design-mode send before this is trusted. |
Follow-up — verified
|
… culprits Two accuracy gaps survived the live-DOM verification of the previous fix. The removal probe called the PR's own motivating case ambiguous: px-2.5 declares 10px, the ComposerShell.css rule declares 8px, and removing the class moves nothing either way — so the bullet claimed both "declare this at the same value", which is false there. A tie now gets a second probe: the utility's own declared value, read from its rule, is applied inline (!important) and re-measured. Applying rather than string-comparing is what makes calc(var(--spacing) * 2.5) comparable to a measured 8px. A declared value that computes to something other than the measured one means the utility provably lost, and the bullet carries the confident overrides claim again; only a genuine same-value tie stays ambiguous. The tautology filter dismissed every single-class selector the element carries, which silenced the likeliest culprit in plain-CSS guest projects — .composer-chip on an element with that class — the exact shape its own comment said must survive. The exclusion is now exactly one selector: the probed utility's own single-class rule. Other carried classes stay nameable; a competing utility on the element is a finding, not a tautology. Verified by executing the bundled module in headless Chromium: the motivating case decides as overridden, a genuine calc-vs-literal tie stays ambiguous and names the backup rule, .plainchip is named again, a winning utility still probes as utilityWins, and every probe restores the element's style attribute. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both follow-up findings fixed in
|
| fixture | result |
|---|---|
px-2.5 = calc(var(--spacing)*2.5) (10px, layered) vs unlayered rule at 8px |
ambiguous: false, culprit named → renders overrides ✓ |
mt-3 = calc 12px vs rule at literal 12px (true tie) |
ambiguous: true, backup rule named ✓ |
| utility genuinely controlling its property | utilityWins: true ✓ |
element carrying .plainchip, rule in app.css |
culprit .plainchip @ app.css ✓ |
| all cases | computed value and style attribute fully restored after probing ✓ |
Checks: 244 fork guard tests, engine island tsc, apps/web typecheck, .fork/lint-owned.mjs (142 files) — all clean. The guard count went 245 → 244 because the five isTautologicalSelector tests consolidated into four singleClassName tests. Manifest intent updated to describe the two-probe tie rule and the narrowed exclusion.
Fix and verification by Claude Fable 5, harness: Claude Code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
REQUEST_CHANGES — thermo-nuclear code quality review
The intent is right: empirical class probes, a hard symbolication gate, and dropping the false Forge-verifier claim. Prior review debt on Partial<>, the dead findExistingUtility fallback, array-shaped culprits, and twin-validator comments is largely cleaned up.
What remains is structural. DeclarationOrigin is still independent-boolean soup that request.ts re-derives into a discriminated union, and the unmapped cases fall back into the exact “change utility X → Y” path this PR exists to kill. That is a code-judo miss with a real accuracy hole (ambiguous / utility-lost without a culprit still names a lever). Origin probing is also still a fat fork block inside buildChangeRequestWithElements rather than one helper that owns probe → item → phrasing.
No file crosses 1k lines; cssOrigin.ts as a dedicated module is the right layer. Do not approve until the origin model is exhaustive and the request builder stops re-encoding flags into sentences.
Prior threads: Partial model / dead fallback / array culprits / twin-validator note → addressed. Probe tangled in buildChangeRequestWithElements → still open.
Sent by Cursor Automation: Thermo-nuclear PR review
| export interface DeclarationOrigin { | ||
| /** The probe proved `utilityClass` is what resolves this property. */ | ||
| utilityWins: boolean; | ||
| /** The probes could not tell: removing the class did not move the value, and the utility's | ||
| * own declared value (where a rule for it was found) computes to the measured one — at least | ||
| * two declarations genuinely carry the same value, and which wins was not established. | ||
| * Callers must not claim the utility is overridden on this basis. A tie whose declared value | ||
| * DIFFERS from the measured one is not ambiguous: the utility provably lost, and the culprit | ||
| * carries the overridden claim. */ | ||
| ambiguous: boolean; | ||
| /** The probed class, echoed back so callers need not re-derive it. Null when no class on the | ||
| * element looked relevant — in which case no probe ran and `utilityWins` is meaningless. */ | ||
| utilityClass: string | null; | ||
| /** The element's own inline style declares this property, which outranks every stylesheet | ||
| * rule below. Named separately because "edit that rule" is the wrong instruction for it. */ | ||
| inlineStyle: boolean; | ||
| /** The rule worth naming, best-effort. Null when nothing could be named. */ | ||
| culprit: OriginRule | null; | ||
| } |
There was a problem hiding this comment.
blocker (code-judo / types): DeclarationOrigin is boolean soup (utilityWins / ambiguous / inlineStyle / nullable culprit) while ChangeItem.origin is already a discriminated union — then request.ts re-derives the union from flags. That dual model is how you get illegal combinations and silent fallthroughs.
Code-judo: make resolveDeclarationOrigins return the union the markdown path already wants, e.g. utility | inline | overrides | ambiguous | plain | unresolved, with utilityClass / culprit only on the arms that have them. Delete the remap. Why keep two representations of one decision?
There was a problem hiding this comment.
Declining the union refactor for this PR. The point is fair — the flag record can express states the union can't — but the concrete harm it enabled (verdicts dropped when no culprit was nameable) is fixed in 4543bdc by making the flag→union mapping exhaustive over the probe's verdicts, and the one flag combination that was genuinely illegal (a 'lost' verdict from a probe that never ran) is now unrepresentable at the source. Collapsing DeclarationOrigin into the wire union would churn cssOrigin.ts and its 17 guard tests for shape alone; noting it as follow-up material rather than growing this diff.
| beforeUtility: origin.utilityWins || origin.ambiguous ? origin.utilityClass : null, | ||
| afterUtility: suggestion?.utility ?? null, | ||
| tokenExact: suggestion?.tokenExact ?? false, | ||
| } | ||
| // The phrasing has to match what was actually established. Only a probe that MOVED the | ||
| // value licenses "the utility is overridden"; a tie licenses "both declare this"; no | ||
| // probe at all licenses nothing beyond naming the rule. | ||
| if (origin.inlineStyle) { | ||
| item.origin = { kind: 'inline' } | ||
| } else if (origin.culprit) { | ||
| item.origin = { | ||
| kind: origin.ambiguous ? 'ambiguous' : origin.utilityClass === null ? 'plain' : 'overrides', | ||
| selectorText: origin.culprit.selectorText, | ||
| stylesheet: origin.culprit.stylesheet, | ||
| } | ||
| } |
There was a problem hiding this comment.
blocker (structural): This mapping still violates the PR’s own claim. beforeUtility is set when utilityWins || ambiguous, but origin is only set for inlineStyle or when culprit exists. So ambiguous && !culprit (and the “utility lost via value probe but no nameable rule” case) leave origin unset and fall through to change \${beforeUtility}` → …/add …` — naming a lever the probe already proved does not uniquely win.
If the class did not move the value, the utility-suggestion arm must be unreachable. Exhaustive switch on a real origin union; no default that pretends the class is the edit surface.
There was a problem hiding this comment.
Confirmed real — both culprit-less paths were reachable whenever the winning/tying declaration lives in a cross-origin sheet (the CSSOM walk skips those by design). Fixed in 4543bdc: ChangeItem.origin gains ambiguous-unnamed / overrides-unnamed arms, the builder maps every probe verdict (with resolveDeclarationOrigins now nulling utilityClass when the removal probe never actually ran, so a non-null class always means a verdict), and the renderer states the tie or the untraceable winner instead of falling through to change/add.
| el.style.setProperty('transition', 'none') | ||
|
|
||
| let raw: Map<string, { beforeCss: string; afterCss: string }> | ||
| // t3-fork: collapsed here rather than at the bullet loop below, because origin probing |
There was a problem hiding this comment.
important (spaghetti): Prior thread still stands. The timing constraint (probe only under compare(el, true)) does not require owning collapse, an origins Map, className re-derivation, non-null origins.get(property)!, flag→union remap, and later renderMarkdown kind chains inside buildChangeRequestWithElements.
This adds another special-case branch into an already busy flow. Extract one helper called inside the compare window — e.g. buildCssChangeItems(el, collapsed, theme, tokens) — so this function stays measure → build → restore and fork policy lives in one place. Right now the same decision is split across try-block, item loop, ChangeItem.origin, and markdown.
There was a problem hiding this comment.
Declining the extraction. buildChangeRequestWithElements is vendored Forge code (engine/vendor/, kept close to upstream with t3-fork edit markers per vendor/README.md); restructuring its measure→build→restore body into new helpers maximizes drift against the vendor for a readability gain the fork's policy already trades away. The timing constraint the inline placement encodes (probe only while compare(el, true) shows the original cascade) is load-bearing and documented at the site.
…stion The probe's verdict only reached the request when the winning (or tying) rule could be named. When the culprit lives where CSSOM cannot follow — a cross-origin sheet throws on access — the verdict was dropped on the floor: an undecided tie fell through to the confident "change `px-2.5` → `px-1`", and a provably-lost utility fell through to "add `px-1`", suggesting a class in the same losing layer. Both are the exact no-op edit this probe exists to prevent (PR #67 review). ChangeItem.origin gains culprit-less arms (`ambiguous-unnamed`, `overrides-unnamed`) so the verdict ships even when the rule has no name, and the renderer states what was established — a tie to check, or an untraceable winner — instead of naming a lever. resolveDeclarationOrigins now also nulls utilityClass when the removal probe never exercised the class, so a non-null class in a verdict always means the probe ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
If the tool already knows an element has no code location, letting the user tune sliders whose send could only ship an anonymous selector is a lying affordance — Noey's call: disable the sidebar inputs and say why. Snapshots gain a per-element sourceState (protocol v5): `resolved` when a tag, source file, or component name exists (component/file-only context is real — PR #67's "Rendered by" line), `pending` while no native-source attempt has settled (stays editable; no flicker), and `unresolved` when an attempt SETTLED with none of the three. nativeSource keeps a settled-untagged ledger the snapshot reads live, and promoteSourceResolution now re-emits on every settle rather than only success — which is also what re-enables the panel if a later retry lands (retries stay allowed by design; React metadata can mount late). When every selected element is unresolved (or the page reports selector-only — the no-resolver host case), the panel renders its sections in a disabled fieldset (pointer-events-none reaches the label scrubs a disabled fieldset cannot) under a message naming the reason. Inspection, selection, and the layers rail stay live. Verified live in the dev desktop app: selecting the fixture's card settles unresolved, the note renders, the fieldset disables, and a real pointer scrub on the radius handle moves nothing. Screenshots in .fork/notes/design-send-unresolved/ (right edge clipped by the small dev window itself, not the change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>


Two design-mode change requests in a row cost more time than making the edits by hand. Both had the same shape: the request asserted something it had not checked.
A css bullet named a utility it never proved was the lever.
findExistingUtilityscans the element's class list for a prefix match, which only proves such a class is present. On the composer's runtime-mode chip that produced:px-2.5is 10px and the measurement was 8px — the payload contradicted itself. AComposerShell.cssrule outranked the utility, so editing the class was a silent no-op that only surfaced when the user noticed the live app was unchanged.The new
cssOrigin.tsproves the lever empirically: remove the class, re-measure, see whether the value moves. That sidesteps cascade emulation entirely and covers layers,!important,@mediaand specificity at once. When the utility does not win, the bullet names the rule that does and the file it lives in — recovered from Vite'sdata-vite-dev-id, the only place an injected<style>keeps its authored filename — and drops the utility suggestion:The probe has to run while the element shows its original cascade; with the draft applied inline it would measure the draft and call every utility inert. That is why
collapse()moved inside the measurement block — origins are probed against the property names the bullets actually use (padding-inline, not thepadding-left/rightdrafts it collapses from).A location pointed at
ComposerControl.tsx:135in a 71-line file. Line 135 is a real position in the module Vite served (231 lines, React Compiler output), not in the source.react-grabalready reports this throughStackFrame.isSymbolicatedand the fork was discarding the flag. Unsymbolicated locations are now rejected — which costs the line but not the file:data-t3-source-filekeeps the authored path anddata-t3-componentcarries the fiber's component name, so the request readsRendered by: <ComposerSelectControl> in /src/… (line not resolvable).The guardrail promised a verifier this fork does not have.
NO_PREVIEW_GUARDRAILtold the agent "The Forge verifies the changes automatically" — true upstream, false here, becauseclient/verifier.tswas never vendored. It told the agent to stop looking at exactly the point where a no-op edit would have been caught.Notes for review
cssOrigin.tslives besideprotocol.tsrather than underengine/, so it crosses the TS-island fence and can be type-checked and unit-tested by the web project. Files underengine/cannot be.file/line/columnsees exactly what it saw before, and a preload predating this still satisfies the current engine. No protocol version bump.forkDesignModeCssOrigin.test.ts; the probe itself is verified only by typecheck and the engine bundle build. It wants one live design-mode send to confirm end to end.Verification
236 fork guard tests, 97 desktop preview tests, engine island
tsc,apps/web+apps/desktoptypecheck, lint — all clean.Model: Claude Opus 5 (1M context), harness: Claude Code.
🤖 Generated with Claude Code