diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index a7a97d615cc63..09971439c708f 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1654,8 +1654,9 @@ menu, the liveness list, the thread sort and settings menus, the thread details card) carry data-fork-glass-rim. The selected segment of every segmented toggle group (tabs, and the pickers drawn as tabs) and of the - fork's SegmentedTabs is one more chip of it; outline pickers and a lone - segmented Toggle keep their look. The rules live in + fork's SegmentedTabs is one more chip of it, as is the design panel's + segment strip, which stamps SegmentedTab's marker; outline pickers and a + lone segmented Toggle keep their look. The rules live in theme.custom.glass-surfaces.css, loaded after the palettes sheet. The send button (Figma 467:22121) has a translucent white face, white glyph and bright inset rim. Its highlight hues follow the existing @@ -1699,6 +1700,8 @@ - apps/web/src/components/ui/toggle-group.tsx # Stamps data-fork-segmented-tabs and data-fork-segmented-tab. - apps/web/src/custom/SegmentedTabs.tsx + # Stamps data-fork-segmented-tab on the design panel's segment strip. + - apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx verify: - apps/web/src/__fork_guards__/forkCoolDarkerTheme.test.ts - apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts @@ -4024,6 +4027,18 @@ surface must stay driven by --fork-design-surface for that to reach it — a fill hardcoded onto the container sails past the glass block entirely, which is exactly how the panel read as a slab while the rail beside it went glassy. + + Design mode needs more room than the right panel's default width: the + 325px panel, the preview's own chrome row (which clips its trailing + controls, the design toggle among them, below ~290px) and the 208px + layers rail when it shows. While a visible preview tab has design mode + on, it publishes that width (custom/designMode/designPanelRoom.ts) and + ChatView passes it to usePreviewPanelInlineSize as a minimum, capped by + the hook's own maximum so the chat column keeps its floor. The width the + user dragged to is never written: the hook clamps at render, so turning + design mode off hands that width straight back. Each tab publishes under + its own key and the thread takes the largest, so a hidden tab can never + clear the visible one's request. tier: 4 files: - apps/web/src/custom/designMode/protocol.ts @@ -4113,6 +4128,8 @@ shadows: - apps/web/src/components/preview/PreviewPanel.tsx watch: + # Fenced minWidth option: design mode's floor for the right panel width. + - apps/web/src/hooks/usePreviewPanelInlineSize.ts # Fenced queued-run edit: upstream moved queued-message editing out of # ChatView (#20304); the design-change run split/re-append followed it. - apps/web/src/components/chat/useQueuedRunEdit.ts diff --git a/apps/web/src/__fork_guards__/forkDesignMode.test.ts b/apps/web/src/__fork_guards__/forkDesignMode.test.ts index ab5c70c83dbf7..d11d65b7aa049 100644 --- a/apps/web/src/__fork_guards__/forkDesignMode.test.ts +++ b/apps/web/src/__fork_guards__/forkDesignMode.test.ts @@ -31,6 +31,7 @@ import { summarizeDesignChangeBlock, } from "../custom/designMode/designChangeTranscript"; import { resolveBrowserDeviceViewportArea } from "../browser/browserViewportLayout"; +import { DESIGN_PANEL_WIDTH_PX, LAYERS_RAIL_WIDTH_PX } from "../custom/designMode/designPanelRoom"; import { CANVAS_RESOLUTIONS, resolutionForViewport, @@ -186,6 +187,25 @@ describe("fork guard: design mode", () => { ).toBeNull(); }); + it("widens the right panel to the room the docked design chrome takes", () => { + // The widths design mode asks for are the widths its columns render at. + expect(read("src/custom/designMode/panel/ForkDesignPanel.tsx")).toContain( + `w-[${DESIGN_PANEL_WIDTH_PX}px]`, + ); + expect(LAYERS_RAIL_WIDTH_PX).toBe(52 * 4); + expect(read("src/custom/designMode/ForkLayersTree.tsx")).toContain('"flex w-52 shrink-0'); + // The visible preview publishes its need, ChatView hands it to the width hook as a + // floor, and the hook caps that floor by its own maximum so the chat column keeps + // its minimum on a narrow window. + expect(read("src/overrides/components/preview/PreviewPanel.tsx")).toContain( + "usePublishDesignPanelRoom(threadRef, runtimeTabId, visible);", + ); + expect(read("src/components/ChatView.tsx")).toContain("minWidth: forkDesignPanelRoom,"); + expect(read("src/hooks/usePreviewPanelInlineSize.ts")).toContain( + "minWidth: Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(options.minWidth ?? 0, maxWidth)),", + ); + }); + it("keeps a way back out of the collapsed layers rail", () => { // A one-way door is the failure mode: collapsed, the rail renders nothing, so the only // control that reopens it would go with it. It moves to the chrome row's leading slot @@ -219,7 +239,9 @@ describe("fork guard: design mode", () => { // while leaving it subscribed would burn that work for a rail the user closed. expect(rail).toContain("if (collapsed || !available || !runtimeTabId) return null;"); expect(rail).toContain("function LayersRail("); - expect(rail).toContain('aria-label="Hide layers"'); + // The rail's icon controls take their accessible name from RailIconButton's label. + expect(rail).toContain('label="Hide layers"'); + expect(rail).toContain("aria-label={label}"); expect(toggle).toContain('aria-label="Show layers"'); expect(toggle).toContain("setCollapsed(false)"); // ...and the slot it mounts into exists on the chrome row and is actually passed. diff --git a/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts b/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts index 720c3d1fce3e2..3c8b143807c9b 100644 --- a/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts +++ b/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts @@ -202,6 +202,9 @@ describe("fork guard: glass surfaces", () => { expect( ruleBodyFor(rules, [`${MARKER}.dark${PALETTE}`, "[data-fork-segmented-tabs]"]), ).toContain("overflow-clip-margin"); + expect(readSibling("../custom/designMode/panel/DesignPanelLayoutControls.tsx")).toMatch( + /data-fork-segmented-tab\s+data-pressed=/u, + ); for (const source of [ "../components/chat/ChatComposer.tsx", "../custom/voice/ForkDictationControl.tsx", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fc97c69b98c5f..8e5989e5aa816 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -298,6 +298,7 @@ import { projectScriptIdFromCommand } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */ import { forkDesignChanges } from "~/custom/designMode/designChangeDraftStore"; +import { useDesignPanelRoom } from "~/custom/designMode/designPanelRoom"; import { extractTrailingDesignChanges, withDesignChangesTrailing, @@ -1740,9 +1741,15 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef?.environmentId ?? null, ); const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser; + /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */ + const forkDesignPanelRoom = useDesignPanelRoom(activeThreadKey); + /* fork:end fork-design-mode */ const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, { container: workspaceLayoutElement, widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`, + /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */ + minWidth: forkDesignPanelRoom, + /* fork:end fork-design-mode */ }); const activeThreadShell = serverThread; const timelineThreadError = diff --git a/apps/web/src/custom/designMode/ForkLayersTree.tsx b/apps/web/src/custom/designMode/ForkLayersTree.tsx index 57424d8591241..01cf97aaf1d20 100644 --- a/apps/web/src/custom/designMode/ForkLayersTree.tsx +++ b/apps/web/src/custom/designMode/ForkLayersTree.tsx @@ -1,4 +1,3 @@ -// oxlint-disable t3code/no-native-title-tooltip -- Vendored design-mode island (see .fork/customizations.yaml#fork-design-mode); native titles kept to stay close to the Forge source. import { ChevronDownIcon, ChevronRight, @@ -7,8 +6,19 @@ import { SearchIcon, XIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type KeyboardEvent, +} from "react"; +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { designModeBridge } from "./designModeBridge"; @@ -34,6 +44,19 @@ import { LayerTypeIcon } from "./panel/LayerTypeIcon"; * "select this" without a per-row closure. */ const TOGGLE_ATTRIBUTE = "data-layer-toggle"; +/** The rail header's icon controls, built like the show-layers toggle they swap with + * (ForkPreviewLayersToggle) so both halves of that disclosure look and hover alike. */ +function RailIconButton({ label, ...props }: { label: string } & ComponentProps) { + return ( + + } + /> + {label} + + ); +} + /** * One row. Memoized on purpose: every interaction on this rail is delegated to the * container, so a row's props are its own id, three booleans and a string — which means a @@ -340,30 +363,19 @@ function LayersRail({ runtimeTabId }: { runtimeTabId: string }) { className="h-6 w-full min-w-0 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" /> {query ? ( - + ) : ( - + )} - +
()((set) => ({ if (current.enabled === enabled) return state; // Toggling off keeps drafts alive in the guest but the panel's world view resets // symmetrically — selection, layers, tokens AND canvas; the engine re-emits all - // four on the next activation (tokens and canvas explicitly after `state`, since - // this reset runs on the `state` flip), so nothing stale survives tab reuse or + // four on the next activation (tokens, canvas and layers explicitly after `state`, + // since this reset runs on the `state` flip), so nothing stale survives tab reuse or // navigations. return patchTab(state, runtimeTabId, { enabled, diff --git a/apps/web/src/custom/designMode/designPanelRoom.test.ts b/apps/web/src/custom/designMode/designPanelRoom.test.ts new file mode 100644 index 0000000000000..6e6bf5d20edd0 --- /dev/null +++ b/apps/web/src/custom/designMode/designPanelRoom.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { designPaneWidth, useDesignPanelRoomStore } from "./designPanelRoom"; + +const room = (threadKey: string) => { + const tabs = useDesignPanelRoomStore.getState().byThreadKey[threadKey]; + return tabs ? Math.max(...Object.values(tabs)) : undefined; +}; + +afterEach(() => useDesignPanelRoomStore.setState({ byThreadKey: {} })); + +describe("design panel room", () => { + it("takes the largest need across a thread's preview tabs", () => { + const { setRoom } = useDesignPanelRoomStore.getState(); + setRoom("thread", "tab-a", designPaneWidth(false)); + setRoom("thread", "tab-b", designPaneWidth(true)); + expect(room("thread")).toBe(designPaneWidth(true)); + }); + + it("keeps another tab's need when one tab clears its own", () => { + const { setRoom } = useDesignPanelRoomStore.getState(); + setRoom("thread", "tab-a", designPaneWidth(true)); + setRoom("thread", "tab-b", designPaneWidth(false)); + setRoom("thread", "tab-b", null); + expect(room("thread")).toBe(designPaneWidth(true)); + setRoom("thread", "tab-a", null); + expect(useDesignPanelRoomStore.getState().byThreadKey).toEqual({}); + }); + + it("scopes needs to their own thread", () => { + useDesignPanelRoomStore.getState().setRoom("thread", "tab-a", designPaneWidth(true)); + expect(room("other-thread")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/custom/designMode/designPanelRoom.ts b/apps/web/src/custom/designMode/designPanelRoom.ts new file mode 100644 index 0000000000000..27c0725a4a879 --- /dev/null +++ b/apps/web/src/custom/designMode/designPanelRoom.ts @@ -0,0 +1,83 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect } from "react"; +import { create } from "zustand"; + +import { selectDesignModeTab, useDesignModeStore } from "./designModeStore"; +import { layersRailAvailable, useLayersCollapsed } from "./layersCollapsed"; + +/** The docked properties panel (ForkDesignPanel's `w-[325px]`). */ +export const DESIGN_PANEL_WIDTH_PX = 325; +/** The layers rail (ForkLayersTree's `w-52`). */ +export const LAYERS_RAIL_WIDTH_PX = 208; +/** The narrowest the preview itself can go before upstream's browser chrome row clips its + * trailing controls, the design-mode toggle among them (measured at 286px). */ +const PREVIEW_MIN_WIDTH_PX = 300; + +/** Width the right panel needs to show the design chrome and a usable preview beside it. */ +export function designPaneWidth(railShown: boolean): number { + return DESIGN_PANEL_WIDTH_PX + PREVIEW_MIN_WIDTH_PX + (railShown ? LAYERS_RAIL_WIDTH_PX : 0); +} + +interface DesignPanelRoomState { + /** Thread key → preview tab → the width that tab's design chrome needs. */ + readonly byThreadKey: Readonly>>>; + readonly setRoom: (threadKey: string, tabId: string, width: number | null) => void; +} + +/** + * The room design mode asks of the right panel. A thread can mount several preview tabs at + * once with only one visible, so each tab publishes its own need and the thread's minimum + * is the largest: a hidden tab that writes nothing can never clear the visible one's. + * See `.fork/customizations.yaml#fork-design-mode`. + */ +export const useDesignPanelRoomStore = create()((set) => ({ + byThreadKey: {}, + setRoom: (threadKey, tabId, width) => + set((state) => { + const tabs = state.byThreadKey[threadKey] ?? {}; + if ((tabs[tabId] ?? null) === width) return state; + const { [tabId]: _previous, ...rest } = tabs; + const next = width === null ? rest : { ...rest, [tabId]: width }; + const { [threadKey]: _thread, ...others } = state.byThreadKey; + return { + byThreadKey: Object.keys(next).length === 0 ? others : { ...others, [threadKey]: next }, + }; + }), +})); + +/** The minimum width design mode needs from this thread's right panel, if any. ChatView + * passes it to the panel's width hook: a raised minimum widens the panel at render without + * touching the width the user dragged to, so clearing it hands that width straight back. */ +export function useDesignPanelRoom(threadKey: string | null): number | undefined { + return useDesignPanelRoomStore((state) => { + const tabs = threadKey === null ? undefined : state.byThreadKey[threadKey]; + if (!tabs) return undefined; + const widths = Object.values(tabs); + return widths.length === 0 ? undefined : Math.max(...widths); + }); +} + +/** Publishes this preview tab's need while it is visible with design mode on. Subscribes to + * one number, not the tab state, so selection and layers traffic never re-render the + * preview panel that calls it. */ +export function usePublishDesignPanelRoom( + threadRef: ScopedThreadRef, + runtimeTabId: string | null, + visible: boolean, +): void { + const [collapsed] = useLayersCollapsed(); + const width = useDesignModeStore((state) => { + if (!visible || runtimeTabId === null) return null; + const tab = selectDesignModeTab(state.byTabId, runtimeTabId); + if (!tab.enabled) return null; + return designPaneWidth(!collapsed && layersRailAvailable(tab, runtimeTabId)); + }); + const threadKey = scopedThreadKey(threadRef); + useEffect(() => { + if (width === null || runtimeTabId === null) return; + const { setRoom } = useDesignPanelRoomStore.getState(); + setRoom(threadKey, runtimeTabId, width); + return () => setRoom(threadKey, runtimeTabId, null); + }, [threadKey, runtimeTabId, width]); +} diff --git a/apps/web/src/custom/designMode/engine/boot.browser.test.ts b/apps/web/src/custom/designMode/engine/boot.browser.test.ts new file mode 100644 index 0000000000000..d76e5988326f6 --- /dev/null +++ b/apps/web/src/custom/designMode/engine/boot.browser.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { + DESIGN_MODE_GLOBAL, + parseDesignModeConsoleMessage, + type DesignModeEngineMessage, +} from "../protocol"; + +afterEach(() => { + const handle = (globalThis as Record)[DESIGN_MODE_GLOBAL] as + | { destroy: () => void } + | undefined; + handle?.destroy(); + document.body.replaceChildren(); +}); + +it("re-sends the layers tree after the state flip that wipes the host's copy", async () => { + document.body.innerHTML = ` +
+

Title

+
`; + const lines: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => { + lines.push(String(line)); + }); + // Importing the bundle entry boots the engine, as injecting it into a guest page does. + await import("./boot"); + log.mockRestore(); + + const messages = lines + .map(parseDesignModeConsoleMessage) + .filter((message): message is DesignModeEngineMessage => message !== null); + const stateOn = messages.findIndex((message) => message.type === "state" && message.active); + expect(stateOn).toBeGreaterThanOrEqual(0); + // The host resets its per-tab layers on every enabled flip (designModeStore.setEnabled), + // so only a tree sent after `state` survives into the rail. + const after = messages.slice(stateOn + 1).find((message) => message.type === "layers"); + expect(after?.type === "layers" ? after.roots.map((root) => root.label) : null).toEqual(["Main"]); +}); diff --git a/apps/web/src/custom/designMode/engine/boot.ts b/apps/web/src/custom/designMode/engine/boot.ts index f13255ca71165..1424d1f34d83b 100644 --- a/apps/web/src/custom/designMode/engine/boot.ts +++ b/apps/web/src/custom/designMode/engine/boot.ts @@ -68,11 +68,12 @@ function boot(): void { }; mode.onStateChange = (active) => { emitToHost({ type: "state", active }); - // Tokens and canvas ride AFTER `state`: the host resets its per-tab world view on - // every enabled flip, so anything emitted before `state` would be wiped by it. + // Tokens, canvas and layers ride AFTER `state`: the host resets its per-tab world view + // on every enabled flip, so anything emitted before `state` would be wiped by it. if (active) { emitTokens(); mode.canvas.reassert(); + mode.reassertLayers(); } }; diff --git a/apps/web/src/custom/designMode/engine/headlessMode.ts b/apps/web/src/custom/designMode/engine/headlessMode.ts index fd7c322cf72a8..50da356c58c88 100644 --- a/apps/web/src/custom/designMode/engine/headlessMode.ts +++ b/apps/web/src/custom/designMode/engine/headlessMode.ts @@ -467,6 +467,12 @@ export class HeadlessDesignMode { // ── Lifecycle ──────────────────────────────────────────────────────────────────────── + /** Re-sends the layers tree past its change gate — boot calls it after `state`, beside + * canvas.reassert(). See LayersSession.reassert. */ + reassertLayers(): void { + this.layers.reassert(); + } + setActive(on: boolean): void { if (on === this.active) { // Re-activating an already-active engine (boot's idempotent same-version path) means diff --git a/apps/web/src/custom/designMode/engine/layersSession.ts b/apps/web/src/custom/designMode/engine/layersSession.ts index f4beecfff4d8a..7d48b0ed3dac2 100644 --- a/apps/web/src/custom/designMode/engine/layersSession.ts +++ b/apps/web/src/custom/designMode/engine/layersSession.ts @@ -92,6 +92,15 @@ export class LayersSession { this.emit(); } + /** Unconditional tree report, bypassing the change gate — canvasSession's reassert, for + * the same reason: start()'s emit lands BEFORE the `state` message, the host wipes its + * layers mirror on every enabled flip (store setEnabled), and the gate would otherwise + * suppress the identical re-emit, leaving the rail missing until the page mutates. */ + reassert(): void { + this.lastJson = ""; + this.emit(); + } + stop(): void { this.settle.stop(); this.registry.clearLayersScope(); diff --git a/apps/web/src/custom/designMode/panel/DesignPanelFields.tsx b/apps/web/src/custom/designMode/panel/DesignPanelFields.tsx index 94c944fe8e65d..41028cb8674b3 100644 --- a/apps/web/src/custom/designMode/panel/DesignPanelFields.tsx +++ b/apps/web/src/custom/designMode/panel/DesignPanelFields.tsx @@ -478,7 +478,7 @@ function SpacingTokenAffordance({ className={cn( "me-1 shrink-0 rounded-[3px] px-1 font-mono text-[9px] leading-4 transition-colors", matched !== null - ? "bg-primary/15 text-primary" + ? "bg-[var(--fork-design-accent-bg)] text-[var(--fork-design-accent)]" : "text-muted-foreground/50 hover:text-foreground", )} > diff --git a/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx b/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx index 5ef5ecd79de16..1c91b7bd5f93a 100644 --- a/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx +++ b/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx @@ -21,7 +21,10 @@ interface SegmentOption { /** Compact segmented control — flex direction, wrap, text-align. Figma's tab strip: * a field-surface track whose selected segment lifts on a white/8% fill with a - * hairline border (see the design tokens in theme.custom.css). */ + * hairline (see the design tokens in theme.custom.css). The selected segment carries + * the fork's segmented-tab marker, so Glass gives it the same chip edge as every other + * selected tab; the hairline is an inset ring rather than a border so that edge can + * replace it without changing the segment's box. */ export function SegmentField({ options, value, @@ -46,11 +49,13 @@ export function SegmentField({ type="button" title={option.title} aria-pressed={selected ? "true" : "false"} + data-fork-segmented-tab + data-pressed={selected ? "" : undefined} onClick={() => onSelect(option.value)} className={cn( "flex h-full flex-1 items-center justify-center rounded px-1 text-xs transition-colors [&_svg]:size-4", selected - ? "border border-border bg-[var(--fork-design-selected)] text-foreground" + ? "bg-[var(--fork-design-selected)] text-foreground shadow-[inset_0_0_0_1px_var(--border)]" : "text-muted-foreground hover:text-foreground", )} > diff --git a/apps/web/src/custom/designMode/panel/ScreenSizeMenu.tsx b/apps/web/src/custom/designMode/panel/ScreenSizeMenu.tsx index 372abdea56f80..ebc8f5838608d 100644 --- a/apps/web/src/custom/designMode/panel/ScreenSizeMenu.tsx +++ b/apps/web/src/custom/designMode/panel/ScreenSizeMenu.tsx @@ -160,7 +160,7 @@ export function ScreenSizeMenu({ ))} - +