authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 19:09:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 23:09:08-03:00
log9efb01690065923820dfa889af5745cc34750091
tree2ce7f8f514cffd89366148fbbc1531d254bad6c3
parent95edeb753b070bad191e10afe15918c969135225
signature Signed by PGP key B5690EEEBB952194

fix(web): keep sidebar branding and build pills from clipping at varying font sizes and zoom levels (#12141)


4 files changed, 125 insertions(+), 34 deletions(-)

apps/web/src/components/AppSidebarLayout.tsx+9-5
...@@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar";...@@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar";
35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
36import ThreadSidebar from "./Sidebar";36import ThreadSidebar from "./Sidebar";
37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { SidebarChromeHeader } from "./sidebar/SidebarChrome";38import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
39import { MainAppLocationTracker } from "./sidebar/mainAppLocation";39import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
40import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";40import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
41import { useProjects } from "../state/entities";41import { useProjects } from "../state/entities";
42import {42import {
43 clampThreadSidebarWidth,
43 resolveInitialThreadSidebarWidth,44 resolveInitialThreadSidebarWidth,
44 resolveThreadSidebarMaximumWidth,45 resolveThreadSidebarMaximumWidth,
46 resolveThreadSidebarMinimumWidth,
45 THREAD_MAIN_CONTENT_MIN_WIDTH,47 THREAD_MAIN_CONTENT_MIN_WIDTH,
46 THREAD_SIDEBAR_MIN_WIDTH,
47 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,48 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
48} from "./threadSidebarWidth";49} from "./threadSidebarWidth";
49import {50import {
...@@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
234 // and a clamped drag ends with an unchanged width, which skips the re-render235 // and a clamped drag ends with an unchanged width, which skips the re-render
235 // that would otherwise refresh a render-time snapshot.236 // that would otherwise refresh a render-time snapshot.
236 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);237 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
237 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth);238 const [brandWidth, setBrandWidth] = useState(0);
239 const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
240 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
238 const resetSidebarWidth = () => {241 const resetSidebarWidth = () => {
239 try {242 try {
240 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);243 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
...@@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
250 : false;253 : false;
251 });254 });
252 const sidebarProviderStyle = {255 const sidebarProviderStyle = {
253 "--sidebar-width": `${sidebarWidth}px`,256 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
254 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,257 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
255 ...(isMacosDesktop && !isWindowFullscreen258 ...(isMacosDesktop && !isWindowFullscreen
256 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }259 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
...@@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
302 defaultOpen305 defaultOpen
303 style={sidebarProviderStyle}306 style={sidebarProviderStyle}
304 >307 >
308 <SidebarBrandWidthProbe onWidthChange={setBrandWidth} />
305 <ProjectProjectionRetention />309 <ProjectProjectionRetention />
306 <Sidebar310 <Sidebar
307 side="left"311 side="left"
...@@ -311,7 +315,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -311,7 +315,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
311 aria-label={isOnSettings ? "Settings" : "Threads"}315 aria-label={isOnSettings ? "Settings" : "Threads"}
312 resizable={{316 resizable={{
313 maxWidth: sidebarMaximumWidth,317 maxWidth: sidebarMaximumWidth,
314 minWidth: THREAD_SIDEBAR_MIN_WIDTH,318 minWidth: sidebarMinimumWidth,
315 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>319 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
316 nextWidth <= currentWidth ||320 nextWidth <= currentWidth ||
317 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,321 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
apps/web/src/components/sidebar/SidebarChrome.tsx+62-24
...@@ -50,7 +50,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -50,7 +50,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
50 // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.50 // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
51 <div51 <div
52 className={cn(52 className={cn(
53 "@container/sidebar-header relative flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center gap-2 px-3 md:px-0",53 "relative flex h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center gap-2 px-3 md:pl-0",
54 isElectron && "drag-region",54 isElectron && "drag-region",
55 )}55 )}
56 >56 >
...@@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
60 variant={backdropVariant ? "media-navigation" : "ghost"}60 variant={backdropVariant ? "media-navigation" : "ghost"}
61 className="relative top-auto z-10 translate-y-0 md:hidden"61 className="relative top-auto z-10 translate-y-0 md:hidden"
62 />62 />
63 <SidebarBrand onBackdrop={backdropVariant !== null} />63 {/* One visible line: the pill wraps onto the clipped second line once it no longer fits.
64 {pillLabel ? (64 The padding keeps the brand's focus ring inside the clip. */}
65 <Badge65 <div className="relative z-10 flex h-8 min-w-0 flex-1 flex-wrap content-start items-center gap-x-2 overflow-hidden py-0.5">
66 className="relative z-10 ml-1 hidden @[15rem]/sidebar-header:inline-flex"66 <SidebarBrand onBackdrop={backdropVariant !== null} />
67 data-environment-identification="pill"67 {pillLabel ? (
68 size="sm"68 <div className="ml-1 flex h-7 items-center">
69 variant="secondary"69 <Badge data-environment-identification="pill" size="sm" variant="secondary">
70 >70 {pillLabel}
71 {pillLabel}71 </Badge>
72 </Badge>72 </div>
73 ) : null}73 ) : null}
74 </div>
74 </div>75 </div>
75 );76 );
76});77});
7778
79// Measures the brand at its titlebar inset, plus the header's right padding and the
80// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls.
81export function SidebarBrandWidthProbe({
82 onWidthChange,
83}: {
84 onWidthChange: (width: number) => void;
85}) {
86 const observeWidth = useCallback(
87 (probe: HTMLDivElement) => {
88 const observer = new ResizeObserver(([entry]) => {
89 if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
90 });
91 observer.observe(probe);
92 return () => observer.disconnect();
93 },
94 [onWidthChange],
95 );
96
97 return (
98 <div
99 aria-hidden="true"
100 className="pointer-events-none invisible fixed top-0 left-0 flex w-max border-r border-transparent pr-3"
101 ref={observeWidth}
102 >
103 <div className="ml-[var(--workspace-titlebar-content-left)] flex">
104 <SidebarBrandMark onBackdrop={false} />
105 </div>
106 </div>
107 );
108}
109
78function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {110function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
79 return (111 return (
80 <Link112 <Link
...@@ -85,22 +117,28 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {...@@ -85,22 +117,28 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
85 )}117 )}
86 to="/"118 to="/"
87 >119 >
88 {/* Center the visible capitals, without the font's ascender/descender space. */}120 <SidebarBrandMark onBackdrop={onBackdrop} />
89 <span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
90 <T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
91 <span
92 className={cn(
93 "truncate [text-box:trim-both_cap_alphabetic]",
94 onBackdrop ? "text-white/70" : "text-muted-foreground",
95 )}
96 >
97 Code
98 </span>
99 </span>
100 </Link>121 </Link>
101 );122 );
102}123}
103124
125function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) {
126 return (
127 // Center the visible capitals, without the font's ascender/descender space.
128 <span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
129 <T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
130 <span
131 className={cn(
132 "truncate [text-box:trim-both_cap_alphabetic]",
133 onBackdrop ? "text-white/70" : "text-muted-foreground",
134 )}
135 >
136 Code
137 </span>
138 </span>
139 );
140}
141
104function SidebarUtilityItem({142function SidebarUtilityItem({
105 icon,143 icon,
106 label,144 label,
apps/web/src/components/threadSidebarWidth.test.ts created+36
...@@ -0,0 +1,36 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 clampThreadSidebarWidth,
4 resolveThreadSidebarMaximumWidth,
5 resolveThreadSidebarMinimumWidth,
6 THREAD_SIDEBAR_MIN_WIDTH,
7} from "./threadSidebarWidth";
8
9describe("resolveThreadSidebarMinimumWidth", () => {
10 it("keeps the default minimum when the brand fits", () => {
11 expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
12 expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
13 });
14
15 it("grows to a brand wider than the default, rounding up", () => {
16 expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238);
17 });
18});
19
20describe("resolveThreadSidebarMaximumWidth", () => {
21 it("never drops below a raised minimum on a narrow viewport", () => {
22 expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238);
23 expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560);
24 });
25});
26
27describe("clampThreadSidebarWidth", () => {
28 it("widens a stored width below a raised minimum", () => {
29 expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238);
30 });
31
32 it("keeps widths inside the range and caps wide ones", () => {
33 expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300);
34 expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560);
35 });
36});
apps/web/src/components/threadSidebarWidth.ts+18-5
...@@ -3,11 +3,24 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;...@@ -3,11 +3,24 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
3export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;3export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
4export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;4export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
55
6export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {6// The brand's measured width can raise the minimum so "T3 Code" never clips.
7 return Math.max(7export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {
8 THREAD_SIDEBAR_MIN_WIDTH,8 return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));
9 Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,9}
10 );10
11export function resolveThreadSidebarMaximumWidth(
12 viewportWidth: number,
13 minimumWidth = THREAD_SIDEBAR_MIN_WIDTH,
14): number {
15 return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH);
16}
17
18export function clampThreadSidebarWidth(
19 width: number,
20 minimumWidth: number,
21 maximumWidth: number,
22): number {
23 return Math.min(maximumWidth, Math.max(minimumWidth, width));
11}24}
1225
13export function resolveInitialThreadSidebarWidth(26export function resolveInitialThreadSidebarWidth(