Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1742,14 +1742,35 @@
file:line:col lazily — on hover dwell, selection, and send — and the
engine synthesizes a canonical data-dc-source (marked
data-t3-native-source so it is never mistaken for a project tag).
Elements whose source never resolves stay fully editable and send
with selector/text/style context; no install prompt, warning toast,
or Forge handoff exists anymore. That downgrade is VISIBLE, not
silent: the payload's per-element sourceLabel (null = unaddressed by
send time) is counted host-side (protocol.ts
countUnresolvedDesignElements) and surfaced on the composer pill and
in the send toast, because buildSend's ~1.5s native-source grace
means WHEN Send was clicked can change how precise the ask is. Canvas mode (the Forge's vendored
A selection that is known-anonymous makes the panel read-only, with
per-element state as the ONLY source of truth: snapshots carry a
sourceState (resolved | pending | anonymous, protocol v5 — named apart
from the pill's "unresolved sourceLabel" counter on purpose, the two have
different inclusion rules) where `anonymous` means a native-source attempt
SETTLED with no tag, no file, and no component name. No-resolver hosts
settle elements as anonymous too (resolveAndTag's early exit records the
settle), so there is no page-level sourceMode clause in the gate — the
boot-time mode probe is a one-shot at dom-ready and must never pin a
session-long lock. Mutating verbs write to ADDRESSABLE ids only, so a
Shift-click mixed selection cannot fan drafts onto anonymous siblings, and
the undo history records the same filtered set. The read-only rendering is
a marker div ([data-fork-design-readonly]) whose theme.custom.css rules
stop pointer events on inputs and non-disclosure buttons — NOT a disabled
fieldset, which would also disable the Expando/section disclosures and
make collapsed values unreadable; disclosures stay live via their
aria-expanded. `pending` stays editable (no flicker), component/file-only
context counts as resolved (PR #67's "Rendered by" line is real context),
and promoteSourceResolution re-emits only on the pending→settled
TRANSITION, coalesced per burst, so repeated failed retries and
no-resolver fan-outs cost nothing. Inspection, selection and the layers
rail stay live throughout; no install prompt, warning toast, or Forge
handoff exists anymore. For elements that remain merely IMPRECISE rather
than anonymous, the downgrade is VISIBLE instead of silent: the payload's
per-element sourceLabel (null = unaddressed by send time) is counted
host-side (protocol.ts countUnresolvedDesignElements) and surfaced on the
composer pill and in the send toast, because buildSend's ~1.5s
native-source grace means WHEN Send was clicked can change how precise
the ask is. Canvas mode (the Forge's vendored
CanvasMode, engine/vendor/canvas.ts) turns the page into a
pannable/zoomable artboard — space-drag/middle-drag pan,
cursor-anchored wheel and pinch zoom, the powers-of-2 ladder,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/web/src/__fork_guards__/forkDesignMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ describe("fork guard: design mode", () => {
id: 1,
tag: "button",
sourceLabel: "App.tsx:5",
sourceState: "resolved",
styles,
sizeModes: { width: "fixed", height: "hug" },
offsets: { x: 24, y: -8 },
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/custom/designMode/designModeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ export interface DesignModeTokens {
export interface DesignModeTabState {
readonly enabled: boolean;
/** How the engine maps elements to source on this page (protocol.ts
* DesignModeSourceMode) — null until the engine's ready message reports it. Every mode
* stays fully editable; `selector-only` gets a soft note in the panel's empty state. */
* DesignModeSourceMode) — null until the engine's ready message reports it.
* `selector-only` means the page has NO source mapping: the panel renders read-only
* (disabled fieldset + message) because there is no code location to point the agent
* at; inspection and selection stay live. The mappable modes stay fully editable. */
readonly sourceMode: DesignModeSourceMode | null;
readonly selection: readonly DesignModeElementSnapshot[];
readonly draftCount: number;
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/custom/designMode/engine/headlessMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { LayersSession } from "./layersSession";
import { basename, findSelectableElement, type TaggedElement } from "./vendor/source";
import {
awaitResolutions,
hasSettledUntagged,
isSynthesizedSource,
markSynthesizedSource,
resolveAndTag,
Expand Down Expand Up @@ -699,14 +700,39 @@ export class HeadlessDesignMode {
private promoteSourceResolution(els: readonly TaggedElement[]): void {
for (const target of sourceContextTargets(els)) {
if (target.dataset?.dcSource) continue;
// Captured BEFORE the attempt: a failed settle must re-emit only on the pending →
// settled TRANSITION. Emitting on every settle re-runs the full snapshot rebuild
// (~45 computed-style reads per selected element) for outcomes the change gate
// then discards — repeated failed retries, and every element of a no-resolver
// page after the first (PR #72 review).
const freshAttempt = !hasSettledUntagged(target);
void resolveAndTag(target).then((tagged) => {
if (!tagged || !this.active || !this.selection.includes(target)) return;
this.emitSelection();
this.persist();
if (!this.active || !this.selection.includes(target)) return;
if (tagged) {
this.emitSelection();
this.persist();
return;
}
// First settle without a tag: the snapshot's sourceState just changed (pending →
// anonymous, or → resolved via a context attribute), which the panel's gate needs
// to hear. Coalesced: a multi-select's settles land in a burst, and one emit
// covers all of them.
if (freshAttempt) this.scheduleSettleEmit();
});
}
}

private settleEmitTimer: ReturnType<typeof setTimeout> | null = null;

/** One selection emit per burst of resolution settles — see promoteSourceResolution. */
private scheduleSettleEmit(): void {
if (this.settleEmitTimer !== null) return;
this.settleEmitTimer = setTimeout(() => {
this.settleEmitTimer = null;
if (this.active && this.selection.length > 0) this.emitSelection();
}, 0);
}

private setSelection(next: TaggedElement[]): void {
// wasSingle: read BEFORE the assignment — a single→single hop is the one case the
// outline tweens (multi-select and first-selection always snap).
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/custom/designMode/engine/nativeSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ export function normalizeNativeSource(value: unknown): string | null {
* selector-only forever); the preload's short-TTL null cache bounds the retry cost. */
const attempts = new WeakMap<TaggedElement, Promise<boolean>>();

/** Elements whose attempt settled without producing a tag. Snapshots read this to tell
* "no attempt has finished" (pending — stay editable) apart from "an attempt finished
* and found nothing" (with the attributes also absent, the element is anonymous and the
* panel disables editing). Membership is never the whole answer: a tag or a
* component/file attribute — including one a LATER retry writes — always wins at read
* time, so stale membership is harmless. */
const settledUntagged = new WeakSet<TaggedElement>();

/** Whether a native-source attempt for `el` has settled without tagging it. */
export function hasSettledUntagged(el: TaggedElement): boolean {
return settledUntagged.has(el);
}
Comment on lines +117 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

i think there's a code-judo move here that makes this much simpler. can we reframe this so these branches disappear?

settledUntagged exists because resolveAndTag returns a boolean that means "tagged", not "addressable" — component/file-only writes attrs and still returns false, then this set is populated, then sourceStateOf re-reads the attrs to override the set. Three places holding one fact.

Worse: the !resolver path (return Promise.resolve(false) before attempts.set) never reaches the .then that fills this set, which is the only reason ForkDesignPanel ORs in page-level sourceMode.

Prefer: typed settle result from resolveAndTag (tagged | context | anonymous | unavailable). Snapshots consume that. Drop the WeakSet. No-resolver settles as unavailable/anonymous immediately so the panel gate does not need a second concept.

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.

Half taken. The no-resolver settle now happens at the early exit (b1acec5), which removes the panel's second concept — that was the load-bearing part of this finding. Declining the typed-settle-enum-replaces-the-WeakSet refactor: the attribute reads are not a redundant third copy of the fact, they are the persistence layer. Engine module state (any WeakSet or WeakMap) dies on destroy/re-inject while the DOM and its attributes survive — the same reason NATIVE_SOURCE_MARKER_ATTR is an attribute rather than a WeakSet (documented at its declaration). A typed settle result would still have to re-read attributes after re-injection, so it adds a shape without removing a reader.


/** The elements a send or selection actually names: each element itself plus the parent
* and adjacent siblings the structural asks (move/absolute) reference. One helper for
* BOTH the send barrier and selection promotion so the two fan-outs never drift. */
Expand All @@ -138,7 +151,15 @@ export function resolveAndTag(el: TaggedElement): Promise<boolean> {
const cached = attempts.get(el);
if (cached) return cached;
const resolver = getResolver();
if (!resolver) return Promise.resolve(false);
if (!resolver) {
// A host with no resolver installed can never address this element — that IS a
// settled answer, and recording it here is what lets the panel's per-element gate
// work without a separate page-level concept (PR #72 review). If a resolver appears
// later (never in practice — preloads install before page scripts), the ordinary
// retry path still runs because nothing was cached in `attempts`.
settledUntagged.add(el);
return Promise.resolve(false);
}
const attempt = (async () => {
let raw: unknown;
try {
Expand Down Expand Up @@ -175,6 +196,7 @@ export function resolveAndTag(el: TaggedElement): Promise<boolean> {
})();
attempts.set(el, attempt);
void attempt.then((tagged) => {
if (!tagged) settledUntagged.add(el);

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 boolean is the wrong type for the new policy. !tagged here means "no data-dc-source", but component/file-only is intentionally addressable (sourceState: "resolved") and still takes this branch. The WeakSet then becomes a hint that sourceStateOf must second-guess via attribute reads.

If resolveAndTag returned a settle enum, this line — and hasSettledUntagged — go away.

if (!tagged && attempts.get(el) === attempt) attempts.delete(el);
});
return attempt;
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/custom/designMode/engine/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import {
DESIGN_MODE_STYLE_KEYS,
type DesignModeElementSnapshot,
type DesignModeSourceState,
type DesignModeStyleKey,
} from "../protocol";
import { alignCapsFor } from "./align";
import { COMPONENT_NAME_ATTR, hasSettledUntagged, SOURCE_FILE_ATTR } from "./nativeSource";
import { readSizeModes } from "./sizeMode";
import type { DraftStore } from "./vendor/drafts";
import { positionStateOf, POSITION_ROWS } from "./vendor/panel-specs";
import { basename, parseSourceAttr, type TaggedElement } from "./vendor/source";

/** What addressing the request could carry for this element, read live off the DOM plus
* the attempt ledger. A component name or source file counts as resolved — "Rendered by
* <X> in file" is real context the agent can act on (PR #67) — so only an element that
* settled with NONE of the three reads as anonymous. */
function sourceStateOf(el: TaggedElement, hasTag: boolean): DesignModeSourceState {
if (hasTag || el.hasAttribute(COMPONENT_NAME_ATTR) || el.hasAttribute(SOURCE_FILE_ATTR)) {
return "resolved";
}
return hasSettledUntagged(el) ? "anonymous" : "pending";
}
Comment on lines +18 to +23

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 re-derives addressing from a side channel + live DOM attrs instead of consuming what resolution already knew. That is the smell that settledUntagged + boolean tagged are the wrong model.

Once resolveAndTag reports a typed settle, this function should be a trivial map (or inline). Until then it is the third reader of the same fact (after the boolean return and the WeakSet).

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.

See the resolveAndTag thread for the split decision: the no-resolver hole is fixed at the source, but the attr reads stay — they are the cross-injection persistence layer, not a side channel, and sourceStateOf reading them live is what lets a later retry's component/file attributes upgrade an element without any bookkeeping.


/** The X/Y readout, in the margin-edge basis the panel's fields also WRITE (POSITION_ROWS
* owns both halves, so the field can never display a basis it doesn't commit to). */
function readOffsets(el: TaggedElement): { x: number; y: number } {
Expand Down Expand Up @@ -40,6 +53,7 @@ export function buildElementSnapshot(
id,
tag: el.tagName.toLowerCase(),
sourceLabel: parsed ? `${basename(parsed.file)}:${parsed.line}` : null,
sourceState: sourceStateOf(el, dcSource !== ""),
styles,
sizeModes: readSizeModes(el, drafts),
offsets: readOffsets(el),
Expand Down
85 changes: 61 additions & 24 deletions apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";

import { Button } from "~/components/ui/button";
import { toastManager } from "~/components/ui/toast";
import { cn } from "~/lib/utils";

import { useDesignChangeDraftStore } from "../designChangeDraftStore";
import { designModeBridge } from "../designModeBridge";
Expand Down Expand Up @@ -50,8 +51,22 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) {
const tab = useDesignModeStore((state) => selectDesignModeTab(state.byTabId, runtimeTabId));

const first = tab.selection[0];
const ids = tab.selection.map((element) => element.id);
/** Every verb below is a no-op without a tab and a selection — one gate, not six. */
// Mutating verbs write to the ADDRESSABLE elements only: in a mixed selection, a draft
// fanned onto an anonymous sibling is the same lying affordance the whole-selection
// gate exists to stop, just reached through Shift-click (PR #72 review). Undo records
// the same filtered set, so every entry covers exactly what was written.
const ids = tab.selection
.filter((element) => element.sourceState !== "anonymous")
.map((element) => element.id);
const addressable = tab.selection.filter((element) => element.sourceState !== "anonymous");
// When NOTHING in the selection can be traced to code — every element's native-source
// attempt settled with no tag, no file, no component name — editing is disabled with
// the reason stated; inspection (values, selection, layers) stays live. Per-element
// state is the single source of truth: no-resolver hosts settle elements as anonymous
// too (nativeSource.ts), so there is no separate page-level concept, and `pending`
// stays editable (no flicker; a settle re-emits the snapshot either way).
const unaddressable = tab.selection.length > 0 && addressable.length === 0;
/** Every verb below is a no-op without a tab and an addressable selection — one gate. */
const target = runtimeTabId !== null && ids.length > 0 ? runtimeTabId : null;

const apply = useCallback(
Expand All @@ -65,7 +80,7 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) {
designUndoHistory.recordDraft(
target,
property,
tab.selection.map((element) => ({
addressable.map((element) => ({
id: element.id,
prev: element.drafted.includes(property)
? ((element.styles as Partial<Record<DesignModeWritableKey, string>>)[property] ?? null)
Expand Down Expand Up @@ -111,7 +126,7 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) {
designUndoHistory.recordInset(
target,
axis,
tab.selection.map((element) => ({ id: element.id, prev: element.offsets[axis] })),
addressable.map((element) => ({ id: element.id, prev: element.offsets[axis] })),
px,
Date.now(),
);
Expand Down Expand Up @@ -285,32 +300,54 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) {
// Keyed by selection identity so field-local input state resets per selection.
<div
key={`${first.id}:${tab.selection.length}`}
className="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-4"
className="min-h-0 flex-1 overflow-y-auto px-4 py-4"
>
<PositionSection
element={first}
selection={tab.selection}
onAlign={onAlign}
onInset={onInset}
onAbsolute={onAbsolute}
/>
<LayoutSection
element={first}
{...sectionProps}
onSizeMode={setSizeMode}
onAspectLock={onAspectLock}
/>
<MarginSection element={first} {...sectionProps} />
<AppearanceSection element={first} {...sectionProps} />
<TypographySection element={first} {...sectionProps} tokens={tab.tokens} />
<FillSection element={first} {...sectionProps} tokens={tab.tokens} />
<StrokeSection element={first} {...sectionProps} tokens={tab.tokens} />
{unaddressable ? (
<p
className="mb-4 rounded-md bg-[var(--fork-design-field)] px-3 py-2 text-xs leading-relaxed text-muted-foreground"
data-fork-design-unaddressable-note
>
{tab.selection.length === 1 ? "This element" : "These elements"} can&apos;t be traced
to code — no source location, file, or component resolved — so the values below are
read-only.
</p>
) : null}
{/* Read-only, not inert: the mutating callbacks above are the real gate (they
write to addressable ids only, none here), and theme.custom.css turns off
pointer events for inputs and non-disclosure buttons under this marker —
NOT a disabled fieldset, which would also disable the Expando/section
disclosure buttons and make collapsed values unreadable, contradicting the
note (PR #72 review). Disclosures keep working via their aria-expanded. */}
<div
data-fork-design-readonly={unaddressable ? "" : undefined}
aria-disabled={unaddressable ? "true" : undefined}
className={cn("space-y-5", unaddressable && "opacity-60")}
>
<PositionSection
element={first}
selection={tab.selection}
onAlign={onAlign}
onInset={onInset}
onAbsolute={onAbsolute}
/>
<LayoutSection
element={first}
{...sectionProps}
onSizeMode={setSizeMode}
onAspectLock={onAspectLock}
/>
<MarginSection element={first} {...sectionProps} />
<AppearanceSection element={first} {...sectionProps} />
<TypographySection element={first} {...sectionProps} tokens={tab.tokens} />
<FillSection element={first} {...sectionProps} tokens={tab.tokens} />
<StrokeSection element={first} {...sectionProps} tokens={tab.tokens} />
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center px-4 text-center">
<p className="text-xs leading-relaxed text-muted-foreground">
{tab.sourceMode === "selector-only"
? "Click an element in the preview to edit it. Source mapping isn't available on this page, so changes are sent with selector and text context instead of file locations."
? "Click an element in the preview to inspect it. Source mapping wasn't detected on this page — elements that can't be traced to code are read-only."
: "Click an element in the preview to edit it. Shift-click adds to the selection; double-click edits text."}
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function snapshot(
id,
tag: "div",
sourceLabel: null,
sourceState: "resolved",
styles: { ...full, ...styles },
sizeModes: { width: "fixed", height: "fixed" },
offsets: { x: 0, y: 0 },
Expand Down
47 changes: 46 additions & 1 deletion apps/web/src/custom/designMode/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import { countUnresolvedDesignElements } from "./protocol";
import {
countUnresolvedDesignElements,
DESIGN_MODE_CONSOLE_PREFIX,
DESIGN_MODE_STYLE_KEYS,
parseDesignModeConsoleMessage,
} from "./protocol";

/**
* The unresolved-count rule the pill and send toast surface: an element with a null
Expand Down Expand Up @@ -31,3 +36,43 @@ describe("countUnresolvedDesignElements", () => {
expect(countUnresolvedDesignElements({ elements: [element("")] })).toBe(0);
});
});

/** A structurally complete snapshot the selection parser accepts, for varying one field. */
const snapshot = (overrides: Record<string, unknown> = {}) => ({
id: 1,
tag: "div",
sourceLabel: null,
sourceState: "pending",
styles: Object.fromEntries(DESIGN_MODE_STYLE_KEYS.map((key) => [key, "0px"])),
sizeModes: { width: "fixed", height: "fixed" },
offsets: { x: 0, y: 0 },
positionState: "flow",
alignCaps: { horizontal: true, vertical: true },
drafted: [],
...overrides,
});

const selectionLine = (elements: unknown[]) =>
DESIGN_MODE_CONSOLE_PREFIX + JSON.stringify({ type: "selection", elements });

describe("selection snapshot sourceState", () => {
it.each(["resolved", "pending", "anonymous"])("accepts %s", (state) => {
const message = parseDesignModeConsoleMessage(
selectionLine([snapshot({ sourceState: state })]),
);
expect(message?.type).toBe("selection");
expect(message?.type === "selection" && message.elements[0]?.sourceState).toBe(state);
});

it("rejects a snapshot without a sourceState — the disable gate must always get an answer", () => {
const bare = snapshot();
delete (bare as Record<string, unknown>).sourceState;
expect(parseDesignModeConsoleMessage(selectionLine([bare]))).toBeNull();
});

it("rejects an unknown sourceState value", () => {
expect(
parseDesignModeConsoleMessage(selectionLine([snapshot({ sourceState: "maybe" })])),
).toBeNull();
});
});
Loading
Loading