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
21 changes: 19 additions & 2 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/__fork_guards__/forkDesignMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/__fork_guards__/forkGlassSurfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
55 changes: 33 additions & 22 deletions apps/web/src/custom/designMode/ForkLayersTree.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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<typeof Button>) {
return (
<Tooltip>
<TooltipTrigger
render={<Button variant="ghost" size="icon-xs" aria-label={label} {...props} />}
/>
<TooltipPopup>{label}</TooltipPopup>
</Tooltip>
);
}

/**
* 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
Expand Down Expand Up @@ -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 ? (
<button
type="button"
title="Clear filter"
aria-label="Clear filter"
onClick={() => setQuery("")}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground [&_svg]:size-3"
>
<RailIconButton label="Clear filter" onClick={() => setQuery("")}>
<XIcon />
</button>
</RailIconButton>
) : (
<button
type="button"
title="Collapse all"
aria-label="Collapse all"
<RailIconButton
label="Collapse all"
onClick={() => setExpanded(Object.fromEntries(rows.map((row) => [row.node.id, false])))}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground [&_svg]:size-3.5"
>
<ChevronsDownUpIcon />
</button>
</RailIconButton>
)}
<button
type="button"
title="Hide layers"
aria-label="Hide layers"
<RailIconButton
label="Hide layers"
aria-expanded={true}
aria-controls={LAYERS_RAIL_ID}
data-fork-design-layers-hide
Expand All @@ -372,10 +384,9 @@ function LayersRail({ runtimeTabId }: { runtimeTabId: string }) {
// This button goes with the rail — hand focus to the control that brings it back.
focusLayersControl(LAYERS_SHOW_BUTTON);
}}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground [&_svg]:size-3.5"
>
<PanelLeftCloseIcon />
</button>
</RailIconButton>
</header>
<div
ref={listRef}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/custom/designMode/designModeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ export const useDesignModeStore = create<DesignModeStoreState>()((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,
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/custom/designMode/designPanelRoom.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
83 changes: 83 additions & 0 deletions apps/web/src/custom/designMode/designPanelRoom.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, Readonly<Record<string, number>>>>;
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<DesignPanelRoomState>()((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]);
}
39 changes: 39 additions & 0 deletions apps/web/src/custom/designMode/engine/boot.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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 = `
<main data-dc-source="src/App.tsx:1:1">
<h1 data-dc-source="src/App.tsx:2:3">Title</h1>
</main>`;
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"]);
});
Loading
Loading