Skip to content

feat(fork): Figma-style design mode for the preview browser - #50

Open
NoahHendrickson wants to merge 1 commit into
customfrom
t3code/add-forge-design-mode
Open

feat(fork): Figma-style design mode for the preview browser#50
NoahHendrickson wants to merge 1 commit into
customfrom
t3code/add-forge-design-mode

Conversation

@NoahHendrickson

Copy link
Copy Markdown
Owner

Problem

Design iteration through T3 means describing visual changes in words — "make the card less cramped" — and hoping the agent guesses the right pixels. The Forge (NoahHendrickson/the-forge) solved this with click-to-edit design tooling, but as a standalone overlay with its own chat surface and delivery stack.

What this does

Brings the Forge's design mode into the in-app preview browser as a fork customization (fork-design-mode), with T3 threads as the only delivery surface:

  • Design toggle in the preview chrome (desktop-only, beside the three-dot menu) injects a headless engine bundle into the previewed page over webview.executeJavaScript. Selection outlines, inline-style draft previews, double-click text edit, drag-to-reorder, 8-point resize, and Del all run in-page. The engine is vendored Forge client code pruned of its chat/queue/MCP layer and is guard-tested network-free.
  • Native properties panel docked beside the browser surface — size, padding, margin, radius, typography, fill, opacity — with scrub-to-edit fields, built from T3 primitives. Guest↔host bridge: console-message lines up, window.__T3_DESIGN_MODE__ commands down.
  • Send = composer attachment. The panel's Send creates an inline pill (div · App.tsx:15 / padding-top 24px → 32px +2, × to discard, tooltip shows the full request). At send time the deterministic, Tailwind-token-aware change-request markdown is appended to the outgoing message inside <design_change_request> blocks. Pill-only messages are sendable; pills clear only when the turn start succeeds.
  • Element→source mapping requires forge-mode's dev-only JSX tagger in the previewed project; untagged pages get a toast with a one-click "ask the agent to set it up" handoff.

Fork hygiene

  • Everything lives in apps/web/src/custom/designMode/; upstream touch is fenced hunks in PreviewView(.test), ChatComposer, ChatView, the two vite configs, tsconfig.json, plus one esbuild devDependency.
  • The vendored engine (engine/vendor/) keeps the Forge's formatting for cheap re-syncs, carries t3-fork: edit markers (vendor/README.md), and is its own TS island; a Vite plugin bundles it to virtual:fork-design-mode-engine.
  • Manifest entry + guard suite: mounts intact, bundle self-contained, engine network-free, protocol round-trips, attachment path wired (284 tests green, fork lint clean).

Screenshots

UI-changes rule: before/after captures to be attached by @NoahHendrickson from the live session (panel + attachment pill).

Follow-ups (next phase, separate PRs)

Layout/auto-layout controls, stroke, typography depth; proper color picker; token picker; native layers tree; transcript chip rendering for <design_change_request> blocks; forge-mode overlay: false tagging-only option upstream.


Built with Claude Code (Claude Fable 5).

🤖 Generated with Claude Code

Ports the editing core of NoahHendrickson/the-forge into the in-app
browser as a fork customization (manifest id fork-design-mode):

- A Design toggle in the preview chrome injects a headless engine bundle
  (vendored Forge client, pruned of its chat/queue/MCP delivery layer)
  into the previewed page via webview.executeJavaScript. Selection
  outlines, inline-style drafts, text edit, drag-reorder, resize and
  delete all run in-page; the engine is guarded network-free.
- The properties panel is native T3 UI docked beside the browser
  surface: size, padding, margin, radius, typography, fill and opacity
  with scrub-to-edit fields, driven over a console-message /
  executeJavaScript bridge.
- Send delivers as a composer attachment pill (element + compact delta);
  the full deterministic token-aware change-request markdown is appended
  to the outgoing message at send time inside <design_change_request>
  blocks, so T3 threads are the delivery surface and the composer stays
  readable. Pill-only messages are sendable.
- Element-to-source mapping needs forge-mode's dev-only JSX tagger in
  the previewed project; untagged pages get a one-click agent handoff.

Vendored engine code keeps the Forge's formatting and carries t3-fork
edit markers (see engine/vendor/README.md); it is its own TS island and
is bundled to an injectable IIFE by the fork:design-mode-engine Vite
plugin as virtual:fork-design-mode-engine.

Built with Claude Code (Claude Fable 5).

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

cursor Bot commented Aug 4, 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:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Aug 4, 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

APPROVE: no — clear structural simplification available (dead vendor chrome + forked orchestrator), and PreviewView uses a heavy last-resort fence where an override fits better.

First-party integration under custom/designMode/** is mostly in good shape: protocol/stores/bridge, native ForkDesignPanel, vite IIFE plugin, and fork guards are coherent. ChatView/ChatComposer fences stay thin via forkDesignChanges — correct given fork guidance to avoid shadowing those giants.

The engine boundary is the problem. Headless mode copies DesignMode into headlessMode.ts while still vendoring the unused Forge panel stack (~5.8k lines / 18 modules, including 1288-line panel.ts and dead index.ts). That is not cheap re-sync; it is dual maintenance. Overlay still mounts Forge chrome and ships full panel CSS despite the native panel. PreviewView’s flex wrapper re-indents the whole surface tree in a watched upstream file.

See inline comments for the actionable cuts.

Open in Web View Automation 

Sent by Cursor Automation: Thermo-nuclear PR review

Comment on lines +14 to +19
Since the native-panel split, the injected bundle is HEADLESS: `engine/headlessMode.ts`
(fork-authored, adapted from `index.ts` here) composes the vendored leaf modules and the
in-page chrome UI modules (`panel*`, `dock`, `layers*`, `canvas*`, `controls`,
`colorpicker`, `tokenpicker`, `ui/*`, and `index.ts` itself) are retained for reference
and future re-syncs but tree-shaken out of the bundle — the properties panel is native T3
React (`custom/designMode/panel/`).

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 preserves a lot of incidental complexity when there's a clear code-judo deletion. reachability from boot.ts/headlessMode.ts (value imports only): ~5.8k lines / 18 modules are dead (panel.ts 1288, index.ts 831, controls, colorpicker, layers*, canvas-chrome, feed-divider, …) while headlessMode.ts is a 650-line fork of the dead DesignMode in index.ts. keeping both is the opposite of cheap re-sync — every Forge edit must be ported twice, or ignored in the dead copy.

pick one boundary: (a) delete unused chrome UI from the tree and vendor only leaf modules headless actually imports, or (b) make headless a thin adapter over vendored DesignMode with panel/dock/layers stubbed so there is one orchestrator. tree-shaking the bundle does not justify shipping dead source that still has to be maintained.

Comment on lines +36 to +39
// No in-page chrome in headless mode: the T3 preview chrome owns activation and the
// native panel owns every control — the overlay contributes outlines/handles only.
overlay.toggle.hidden = true;
overlay.status.hidden = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hiding toggle/status does not make Overlay headless. Overlay still constructs Design/Copy/Before/Reset/Unlink chrome (overlay.ts ~725–730), imports DEFAULT_WIDTH from dock, and embeds the entire #panel / layers / feed CSS string into every injection.

i think there's a code-judo move here: a slim outline/handles host (or a HeadlessOverlay) so guest DOM + CSS match the native-panel split. the dual panel systems (vendor panel CSS/chrome vs ForkDesignPanel) make the boundary harder to reason about, not easier.

{/* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode
Flex wrapper so the native design panel docks as a column beside the browser
surface; the webview auto-shrinks because BrowserSurfaceSlot measures its slot. */}
<div className="flex min-h-0 flex-1 overflow-hidden">

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 works, but it makes the surrounding upstream path messier. the flex wrapper re-indents the entire browser surface block in a watched hot file — last-resort fencing with a large dirty hunk.

fork preference is custom > overrides > fenced upstream. PreviewView (~723 lines) is a better override target than permanently re-parenting this tree; ChatView/ChatComposer thin fences are the right call (those giants must not be shadowed). isolate the dock layout behind an override (or a thinner slot) so upstream surface edits don't conflict with a formatting-only re-indent.

message.type === "selection" ||
message.type === "drafts"
) {
return value as DesignModeEngineMessage;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why does this need a cast here? checking message.type then return value as DesignModeEngineMessage accepts malformed payloads (missing fields, wrong shapes). same smell in designModeBridge.buildSend's ad-hoc cast.

make the boundary more explicit — narrow each variant's fields (or a tiny decoder) so the store/panel never see half-shaped snapshots.

@@ -0,0 +1,1288 @@
import type { TaggedElement } from './source'

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 pushes a new file past 1k lines with zero runtime value-importers from the headless entry (only dead index.ts / type-only dock/canvas-chrome wiring). if the dead-chrome cut lands, this file should leave the tree; if you insist on a full Forge mirror for diffing, keep it out-of-tree or behind an explicit sync script, not in the shipped module graph.

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