diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index d82430c0fb1..b520cc12b68 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -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, diff --git a/.fork/notes/design-send-unresolved/panel-after.png b/.fork/notes/design-send-unresolved/panel-after.png new file mode 100644 index 00000000000..fd325bafb87 Binary files /dev/null and b/.fork/notes/design-send-unresolved/panel-after.png differ diff --git a/.fork/notes/design-send-unresolved/panel-before.png b/.fork/notes/design-send-unresolved/panel-before.png new file mode 100644 index 00000000000..c7ac3d71a1e Binary files /dev/null and b/.fork/notes/design-send-unresolved/panel-before.png differ diff --git a/apps/web/src/__fork_guards__/forkDesignMode.test.ts b/apps/web/src/__fork_guards__/forkDesignMode.test.ts index ccc7494c2cd..f38272b2814 100644 --- a/apps/web/src/__fork_guards__/forkDesignMode.test.ts +++ b/apps/web/src/__fork_guards__/forkDesignMode.test.ts @@ -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 }, diff --git a/apps/web/src/custom/designMode/designModeStore.ts b/apps/web/src/custom/designMode/designModeStore.ts index 966793b834d..8cba9802569 100644 --- a/apps/web/src/custom/designMode/designModeStore.ts +++ b/apps/web/src/custom/designMode/designModeStore.ts @@ -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; diff --git a/apps/web/src/custom/designMode/engine/headlessMode.ts b/apps/web/src/custom/designMode/engine/headlessMode.ts index 6bc6141fe20..42530d41c28 100644 --- a/apps/web/src/custom/designMode/engine/headlessMode.ts +++ b/apps/web/src/custom/designMode/engine/headlessMode.ts @@ -35,6 +35,7 @@ import { LayersSession } from "./layersSession"; import { basename, findSelectableElement, type TaggedElement } from "./vendor/source"; import { awaitResolutions, + hasSettledUntagged, isSynthesizedSource, markSynthesizedSource, resolveAndTag, @@ -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 | 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). diff --git a/apps/web/src/custom/designMode/engine/nativeSource.ts b/apps/web/src/custom/designMode/engine/nativeSource.ts index 6ef513261a8..ada71f11b8c 100644 --- a/apps/web/src/custom/designMode/engine/nativeSource.ts +++ b/apps/web/src/custom/designMode/engine/nativeSource.ts @@ -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>(); +/** 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(); + +/** Whether a native-source attempt for `el` has settled without tagging it. */ +export function hasSettledUntagged(el: TaggedElement): boolean { + return settledUntagged.has(el); +} + /** 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. */ @@ -138,7 +151,15 @@ export function resolveAndTag(el: TaggedElement): Promise { 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 { @@ -175,6 +196,7 @@ export function resolveAndTag(el: TaggedElement): Promise { })(); attempts.set(el, attempt); void attempt.then((tagged) => { + if (!tagged) settledUntagged.add(el); if (!tagged && attempts.get(el) === attempt) attempts.delete(el); }); return attempt; diff --git a/apps/web/src/custom/designMode/engine/snapshot.ts b/apps/web/src/custom/designMode/engine/snapshot.ts index 98f22616f4d..543e95715d4 100644 --- a/apps/web/src/custom/designMode/engine/snapshot.ts +++ b/apps/web/src/custom/designMode/engine/snapshot.ts @@ -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 + * 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"; +} + /** 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 } { @@ -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), diff --git a/apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx b/apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx index 91a8fc1a8a6..4599e240590 100644 --- a/apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx +++ b/apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx @@ -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"; @@ -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( @@ -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>)[property] ?? null) @@ -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(), ); @@ -285,32 +300,54 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) { // Keyed by selection identity so field-local input state resets per selection.
- - - - - - - + {unaddressable ? ( +

+ {tab.selection.length === 1 ? "This element" : "These elements"} can't be traced + to code — no source location, file, or component resolved — so the values below are + read-only. +

+ ) : 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. */} +
+ + + + + + + +
) : (

{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."}

diff --git a/apps/web/src/custom/designMode/panel/selectionValues.test.ts b/apps/web/src/custom/designMode/panel/selectionValues.test.ts index 89c12a08b05..c8a126d6b22 100644 --- a/apps/web/src/custom/designMode/panel/selectionValues.test.ts +++ b/apps/web/src/custom/designMode/panel/selectionValues.test.ts @@ -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 }, diff --git a/apps/web/src/custom/designMode/protocol.test.ts b/apps/web/src/custom/designMode/protocol.test.ts index 92fb3021919..55c979992da 100644 --- a/apps/web/src/custom/designMode/protocol.test.ts +++ b/apps/web/src/custom/designMode/protocol.test.ts @@ -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 @@ -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 = {}) => ({ + 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).sourceState; + expect(parseDesignModeConsoleMessage(selectionLine([bare]))).toBeNull(); + }); + + it("rejects an unknown sourceState value", () => { + expect( + parseDesignModeConsoleMessage(selectionLine([snapshot({ sourceState: "maybe" })])), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/custom/designMode/protocol.ts b/apps/web/src/custom/designMode/protocol.ts index 27bf7e2332c..582bb168f94 100644 --- a/apps/web/src/custom/designMode/protocol.ts +++ b/apps/web/src/custom/designMode/protocol.ts @@ -32,7 +32,7 @@ export const DESIGN_MODE_GLOBAL = "__T3_DESIGN_MODE__"; * contract had grown) were rejected wholesale by the stricter parser, so selection simply * stopped updating with nothing in the UI to say why (PR #57 review). */ -export const DESIGN_MODE_PROTOCOL_VERSION = 4; +export const DESIGN_MODE_PROTOCOL_VERSION = 5; /** The computed-style properties the native panel renders (READ keys), in section * order. The guest snapshot carries exactly these keys (engine/snapshot.ts); the panel @@ -145,6 +145,9 @@ export interface DesignModeAlignCaps { readonly vertical: boolean; } +export const DESIGN_MODE_SOURCE_STATES = ["resolved", "pending", "anonymous"] as const; +export type DesignModeSourceState = (typeof DESIGN_MODE_SOURCE_STATES)[number]; + /** One selected element as the native panel sees it. `id` is minted by the guest engine * and is only meaningful for the current selection — commands referencing a stale id * no-op. `sourceLabel` is "file.tsx:12" when the element carries a data-dc-source tag. */ @@ -152,6 +155,17 @@ export interface DesignModeElementSnapshot { readonly id: number; readonly tag: string; readonly sourceLabel: string | null; + /** What the engine knows about addressing this element in code. `resolved` — a tag, a + * source file, or at least a component name exists (all three give the agent something + * real to act on). `pending` — no attempt has settled yet; stay editable rather than + * flicker. `anonymous` — a native-source attempt SETTLED and produced nothing at all: + * the one state where the panel disables editing, because an edit could only ever ship + * an anonymous selector. Late resolution re-emits the snapshot, so `unresolved` can + * still upgrade (retries stay allowed by design — React metadata can mount late). + * Distinct on purpose from countUnresolvedDesignElements' rule (sourceLabel null = + * imprecise SEND), which includes component/file-only elements this field calls + * resolved (PR #72 review). */ + readonly sourceState: DesignModeSourceState; readonly styles: Readonly>; /** Current W/H sizing modes (engine/sizeMode.ts) — drives the panel's per-axis menu. */ readonly sizeModes: { readonly width: DesignModeSizeMode; readonly height: DesignModeSizeMode }; @@ -378,6 +392,7 @@ function parseElementSnapshot(value: unknown): DesignModeElementSnapshot | null !isNonNegativeInteger(value.id) || typeof value.tag !== "string" || (value.sourceLabel !== null && typeof value.sourceLabel !== "string") || + !DESIGN_MODE_SOURCE_STATES.some((state) => state === value.sourceState) || !isStyleMap(value.styles) || !isRecord(value.sizeModes) || !isRecord(value.offsets) || @@ -394,11 +409,13 @@ function parseElementSnapshot(value: unknown): DesignModeElementSnapshot | null const width = parseSizeMode(value.sizeModes.width); const height = parseSizeMode(value.sizeModes.height); const positionState = POSITION_STATES.find((state) => state === value.positionState); - if (width === null || height === null || !positionState) return null; + const sourceState = DESIGN_MODE_SOURCE_STATES.find((state) => state === value.sourceState); + if (width === null || height === null || !positionState || !sourceState) return null; return { id: value.id, tag: value.tag, sourceLabel: value.sourceLabel, + sourceState, styles: value.styles, sizeModes: { width, height }, offsets: { x: value.offsets.x, y: value.offsets.y }, diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 13e63dcd254..2cd072016d1 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -1519,3 +1519,22 @@ --fork-design-accent: #45ff96; --fork-design-accent-bg: rgb(69 255 150 / 16%); } + +/* ── Design panel read-only affordance (fork-design-mode) ────────────────────── + An unaddressable selection (every element's sourceState is `anonymous`) renders + inside [data-fork-design-readonly]. The real mutation gate is the panel's + callbacks; these rules are the affordance layer: inputs and mutating buttons + stop taking the pointer, while disclosure buttons — identified by the + aria-expanded the ARIA pattern requires of them — keep working so collapsed + values stay READABLE, which a disabled fieldset would prevent (PR #72 review). + The :first-child rule reaches the field labels' prefix cell, the span that + doubles as the scrub handle. */ +:root[data-fork="noahhendrickson-t3code"] + [data-fork-design-readonly] + :is(input, select, textarea, button:not([aria-expanded])) { + pointer-events: none; +} + +:root[data-fork="noahhendrickson-t3code"] [data-fork-design-readonly] label > :first-child { + pointer-events: none; +}