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";
3535import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
3636import ThreadSidebar from "./Sidebar";
3737import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
38import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
3939import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
4040import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
4141import { useProjects } from "../state/entities";
4242import {
43 clampThreadSidebarWidth,
4344 resolveInitialThreadSidebarWidth,
4445 resolveThreadSidebarMaximumWidth,
46 resolveThreadSidebarMinimumWidth,
4547 THREAD_MAIN_CONTENT_MIN_WIDTH,
46 THREAD_SIDEBAR_MIN_WIDTH,
4748 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
4849} from "./threadSidebarWidth";
4950import {
......@@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
234235 // and a clamped drag ends with an unchanged width, which skips the re-render
235236 // that would otherwise refresh a render-time snapshot.
236237 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);
238241 const resetSidebarWidth = () => {
239242 try {
240243 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
......@@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
250253 : false;
251254 });
252255 const sidebarProviderStyle = {
253 "--sidebar-width": `${sidebarWidth}px`,
256 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
254257 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
255258 ...(isMacosDesktop && !isWindowFullscreen
256259 ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET }
......@@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
302305 defaultOpen
303306 style={sidebarProviderStyle}
304307 >
308 <SidebarBrandWidthProbe onWidthChange={setBrandWidth} />
305309 <ProjectProjectionRetention />
306310 <Sidebar
307311 side="left"
......@@ -311,7 +315,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
311315 aria-label={isOnSettings ? "Settings" : "Threads"}
312316 resizable={{
313317 maxWidth: sidebarMaximumWidth,
314 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
318 minWidth: sidebarMinimumWidth,
315319 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
316320 nextWidth <= currentWidth ||
317321 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({
5050 // The titlebar row, not a padded SidebarHeader: it aligns to the window controls.
5151 <div
5252 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",
5454 isElectron && "drag-region",
5555 )}
5656 >
......@@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
6060 variant={backdropVariant ? "media-navigation" : "ghost"}
6161 className="relative top-auto z-10 translate-y-0 md:hidden"
6262 />
63 <SidebarBrand onBackdrop={backdropVariant !== null} />
64 {pillLabel ? (
65 <Badge
66 className="relative z-10 ml-1 hidden @[15rem]/sidebar-header:inline-flex"
67 data-environment-identification="pill"
68 size="sm"
69 variant="secondary"
70 >
71 {pillLabel}
72 </Badge>
73 ) : null}
63 {/* One visible line: the pill wraps onto the clipped second line once it no longer fits.
64 The padding keeps the brand's focus ring inside the clip. */}
65 <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 <SidebarBrand onBackdrop={backdropVariant !== null} />
67 {pillLabel ? (
68 <div className="ml-1 flex h-7 items-center">
69 <Badge data-environment-identification="pill" size="sm" variant="secondary">
70 {pillLabel}
71 </Badge>
72 </div>
73 ) : null}
74 </div>
7475 </div>
7576 );
7677});
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
78110function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
79111 return (
80112 <Link
......@@ -85,22 +117,28 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
85117 )}
86118 to="/"
87119 >
88 {/* Center the visible capitals, without the font's ascender/descender space. */}
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>
120 <SidebarBrandMark onBackdrop={onBackdrop} />
100121 </Link>
101122 );
102123}
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
104142function SidebarUtilityItem({
105143 icon,
106144 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;
33export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
44export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
55
6export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
7 return Math.max(
8 THREAD_SIDEBAR_MIN_WIDTH,
9 Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
10 );
6// The brand's measured width can raise the minimum so "T3 Code" never clips.
7export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {
8 return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));
9}
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));
1124}
1225
1326export function resolveInitialThreadSidebarWidth(