diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c405b7a9efa9..918d28845068 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { + clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, - THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { @@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); + const [brandWidth, setBrandWidth] = useState(0); + const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : false; }); const sidebarProviderStyle = { - "--sidebar-width": `${sidebarWidth}px`, + "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } @@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > + nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 29d1ae115d92..ce2ef2629549 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -50,7 +50,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
@@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ variant={backdropVariant ? "media-navigation" : "ghost"} className="relative top-auto z-10 translate-y-0 md:hidden" /> - - {pillLabel ? ( - - {pillLabel} - - ) : null} + {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. + The padding keeps the brand's focus ring inside the clip. */} +
+ + {pillLabel ? ( +
+ + {pillLabel} + +
+ ) : null} +
); }); +// Measures the brand at its titlebar inset, plus the header's right padding and the +// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls. +export function SidebarBrandWidthProbe({ + onWidthChange, +}: { + onWidthChange: (width: number) => void; +}) { + const observeWidth = useCallback( + (probe: HTMLDivElement) => { + const observer = new ResizeObserver(([entry]) => { + if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); + }); + observer.observe(probe); + return () => observer.disconnect(); + }, + [onWidthChange], + ); + + return ( + + ); +} + function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( - {/* Center the visible capitals, without the font's ascender/descender space. */} - - - - Code - - + ); } +function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { + return ( + // Center the visible capitals, without the font's ascender/descender space. + + + + Code + + + ); +} + function SidebarUtilityItem({ icon, label, diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts new file mode 100644 index 000000000000..41f6b97c9283 --- /dev/null +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + clampThreadSidebarWidth, + resolveThreadSidebarMaximumWidth, + resolveThreadSidebarMinimumWidth, + THREAD_SIDEBAR_MIN_WIDTH, +} from "./threadSidebarWidth"; + +describe("resolveThreadSidebarMinimumWidth", () => { + it("keeps the default minimum when the brand fits", () => { + expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH); + }); + + it("grows to a brand wider than the default, rounding up", () => { + expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238); + }); +}); + +describe("resolveThreadSidebarMaximumWidth", () => { + it("never drops below a raised minimum on a narrow viewport", () => { + expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238); + expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560); + }); +}); + +describe("clampThreadSidebarWidth", () => { + it("widens a stored width below a raised minimum", () => { + expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238); + }); + + it("keeps widths inside the range and caps wide ones", () => { + expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300); + expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560); + }); +}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index 27589d8f200c..ea9967c1f06d 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -3,11 +3,24 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { - return Math.max( - THREAD_SIDEBAR_MIN_WIDTH, - Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, - ); +// The brand's measured width can raise the minimum so "T3 Code" never clips. +export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { + return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth)); +} + +export function resolveThreadSidebarMaximumWidth( + viewportWidth: number, + minimumWidth = THREAD_SIDEBAR_MIN_WIDTH, +): number { + return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH); +} + +export function clampThreadSidebarWidth( + width: number, + minimumWidth: number, + maximumWidth: number, +): number { + return Math.min(maximumWidth, Math.max(minimumWidth, width)); } export function resolveInitialThreadSidebarWidth(