From 9758b20d87f295af11202ac34f9733aa9966c52e Mon Sep 17 00:00:00 2001 From: x Date: Fri, 30 Oct 2026 11:45:49 -0400 Subject: [PATCH 1/4] fix(web): bring design mode back in line with the fork's design system - The screen-size menu's divider runs full width (-mx-1), per the full-width divider rule. - The panel's segment strips take Glass's selected-tab chip finish, like every other selected tab; the hairline gives way to the glass edge. - The layers rail header buttons are the ghost icon-xs Button: the 24px box and hover fill every icon-only control in the chrome carries. - The on-scale spacing badge lights in the design green rather than --primary, which is white in dark themes. Co-Authored-By: Claude Opus 5.5 --- .fork/customizations.yaml | 7 ++++-- .../__fork_guards__/forkGlassSurfaces.test.ts | 9 +++++++ .../src/custom/designMode/ForkLayersTree.tsx | 25 +++++++++++-------- .../designMode/panel/DesignPanelFields.tsx | 2 +- .../panel/DesignPanelLayoutControls.tsx | 1 + .../designMode/panel/ScreenSizeMenu.tsx | 2 +- apps/web/src/theme.custom.glass-surfaces.css | 8 ++++++ 7 files changed, 40 insertions(+), 14 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index a7a97d615cc63..b097739d4c858 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 (its hairline dropped for the glass edge); 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-design-segment 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 diff --git a/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts b/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts index 720c3d1fce3e2..26bcf3894fff9 100644 --- a/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts +++ b/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts @@ -202,6 +202,15 @@ 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")).toContain( + "data-fork-design-segment\n", + ); + expect( + ruleBodyFor(rules, [ + `${MARKER}.dark${PALETTE}`, + '[data-fork-design-segment][aria-pressed="true"]', + ]), + ).toContain("var(--fork-glass-light)"); for (const source of [ "../components/chat/ChatComposer.tsx", "../custom/voice/ForkDictationControl.tsx", diff --git a/apps/web/src/custom/designMode/ForkLayersTree.tsx b/apps/web/src/custom/designMode/ForkLayersTree.tsx index 57424d8591241..fefa3b960c28b 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, @@ -9,6 +8,7 @@ import { } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; import { designModeBridge } from "./designModeBridge"; @@ -339,28 +339,34 @@ function LayersRail({ runtimeTabId }: { runtimeTabId: string }) { aria-label="Filter layers" className="h-6 w-full min-w-0 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" /> + {/* The ghost icon-xs Button, like the show-layers toggle this pairs with: the 24px + box and hover fill every icon-only control in the fork's chrome carries. */} {query ? ( - + ) : ( - + )} - +
diff --git a/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx b/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx index 5ef5ecd79de16..d53f9b127734e 100644 --- a/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx +++ b/apps/web/src/custom/designMode/panel/DesignPanelLayoutControls.tsx @@ -46,6 +46,7 @@ export function SegmentField({ type="button" title={option.title} aria-pressed={selected ? "true" : "false"} + data-fork-design-segment 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", 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({ ))} - + Date: Fri, 30 Oct 2026 11:51:38 -0400 Subject: [PATCH 2/4] fix(web): give the layers rail real tooltips and share the tab glass rule Review follow-up on the design-mode alignment pass. - The rail's header buttons rendered native title tooltips through Button's prop passthrough, which the lint rule cannot see. They now use the Tooltip pattern of the show-layers toggle they pair with, through a small RailIconButton helper. - The panel's segment strip stamps SegmentedTab's marker and data-pressed, so the existing selected-tab Glass rule covers it. Its hairline is an inset ring that rule replaces, so no theme sheet undoes a border and the duplicate rule, marker and guard assertions are gone. Co-Authored-By: Claude Opus 5.5 --- .fork/customizations.yaml | 6 +- .../__fork_guards__/forkDesignMode.test.ts | 4 +- .../__fork_guards__/forkGlassSurfaces.test.ts | 10 +--- .../src/custom/designMode/ForkLayersTree.tsx | 58 ++++++++++--------- .../panel/DesignPanelLayoutControls.tsx | 10 +++- apps/web/src/theme.custom.glass-surfaces.css | 12 +--- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index b097739d4c858..7603d989f707d 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -1655,8 +1655,8 @@ 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, as is the design panel's - segment strip (its hairline dropped for the glass edge); outline pickers - and a lone segmented Toggle keep their look. The rules live in + 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 @@ -1700,7 +1700,7 @@ - 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-design-segment on the design panel's segment strip. + # 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 diff --git a/apps/web/src/__fork_guards__/forkDesignMode.test.ts b/apps/web/src/__fork_guards__/forkDesignMode.test.ts index ab5c70c83dbf7..af17343aee972 100644 --- a/apps/web/src/__fork_guards__/forkDesignMode.test.ts +++ b/apps/web/src/__fork_guards__/forkDesignMode.test.ts @@ -219,7 +219,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 26bcf3894fff9..3c8b143807c9b 100644 --- a/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts +++ b/apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts @@ -202,15 +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")).toContain( - "data-fork-design-segment\n", + expect(readSibling("../custom/designMode/panel/DesignPanelLayoutControls.tsx")).toMatch( + /data-fork-segmented-tab\s+data-pressed=/u, ); - expect( - ruleBodyFor(rules, [ - `${MARKER}.dark${PALETTE}`, - '[data-fork-design-segment][aria-pressed="true"]', - ]), - ).toContain("var(--fork-glass-light)"); for (const source of [ "../components/chat/ChatComposer.tsx", "../custom/voice/ForkDictationControl.tsx", diff --git a/apps/web/src/custom/designMode/ForkLayersTree.tsx b/apps/web/src/custom/designMode/ForkLayersTree.tsx index fefa3b960c28b..01cf97aaf1d20 100644 --- a/apps/web/src/custom/designMode/ForkLayersTree.tsx +++ b/apps/web/src/custom/designMode/ForkLayersTree.tsx @@ -6,9 +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 @@ -339,37 +362,20 @@ function LayersRail({ runtimeTabId }: { runtimeTabId: string }) { aria-label="Filter layers" className="h-6 w-full min-w-0 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" /> - {/* The ghost icon-xs Button, like the show-layers toggle this pairs with: the 24px - box and hover fill every icon-only control in the fork's chrome carries. */} {query ? ( - + ) : ( - + )} - +
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/theme.custom.glass-surfaces.css b/apps/web/src/theme.custom.glass-surfaces.css index cff6d888b593a..875080626d383 100644 --- a/apps/web/src/theme.custom.glass-surfaces.css +++ b/apps/web/src/theme.custom.glass-surfaces.css @@ -190,7 +190,9 @@ box-shadow: var(--fork-glass-light); } - /* A SegmentedTab has no border, so its inset light lands on the edge. */ + /* A SegmentedTab has no border, so its inset light lands on the edge. The + design panel's segment strip stamps the same marker, and its hairline is + an inset ring this shadow replaces. */ &.dark[data-fork-theme="cool-darker"] [data-fork-segmented-tab][data-pressed] { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), @@ -203,14 +205,6 @@ overflow-clip-margin: 1px; } - /* The design panel's segment strips are tabs on a field track too. Their - hairline goes: the glass edge replaces it, and a light drawn inside a - 1px border would sit a pixel in from the edge. */ - &.dark[data-fork-theme="cool-darker"] [data-fork-design-segment][aria-pressed="true"] { - border-width: 0; - box-shadow: var(--fork-glass-halo-soft), var(--fork-glass-light); - } - /* The composer as the same glass: the vessel is the pane, with the halo round it and the glint, sheens and the popups' top and bottom glows on a ::after laid over everything inside. Over, because the prompt well covers From d530d7dc6292413d59d842fb46f1724a23b39a17 Mon Sep 17 00:00:00 2001 From: x Date: Fri, 30 Oct 2026 12:25:39 -0400 Subject: [PATCH 3/4] fix(web): design mode's layers rail always appears, and the panel gets room - The engine re-sends the layers tree after `state {active: true}`, like tokens and canvas already do. Its first emit lands before `state`, and the host's enable reset wiped it, so the rail could stay missing until the page next mutated. A browser test boots the real engine and checks a layers message follows the state flip. - While a visible preview tab has design mode on, it asks the right panel for the room its chrome takes (panel + preview chrome row + layers rail when shown). ChatView passes that to the panel width hook as a floor, capped by the hook's own maximum. The user's dragged width is never written, so turning design mode off restores it. At the default width the 325px panel used to overflow the window and clip Send to chat. Co-Authored-By: Claude Opus 5.5 --- .fork/customizations.yaml | 14 ++++ .../__fork_guards__/forkDesignMode.test.ts | 20 +++++ apps/web/src/components/ChatView.tsx | 7 ++ .../src/custom/designMode/designModeStore.ts | 4 +- .../custom/designMode/designPanelRoom.test.ts | 34 ++++++++ .../src/custom/designMode/designPanelRoom.ts | 83 +++++++++++++++++++ .../designMode/engine/boot.browser.test.ts | 39 +++++++++ apps/web/src/custom/designMode/engine/boot.ts | 5 +- .../custom/designMode/engine/headlessMode.ts | 6 ++ .../custom/designMode/engine/layersSession.ts | 9 ++ .../src/hooks/usePreviewPanelInlineSize.ts | 9 +- .../components/preview/PreviewPanel.tsx | 3 + 12 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/custom/designMode/designPanelRoom.test.ts create mode 100644 apps/web/src/custom/designMode/designPanelRoom.ts create mode 100644 apps/web/src/custom/designMode/engine/boot.browser.test.ts diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 7603d989f707d..09971439c708f 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -4027,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 @@ -4116,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 af17343aee972..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 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/designModeStore.ts b/apps/web/src/custom/designMode/designModeStore.ts index 8cba9802569f7..d9409e34ce05f 100644 --- a/apps/web/src/custom/designMode/designModeStore.ts +++ b/apps/web/src/custom/designMode/designModeStore.ts @@ -90,8 +90,8 @@ export const useDesignModeStore = create()((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..db27fccb99351 --- /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). */ +export 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/hooks/usePreviewPanelInlineSize.ts b/apps/web/src/hooks/usePreviewPanelInlineSize.ts index 1e9de0a3cd017..6673932cbe014 100644 --- a/apps/web/src/hooks/usePreviewPanelInlineSize.ts +++ b/apps/web/src/hooks/usePreviewPanelInlineSize.ts @@ -25,13 +25,20 @@ export function usePreviewPanelInlineSize( readonly widthStorageKey?: string | undefined; readonly defaultWidth?: number | undefined; readonly container?: HTMLElement | null | undefined; + /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */ + readonly minWidth?: number | undefined; + /* fork:end fork-design-mode */ } = {}, ): PreviewPanelInlineSize { const maxWidth = useViewportClampedMaxWidth(hostRef, options.enabled ?? true, options.container); return useResizableWidth({ storageKey: options.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: options.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, - minWidth: PREVIEW_PANEL_MIN_WIDTH, + /* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode + A caller's floor (design mode's chrome) yields to the cap, so the sibling column + keeps its own minimum on a window too narrow for both. */ + minWidth: Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(options.minWidth ?? 0, maxWidth)), + /* fork:end fork-design-mode */ maxWidth, edge: "left", }); diff --git a/apps/web/src/overrides/components/preview/PreviewPanel.tsx b/apps/web/src/overrides/components/preview/PreviewPanel.tsx index 333f11062bbc0..c7e474453a690 100644 --- a/apps/web/src/overrides/components/preview/PreviewPanel.tsx +++ b/apps/web/src/overrides/components/preview/PreviewPanel.tsx @@ -12,6 +12,7 @@ import { usePreviewAvailable } from "~/browser/previewRuntime"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { PreviewPanelShell, type PreviewPanelMode } from "~/components/preview/PreviewPanelShell"; import { PreviewView } from "~/components/preview/PreviewView"; +import { usePublishDesignPanelRoom } from "~/custom/designMode/designPanelRoom"; import { ForkLayersTree } from "~/custom/designMode/ForkLayersTree"; import { ForkDesignPanel } from "~/custom/designMode/panel/ForkDesignPanel"; import { useThreadPreviewState } from "~/previewStateStore"; @@ -43,6 +44,8 @@ export function PreviewPanel({ const runtimeTabId = activeTabId ? previewRuntimeTabId(threadRef, previewState.serverEpoch, activeTabId) : null; + // Design mode needs more room than the panel's default width; ChatView widens it. + usePublishDesignPanelRoom(threadRef, runtimeTabId, visible); const available = usePreviewAvailable(threadRef.environmentId); const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); if (!canOperatePreview || !available) { From 1a5800f0dc8d08d3b406305212f37846af1d8b67 Mon Sep 17 00:00:00 2001 From: x Date: Fri, 30 Oct 2026 12:29:17 -0400 Subject: [PATCH 4/4] chore(web): keep the preview width floor private to the room module knip flags it as an unused export; only the panel and rail widths are read outside designPanelRoom.ts. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/custom/designMode/designPanelRoom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/custom/designMode/designPanelRoom.ts b/apps/web/src/custom/designMode/designPanelRoom.ts index db27fccb99351..27c0725a4a879 100644 --- a/apps/web/src/custom/designMode/designPanelRoom.ts +++ b/apps/web/src/custom/designMode/designPanelRoom.ts @@ -12,7 +12,7 @@ export const DESIGN_PANEL_WIDTH_PX = 325; 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). */ -export const PREVIEW_MIN_WIDTH_PX = 300; +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 {