Skip to content

feat(fork): native React source mapping + Figma-spec design panel - #54

Merged
NoahHendrickson merged 8 commits into
customfrom
t3code/native-react-design-mode
Aug 5, 2026
Merged

feat(fork): native React source mapping + Figma-spec design panel#54
NoahHendrickson merged 8 commits into
customfrom
t3code/native-react-design-mode

Conversation

@NoahHendrickson

Copy link
Copy Markdown
Owner

Problem

Design mode required the previewed project to install forge-mode's JSX tagger — untagged pages couldn't select anything and got a setup-handoff toast. The panel also still wore its provisional chrome rather than the t3-fork Figma design.

What changed

Native source mapping (no project install). The desktop pick preload installs a frozen, validated __T3_DESIGN_SOURCE_RESOLVER_V1__ page global backed by the already-bundled react-grab. The engine resolves untagged elements lazily — 75ms hover-dwell prefetch, promotion on selection (plus the parent/siblings structural asks name), and a 1500ms bounded grace at send — synthesizing canonical data-dc-source tags marked data-t3-native-source. Project Forge tags stay authoritative and are never overwritten. Unresolvable elements stay fully editable and send with selector/text/style context; persisted entries carry a css-path selector fallback so drafts survive reloads that wipe synthesized tags. The ready message reports forge | native-react | selector-only, the layers tree now walks untagged pages (Forge pages keep the curated walk), and the Forge setup prompt/toast is gone.

Figma-spec panel restyle (t3-fork file, nodes 193:9686 / 192:9018 / 180:7219): 24px inset field rows with icon-prefix cells, lifted opaque surfaces via new --fork-design-* theme tokens (dark = Figma palette, light falls back to app tokens), sentence-case 12px section labels, lifted segmented tabs, arrow-icon direction tabs, glowing green align-matrix dot, 325px panel, a new Appearance section (opacity + radius + per-corner fields wearing custom corner glyphs), and the paintbrush toolbar toggle.

Deliberately deferred: the Figma Position (X/Y + align-to-parent) section needs engine capabilities that don't exist yet; panel background/workspace color unification; canvas mode (follow-up branch).

Verification

  • Desktop: resolver unit tests (6), preview suite 87/87, typecheck clean, preload smoke-bundles with the resolver included.
  • Web: full fork guard + custom suite 217/217 (incl. new bridge-contract and sourceMode round-trip guards, engine IIFE bundle build), web + engine-island typechecks, vp fmt, fork-lint, manifest updated.
  • Reviewed live in the dev desktop app (worktree state seeded from a real-data snapshot); before/after screenshots to follow.

Built with Claude Fable 5 on Claude Code.

🤖 Generated with Claude Code

NoahHendrickson and others added 2 commits August 4, 2026 22:22
… needed

Design mode previously required the previewed project to run forge-mode's
JSX tagger; untagged pages were inert and got a setup handoff toast. Now
the desktop pick preload installs a frozen react-grab-backed resolver on
the preview page, and the engine resolves untagged elements lazily (hover
dwell, selection, bounded send-time grace), synthesizing canonical
data-dc-source tags marked data-t3-native-source. Project Forge tags stay
authoritative. Elements whose source never resolves remain fully editable
and send with selector/text context; persisted drafts carry a css-path
selector fallback so restores survive reloads that wipe synthesized tags.
The ready message now reports forge | native-react | selector-only, the
layers tree walks untagged pages, and the Forge setup prompt is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopts the Figma design language for the native design panel: 24px inset
field rows with icon-prefix cells on lifted opaque surfaces, 12px
sentence-case section labels with 24px section gaps, segmented tabs whose
selected segment lifts on white/8%, arrow-icon direction tabs, a glowing
green align-matrix dot, and a 325px panel column. New Appearance section
groups opacity and radius behind a green corners toggle whose per-corner
fields wear custom corner glyphs (CornerRadiusIcon). Surfaces and the
green accent ride new --fork-design-* tokens in theme.custom.css (dark
gets the Figma palette; light falls back to app tokens). The toolbar
toggle is now the paintbrush, and the lucide→Phosphor shim gains Scan and
FoldHorizontal mappings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Aug 5, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear code quality review — request changes.

The core design move is strong: synthesizing canonical data-dc-source="file:line:col" on untagged elements so every existing consumer keeps reading one representation is real code-judo. WeakMap attempt coalescing, attribute-based ownership for re-injection, the frozen page global, and sourceMode replacing a boolean are all clean. The panel restyle is cosmetic and fine.

Two boundary/duplication issues keep this below the approval bar:

  1. Dead bridge contract surfaceDesignSourceResult validates/ships componentName and selector, but the engine only consumes file/line/column and recomputes CSS paths itself.
  2. Duplicated source-context fan-out — element+parent+sibling → Set<TaggedElement> is copy-pasted in buildSend and promoteSourceResolution; those two paths must not silently drift.

Non-blocking: duplicated validation between desktop normalizeResolvedSource and engine normalizeNativeSource is defense-in-depth across a page-shared global, but worth consolidating if the shared module is already reachable. headlessMode.ts is at 791 lines — under the 1k bar for now, but the next feature landing here should decompose rather than append.

Not approving until the dead contract fields are wired or deleted, and the context fan-out lives in one helper.

Open in Web View Automation 

Sent by Cursor Automation: Thermo-nuclear PR review

Comment on lines +19 to +20
componentName: string | null;
selector: string | null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

componentName and selector are validated, length-capped, and unit-tested here, but nothing consumes them: the engine's only reader (normalizeNativeSource in engine/nativeSource.ts) pulls file/line/column and drops the rest. This is dead contract surface that obscures the real design — the bridge's actual product is file:line:col.

Worse, the engine recomputes a CSS path in persistedAddress via cssPath(el) while this validated selector already crossed the boundary and got discarded. Either make these fields live (wire componentName → the panel sourceLabel, selector → the persisted css-path address) or delete them from DesignSourceResult and remove the tests that only cover dead code. Don't ship a contract nobody reads.

Comment on lines +221 to +230
const sourceTargets = new Set<TaggedElement>();
for (const el of this.drafts.draftedElements()) {
if (!el.isConnected) continue;
sourceTargets.add(el);
for (const context of [el.parentElement, el.previousElementSibling, el.nextElementSibling]) {
if (context instanceof HTMLElement || context instanceof SVGElement) {
sourceTargets.add(context);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This element+parent+sibling fan-out into a Set<TaggedElement> is duplicated verbatim in promoteSourceResolution (~line 506). Two copies of the same "what's my source-resolution context" rule on the two paths (send vs. select) that must agree is how they quietly drift.

Extract one helper — natural home is nativeSource.ts, e.g. sourceContextTargets(els): Set<TaggedElement> — and call it from both sites so the context definition lives in exactly one place.

@NoahHendrickson NoahHendrickson left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Scope reviewed: 26 files, +841/−143 — the desktop preload resolver bridge, the engine's native-source layer, persistence/protocol changes, and the Figma panel restyle.

Overview

Two independent things ship here:

  1. Native source mapping. A frozen __T3_DESIGN_SOURCE_RESOLVER_V1__ page global installed by the pick preload (DesignSourceResolver.ts), consumed lazily by the engine (nativeSource.ts), which synthesizes canonical data-dc-source tags marked data-t3-native-source. Selection eligibility widens (findTaggedElementfindSelectableElement), the layers walk gains an untagged mode, the ready message becomes sourceMode, buildSend goes async with a 1500ms grace, and the Forge setup toast/handoff is deleted.
  2. Panel restyle to the t3-fork Figma spec — --fork-design-* tokens, 325px panel, new Appearance section, icon-prefix fields, paintbrush toggle.

The bridge design is the strongest part: validation on both sides, a non-writable/non-configurable/frozen descriptor, isConnected + ownerDocument rechecks after every await, and the marker attribute (not a WeakSet) so forge-vs-synthesized survives destroy/re-inject. The hasForgeTags() exclusion of synthesized tags is the right call — without it T3's own lazy tagging would collapse the layers walk out from under itself. Guard coverage is good: the bridge-contract test pins the global literal on both sides and the fenced preload import, asserts react-grab never lands in the web package, and the protocol test rejects both the retired tagged shape and unknown modes.


Blocking

1. cssPath is not a unique address, but restore now treats it as oneheadlessMode.ts:386-407 (locatePersisted), :417 (persistedAddress)

cssPath (vendor/request.ts:280) caps at depth 4 and is not anchored to the document root — it emits e.g. div > div:nth-of-type(2) > span > button. Before this PR it was only ever a descriptive field in the change-request markdown (request.ts:269), where ambiguity is harmless. Here it becomes the addressing key:

candidate = document.querySelector(entry.selector);

That matches the first element anywhere in the document fitting a 4-deep relative pattern. On a native-react page this is not an edge path — it's the primary restore path, since synthesized data-dc-source tags never survive a reload, so locateBySource always misses and every entry falls through to the selector. The failure is silent and destructive: the persisted draft props get applied to the wrong node, which is then stamped with the persisted dcSource via markSynthesizedSource, so the wrong element is what ships to the agent.

Suggestions, roughly in order of cheapness:

  • Verify uniqueness before trusting the hit: const hits = document.querySelectorAll(sel); if (hits.length !== 1) return null; — a missed restore is recoverable, a wrong one isn't.
  • And/or cross-check the candidate against the entry (tagName, direct text) before accepting.
  • Or give persistence its own path builder: anchored at body, no depth cap, :nth-child at every level. Keep cssPath as-is for the human-readable request context — the two use cases genuinely want different things.

Worth fixing

2. Send has no in-flight guard, and the window just grew ~100×ForkDesignPanel.tsx:490-496

buildSend previously returned in about one IPC round trip; it can now block for SEND_SOURCE_WAIT_MS (1500ms). The button is disabled only on tab.draftCount === 0, and useDesignChangeDraftStore.add appends unconditionally (designChangeDraftStore.ts:35-41) — so two clicks during the grace produce two identical attachment pills for one set of drafts. A sending state that disables the button (and ideally shows it's working — 1.5s of a dead button is exactly the "lying spinner" case CLAUDE.md calls out) covers both the duplicate and the perceived hang.

3. The untagged layers walk builds the whole DOM before the cap appliesvendor/layers.ts:39-52, layersSession.ts:97

With includeUntagged, buildLayerTree mints a LayerNode and calls layerLabeldirectText (childNode iteration + string building) for every element under body, and only then does serialize apply LAYERS_NODE_CAP = 400. On a page with a few thousand elements that's thousands of allocations and label computations discarded per rebuild, repeating on every 250ms mutation-quiet window while the mode is on. The old curated walk traversed the full DOM too, but only allocated/labelled per tagged element — the new cost is real and lands exactly on the untagged pages this feature is for. Threading the budget into buildLayerTree so the walk stops at the cap would keep it O(400) instead of O(DOM). hasForgeTags() also runs a full-document querySelector per emit that scans the entire tree when it (by definition) won't match; caching it per debounce window is nearly free.

4. Negative results are cached in two places, so "retry on re-inject" doesn't happennativeSource.ts:715 (attempts), DesignSourceResolver.ts:138 (resolutionCache)

Both layers memoize failures per element. The engine's attempts WeakMap resets when the host re-injects after a toggle, which reads as "we'll try again" — but the preload's cache is process-lifetime and returns the same settled null. So an element hovered before React's dev metadata is available stays selector-only for its entire DOM lifetime, with no user-visible way to retry. That may be the intended trade (the comments justify not retry-storming), but the current comments overstate the recovery. Either say so explicitly, or let the preload cache drop nulls after a short TTL while keeping successes permanent.


Minor

  • Concurrency gate can overshootDesignSourceResolver.ts:122-134. releaseSlot decrements and then resolves the waiter, which increments a microtask later. A synchronous acquireSlot in between takes the fast path, so activeResolutions can reach 3 with MAX_CONCURRENT = 2. Hand the slot directly to the waiter instead of decrementing: const next = waiters.shift(); if (next) next(); else activeResolutions -= 1; (and drop the += 1 after the await).
  • componentName / selector on DesignSourceResult are deadDesignSourceResult.ts:196-202. They're computed, bounded, and unit-tested, but normalizeNativeSource only reads file/line/column, so nothing downstream ever sees them. (Note the PR description's "persisted entries carry a css-path selector fallback" is the engine's cssPath, unrelated to this field.) Either wire componentName into the panel's source label or drop both fields.
  • Grow / Shrk labels will clipDesignPanelFields.tsx:63-74, used at ForkDesignPanel.tsx:319/329. The prefix cell went from w-7 + ps-1.5 + text-[10px] to size-6 (24px) + justify-center + text-xs (12px). Four characters at 12px centered in 24px overflow both sides; the parent's new overflow-hidden clips the left and the right overlaps the input. Every other text label is ≤2 chars, so it's just these two — either give them icons like Gap/TL-BR, or shorten them.
  • awaitResolutions leaks its timernativeSource.ts:754-757. Promise.race leaves a 1.5s setTimeout pending when the batch wins. Harmless, but a clearTimeout in a finally is one line.
  • aria-label on the non-interactive prefix <span> (DesignPanelFields.tsx:65) does feed the wrapping <label>'s name computation, so icon-only fields aren't nameless — but sr-only text would be less dependent on accname recursion.

Conventions

  • Two concerns in one PR. CLAUDE.md: "One concern per PR. If the description says 'also', split it." The description has two independent "What changed" halves — the native-source engine work and the Figma restyle share no code and could land separately. The restyle half is the easy review; splitting would let the engine half get the attention finding #1 needs.
  • Before/after images are still pending ("screenshots to follow"). Required for UI changes, and the panel restyle is most of the visual surface.
  • Multi-surface is handled correctly as far as I can tell: native resolution is desktop-only (it rides the Electron preload), and web/mobile hosts fall to selector-only with the panel's soft note rather than a broken mode. Worth confirming the selector-only copy is what a non-desktop user actually reads, since that's now their permanent state rather than a setup prompt.
  • Docs. Deleting the Forge-setup requirement is a user-visible behavior change ("Design mode now works without touching your project"). .fork/customizations.yaml is updated thoroughly, but nothing in docs/user/.

What's good

  • Untrusted-input discipline across the page-shared global: validated in the preload, validated again in the engine, sanitized a third time by the request builder. Control-character rejection on file paths is a nice touch, and it's tested.
  • Every await is followed by an isConnected recheck before the DOM is mutated — both resolveElement and resolveAndTag get this right, including the "a tag appeared meanwhile, don't overwrite it" case.
  • Forge tags stay authoritative per element rather than per page. That's the right granularity and it's enforced in one place.
  • The pure/impure split (DesignSourceResult.ts beside DesignSourceResolver.ts) matches the existing PickLabelPosition.ts precedent and makes the validation unit-testable.
  • vendor/README.md documents each local edit against a future re-sync, and the guard test pins the fenced preload import specifically because an upstream sync would silently revert it.

Generated by Claude Code

Padding collapses from four per-side scrubs to the design's two paired
fields — left/right and top/bottom, each wearing its custom frame-and-bars
glyph. Typing one value sets both halves; "8, 16" splits them (first =
left/top). Scrubbing moves both halves together, preserving a split, and
the spacing-token ladder collapses the pair onto the picked step. Margin
keeps its per-side fields (the design doesn't cover it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:XXL and removed size:XL labels Aug 5, 2026
NoahHendrickson and others added 4 commits August 4, 2026 22:39
The Figma design's GridFour button, green while the selected element is a
flex container. Toggling on previews display: flex; toggling off previews
display: block, which the change-request builder already rewrites as
"remove auto layout" intent so the agent edits classes instead of
hardcoding block. The display select stays for the values the toggle
doesn't cover (grid, inline variants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draft edits change the selection's computed values, but only discardAll
re-emitted a snapshot — an edit that changes what the panel renders
structurally (the auto-layout toggle turning display: flex, which reveals
the direction/gap/alignment group) stayed invisible until the user
reselected the element. The debounced draft sync now re-emits the
selection snapshot after edits settle, so scrub bursts still cost zero
extra bridge traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Figma's per-axis sizing menu, docked at the right edge of the W/H fields.
The write branch reconstructs the Forge's deleted onSizeModeChange against
the vendored helpers that survived: Fixed measures then pins px (defeating
a flex fill first so the number wins on a flex-1 element), Hug drafts the
auto/fit-content keyword the request builder already passes through as
intent, and Fill drafts flex-grow 1 + basis 0% on the main axis,
align-self stretch on the cross axis, or 100% outside flex (added to the
keyword passthrough — the percentage IS the ask). Snapshots carry
draft-first mode reads, so an unstyled flex child's cross axis correctly
reports Fill, Figma's model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The t3-fork Figma chip (node 157:4660): a 32px fully-rounded pill with a
dark circular paintbrush badge, then tag, source location, and the first
delta with a +N overflow count. Fills cycle through a four-color
translucent palette (the design's blue and purple plus green and orange in
the same family), keyed off the entry id so simultaneous changes read
apart at a glance and removing one chip never recolors its neighbors. The
dismiss button and full-markdown tooltip stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Persisted-selector restore trusts a css-path hit only when it is unique
  in the document: cssPath is depth-capped and unanchored, so a first-match
  querySelector could silently restore drafts onto (and stamp a synthesized
  source on) the wrong element.
- Send gets an in-flight guard: the button disables and reads "Preparing…"
  during buildSend's native-source grace, so a double-click can't attach
  duplicate pills and the wait is shown honestly.
- The layers node cap is threaded into buildLayerTree as a budget, so the
  untagged full-DOM walk allocates and labels O(cap) nodes per rebuild
  instead of building the whole tree for the serializer to drop.
- Failed native-source resolutions are retryable: the engine drops failed
  attempts on settle and the preload expires cached nulls after a 5s TTL,
  so an element hovered before React's dev metadata mounts isn't pinned
  selector-only for the page's lifetime. Successes still cache forever.
- The preload's concurrency gate hands released slots directly to waiters,
  closing the microtask window that let it overshoot MAX_CONCURRENT.
- Dead DesignSourceResult fields (componentName/selector) are deleted —
  the engine only ever consumed file/line/column.
- The element+parent+siblings source fan-out lives in one helper
  (sourceContextTargets) shared by send and selection promotion.
- awaitResolutions clears its race timer; Grow/Shrink fields wear icons
  instead of clipping 4-char labels; icon-only field prefixes name their
  wrapping label via sr-only text rather than accname recursion.

Built with Claude Fable 5 on Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NoahHendrickson

Copy link
Copy Markdown
Owner Author

Review feedback addressed in 0e88d6e — both the Cursor automation findings and the owner review.

Blocking — selector restore correctness. locatePersisted now trusts a persisted css-path only when querySelectorAll returns exactly one hit. cssPath stays as-is for the human-readable request context (the two use cases genuinely differ, per the review's own framing); the restore path just refuses ambiguous patterns. A missed restore is recoverable, a wrong one wasn't.

Send in-flight guard. onSend sets a sending flag: the button disables and reads "Preparing…" for the duration of the native-source grace, so a double-click can no longer attach duplicate pills and the 1.5s wait is shown honestly.

Layers walk cost. The 400-node cap is threaded into buildLayerTree as a LayerBudget — the untagged walk stops minting (and labelling) at the cap, O(cap) instead of O(DOM) per rebuild. The serializer now just mints ids over the pre-capped tree; truncation semantics are unchanged (set only when a mintable node was actually dropped). hasForgeTags() already ran once per debounce window (it's called once per emit), so no extra caching was added there.

Negative-result caching. Recovery is now real instead of overstated: the engine drops failed attempts on settle, and the preload expires cached nulls after a 5s TTL (successes still cache for the element's lifetime). Net behavior: at most one react-grab attempt per element per 5s, still concurrency-capped at 2, and an element hovered before hydration resolves on a later hover/selection/send.

Dead contract fields. componentName/selector deleted from DesignSourceResult — the bridge now carries exactly what the engine consumes (file/line/column). Dropped rather than wired: surfacing componentName in the panel label would be new feature surface this PR doesn't need.

Duplicated fan-out. The element+parent+siblings expansion lives in one exported helper, sourceContextTargets, used by both buildSend and promoteSourceResolution.

Minors. Concurrency gate hands released slots directly to waiters (no overshoot window); awaitResolutions clears its race timer; Grow/Shrink wear expand/contract icons instead of clipping 4-char labels; icon-prefixed fields name their wrapping <label> via sr-only text instead of accname recursion.

Kept as-is, deliberately:

  • The doubled validation (preload normalizeResolvedSource / engine normalizeNativeSource) stays — the engine is a bundled IIFE injected into the guest and the preload is desktop code; they can't share a module across that boundary without giving the page a new import surface, and belt-and-braces on a page-shared global is the point.
  • Fork-feature docs live in .fork/customizations.yaml, not docs/user/ — that tree is an upstream mirror on this fork and would conflict on every sync.
  • Not splitting the PR at this point: the panel-restyle commits landed interleaved with engine work after review and the branch has moved five commits past the reviewed head. Noted for next time.

Before/after screenshots remain the open item from the PR body.

Verification: desktop preview suite 86/86 (one test deleted with the dead fields), desktop+web typecheck clean, full fork guard suite 217/217, vp fmt and fork-lint clean.

@NoahHendrickson
NoahHendrickson merged commit 81e5800 into custom Aug 5, 2026
10 checks passed
@NoahHendrickson
NoahHendrickson deleted the t3code/native-react-design-mode branch August 5, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant