| author | |
| committer | |
| log | 9efb01690065923820dfa889af5745cc34750091 |
| tree | 2ce7f8f514cffd89366148fbbc1531d254bad6c3 |
| parent | 95edeb753b070bad191e10afe15918c969135225 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 125 insertions(+), 34 deletions(-)
apps/web/src/components/AppSidebarLayout.tsx+9-5| ... | ... | @@ -35,15 +35,16 @@ import LegacyThreadSidebar from "./LegacySidebar"; |
| 35 | 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; |
| 36 | 36 | import ThreadSidebar from "./Sidebar"; |
| 37 | 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 38 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; | |
| 38 | import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | |
| 39 | 39 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; |
| 40 | 40 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; |
| 41 | 41 | import { useProjects } from "../state/entities"; |
| 42 | 42 | import { |
| 43 | clampThreadSidebarWidth, | |
| 43 | 44 | resolveInitialThreadSidebarWidth, |
| 44 | 45 | resolveThreadSidebarMaximumWidth, |
| 46 | resolveThreadSidebarMinimumWidth, | |
| 45 | 47 | THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 46 | THREAD_SIDEBAR_MIN_WIDTH, | |
| 47 | 48 | THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 48 | 49 | } from "./threadSidebarWidth"; |
| 49 | 50 | import { |
| ... | ... | @@ -234,7 +235,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 234 | 235 | // and a clamped drag ends with an unchanged width, which skips the re-render |
| 235 | 236 | // that would otherwise refresh a render-time snapshot. |
| 236 | 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 | 241 | const resetSidebarWidth = () => { |
| 239 | 242 | try { |
| 240 | 243 | removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); |
| ... | ... | @@ -250,7 +253,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 250 | 253 | : false; |
| 251 | 254 | }); |
| 252 | 255 | const sidebarProviderStyle = { |
| 253 | "--sidebar-width": `${sidebarWidth}px`, | |
| 256 | "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, | |
| 254 | 257 | "--panel-animation-duration": `${panelAnimationDurationMs}ms`, |
| 255 | 258 | ...(isMacosDesktop && !isWindowFullscreen |
| 256 | 259 | ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } |
| ... | ... | @@ -302,6 +305,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 302 | 305 | defaultOpen |
| 303 | 306 | style={sidebarProviderStyle} |
| 304 | 307 | > |
| 308 | <SidebarBrandWidthProbe onWidthChange={setBrandWidth} /> | |
| 305 | 309 | <ProjectProjectionRetention /> |
| 306 | 310 | <Sidebar |
| 307 | 311 | side="left" |
| ... | ... | @@ -311,7 +315,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 311 | 315 | aria-label={isOnSettings ? "Settings" : "Threads"} |
| 312 | 316 | resizable={{ |
| 313 | 317 | maxWidth: sidebarMaximumWidth, |
| 314 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | |
| 318 | minWidth: sidebarMinimumWidth, | |
| 315 | 319 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => |
| 316 | 320 | nextWidth <= currentWidth || |
| 317 | 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 | 50 | // The titlebar row, not a padded SidebarHeader: it aligns to the window controls. |
| 51 | 51 | <div |
| 52 | 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 | 54 | isElectron && "drag-region", |
| 55 | 55 | )} |
| 56 | 56 | > |
| ... | ... | @@ -60,21 +60,53 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 60 | 60 | variant={backdropVariant ? "media-navigation" : "ghost"} |
| 61 | 61 | className="relative top-auto z-10 translate-y-0 md:hidden" |
| 62 | 62 | /> |
| 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> | |
| 74 | 75 | </div> |
| 75 | 76 | ); |
| 76 | 77 | }); |
| 77 | 78 | |
| 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. | |
| 81 | export 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 | ||
| 78 | 110 | function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 79 | 111 | return ( |
| 80 | 112 | <Link |
| ... | ... | @@ -85,22 +117,28 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 85 | 117 | )} |
| 86 | 118 | to="/" |
| 87 | 119 | > |
| 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} /> | |
| 100 | 121 | </Link> |
| 101 | 122 | ); |
| 102 | 123 | } |
| 103 | 124 | |
| 125 | function 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 | ||
| 104 | 142 | function SidebarUtilityItem({ |
| 105 | 143 | icon, |
| 106 | 144 | label, |
apps/web/src/components/threadSidebarWidth.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { | |
| 3 | clampThreadSidebarWidth, | |
| 4 | resolveThreadSidebarMaximumWidth, | |
| 5 | resolveThreadSidebarMinimumWidth, | |
| 6 | THREAD_SIDEBAR_MIN_WIDTH, | |
| 7 | } from "./threadSidebarWidth"; | |
| 8 | ||
| 9 | describe("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 | ||
| 20 | describe("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 | ||
| 27 | describe("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 | 3 | export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| 4 | 4 | export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; |
| 5 | 5 | |
| 6 | export 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. | |
| 7 | export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { | |
| 8 | return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth)); | |
| 9 | } | |
| 10 | ||
| 11 | export 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 | ||
| 18 | export function clampThreadSidebarWidth( | |
| 19 | width: number, | |
| 20 | minimumWidth: number, | |
| 21 | maximumWidth: number, | |
| 22 | ): number { | |
| 23 | return Math.min(maximumWidth, Math.max(minimumWidth, width)); | |
| 11 | 24 | } |
| 12 | 25 | |
| 13 | 26 | export function resolveInitialThreadSidebarWidth( |