From 559297c8c9b88fc7256c2f8eba5106929d8dc0c6 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sun, 4 Oct 2026 01:23:54 +0000 Subject: [PATCH] fix(web): keep sidebar branding and build pills from clipping at varying font sizes and zoom levels The environment pill now wraps out of the header's single visible line when it no longer fits, replacing a fixed 15rem container query. A hidden probe measures the brand at its titlebar inset, so the sidebar minimum width grows when font size, zoom or the macOS window controls would clip "T3 Code". --- apps/web/src/components/AppSidebarLayout.tsx | 14 +-- .../src/components/sidebar/SidebarChrome.tsx | 86 +++++++++++++------ .../src/components/threadSidebarWidth.test.ts | 36 ++++++++ apps/web/src/components/threadSidebarWidth.ts | 23 +++-- 4 files changed, 125 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/threadSidebarWidth.test.ts 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(