From 86d038ee5d2224d773cdc74e5089dd0922d5603a Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 09:46:33 -0700 Subject: [PATCH 01/14] sidebar slide tweaks --- app/components/AppShell.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 1113e76..ffe0f6c 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -4,7 +4,7 @@ import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEff import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Dialog } from '@base-ui/react/dialog'; -import { AnimatePresence, motion, useMotionTemplate, useMotionValue, useReducedMotion, type MotionValue } from 'motion/react'; +import { AnimatePresence, easeOut, motion, useMotionTemplate, useMotionValue, useReducedMotion, type MotionValue } from 'motion/react'; import { Toaster } from 'sonner'; import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation'; @@ -363,12 +363,12 @@ function opensInNewTab(event: ReactMouseEvent): boolean { } const slideVariants = { - enter: (direction: number) => ({ x: direction > 0 ? '60%' : '-60%', opacity: 0 }), - center: { x: 0, opacity: 1 }, - exit: (direction: number) => ({ x: direction > 0 ? '-60%' : '60%', opacity: 0 }), + enter: (direction: number) => ({ x: direction > 0 ? 10 : -10, opacity: 0, filter: 'blur(1px)' }), + center: { x: 0, opacity: 1, filter: 'none' }, + exit: (direction: number) => ({ x: direction > 0 ? -10 : 10, opacity: 0, filter: 'blur(1px)' }), }; -const slideTransition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] as const }; +const slideTransition = { duration: 0.2, ease: easeOut, x: { visualDuration: 0.2, type: 'spring', bounce: 0 } }; /** Matches `h-9` / theme(spacing.9). */ const APP_BANNER_HEIGHT = '2.25rem'; @@ -489,7 +489,7 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC { if (opensInNewTab(event)) return; // Stay on the current page and keep the mobile drawer From 1cb55e772f1b66b1180a34c32e4f08984a810dee Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 10:08:02 -0700 Subject: [PATCH 02/14] dismiss on click banner link --- app/components/AppShell.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index ffe0f6c..d17b03f 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -666,7 +666,14 @@ function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerP New! EIP-8130: Accounts - + { + if (opensInNewTab(event)) return; + onDismiss(); + }} + > Test on Vibenet From 9f5124dbe14adfbc8b2993aa6ab390869fd7432c Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 10:41:07 -0700 Subject: [PATCH 03/14] remove sidebar transition --- app/globals.css | 1 - 1 file changed, 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 0d5ee45..cdcb7e2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -576,7 +576,6 @@ button { border-radius: 8px; } -.nav-row-hover::before, .nav-header-hover { transition: background-color 0.15s ease; } From 6fc20fbe5e3fb48423ce506558e2708c18f24c6e Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 10:42:20 -0700 Subject: [PATCH 04/14] Add sidebar footer hover and keep theme swaps from tweening colors. Focus rings hug the footer labels, and the nav clip lets first/last outlines paint without extra padding. Co-authored-by: Cursor --- app/components/AppShell.tsx | 131 ++++++++++++++++++++++++------------ app/globals.css | 28 -------- 2 files changed, 88 insertions(+), 71 deletions(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index d17b03f..81e67bc 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useInsertionEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Dialog } from '@base-ui/react/dialog'; @@ -76,11 +76,6 @@ const styles: Record = { display: 'flex', flexDirection: 'column', }, - // Clips the pane's horizontal slide so it never becomes overflow-x on the - // scroll viewport. Height is content-sized, so this does not clip vertically. - navSlideClip: { - overflow: 'hidden', - }, // `isolation` keeps the selected pill (z-index -1) in this stacking context // so it paints behind the row's label instead of behind the sidebar. nav: { display: 'flex', flexDirection: 'column', gap: 2, position: 'relative', isolation: 'isolate' }, @@ -125,7 +120,6 @@ const styles: Record = { padding: '9px 10px', borderRadius: 8, textDecoration: 'none', - color: 'var(--bds-gray-50)', }, footerIcon: { display: 'inline-flex', width: 18, height: 18 }, // Hugs the switch rather than filling the row: with no label beside it, a @@ -151,10 +145,8 @@ const styles: Record = { height: 20, padding: 2, borderRadius: 999, - background: 'var(--bds-gray-50)', boxSizing: 'border-box', }, - switchTrackOn: { background: BRAND_BLUE }, switchThumb: { display: 'inline-flex', alignItems: 'center', @@ -377,6 +369,12 @@ const APP_BANNER_HEIGHT = '2.25rem'; // slipped through), so it just has to be longer than a slow route. const PENDING_PATH_TIMEOUT_MS = 5000; +const SIDEBAR_FOOTER_LINK = + 'group outline-none text-bds-gray-50 transition-colors duration-150 hover:text-bds-gray-80'; + +const SIDEBAR_FOOTER_LINK_LABEL = + 'inline-flex items-center gap-2.5 rounded-sm group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-brand-blue'; + // Rides inside the switch thumb. Stroke is heavier than the nav glyphs' 1.8 // because at 10px that weight all but disappears. function ThemeIcon({ dark }: { dark: boolean }) { @@ -472,7 +470,7 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC )} -
+
{activeParent ? (
- - - - - + + + + + + + + Status - Status - - - - - + + + + + + + + Support - Support - - - - - + + + + + + + + Docs - Docs
- - - - - + + + + + + + + Blog - Blog {/* `role="switch"` rather than a plain button: the control reports a state rather than firing an action, so screen readers announce "on"/"off" @@ -613,16 +619,16 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC aria-checked={dark} aria-label="Dark mode" onClick={onToggleTheme} - className="nav-header-hover theme-switch" + className="group outline-none" style={{ ...styles.footerLink, ...styles.themeButton }} > @@ -692,6 +698,30 @@ function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerP ); } +// next-themes `disableTransitionOnChange`: stamp a global `transition: none` +// rule, apply the theme, force a restyle, then drop the rule on the next tick +// so color tokens don't animate through every `transition-colors` on the page. +// https://github.com/pacocoursey/next-themes/blob/main/next-themes/src/index.tsx +function disableAnimation() { + const css = document.createElement('style'); + css.appendChild( + document.createTextNode( + `*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`, + ), + ); + document.head.appendChild(css); + + return () => { + // Force restyle + (() => window.getComputedStyle(document.body))(); + + // Wait for next tick before removing + setTimeout(() => { + document.head.removeChild(css); + }, 1); + }; +} + export function AppShell({ children }: PropsWithChildren) { const pathname = usePathname() || '/'; const title = titleForPath(pathname); @@ -702,21 +732,36 @@ export function AppShell({ children }: PropsWithChildren) { // Starts false on both server and client so the first render matches; the // effect below reads the attribute the pre-paint script in layout.tsx set. const [dark, setDark] = useState(false); + // Insertion effect writes `data-theme`. It must not run on mount or on the + // hydration sync below: `dark` starts false to match SSR, and the pre-paint + // script may already have stamped dark. Flipping this in the click handler + // means Strict Mode's extra first-run cannot flash light. + const applyTheme = useRef(false); useEffect(() => { setDark(document.documentElement.dataset.theme === 'dark'); }, []); - const toggleTheme = () => { - const nextDark = !dark; - document.documentElement.dataset.theme = nextDark ? 'dark' : 'light'; - setDark(nextDark); + // Lock transitions before React commits so the attribute swap and the switch + // re-render share one no-transition frame (next-themes disableTransitionOnChange). + useInsertionEffect(() => { + if (!applyTheme.current) return; + const next = dark ? 'dark' : 'light'; + if (document.documentElement.dataset.theme === next) return; + const restore = disableAnimation(); + document.documentElement.dataset.theme = next; try { - localStorage.setItem('theme', nextDark ? 'dark' : 'light'); + localStorage.setItem('theme', next); } catch { // Private browsing or a blocked-storage profile — the theme still applies // for this session, it just won't survive a reload. } + restore(); + }, [dark]); + + const toggleTheme = () => { + applyTheme.current = true; + setDark((prev) => !prev); }; return ( diff --git a/app/globals.css b/app/globals.css index cdcb7e2..e914fb1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -585,34 +585,6 @@ button { background-color: var(--bds-gray-5); } -/* Theme switch. The thumb is offset with a transform (see `switchThumbOn` in - AppShell) so only the compositor is involved; the track recolours alongside it. */ -.theme-switch-track { - transition: background-color 0.18s ease, border-color 0.18s ease; -} - -.theme-switch-thumb { - transition: transform 0.18s ease, color 0.18s ease, border-color 0.18s ease; -} - -/* The button carries no visible border, so the ring goes on the track — the part - that actually reads as the control. */ -.theme-switch:focus-visible { - outline: none; -} - -.theme-switch:focus-visible .theme-switch-track { - outline: 2px solid var(--bds-brand); - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - .theme-switch-track, - .theme-switch-thumb { - transition: none; - } -} - .nav-back-icon { color: var(--bds-gray-40); transition: color 0.15s ease; From cd2269e5443b0c93ef4aae81ceaa573952fc6e46 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 10:43:02 -0700 Subject: [PATCH 05/14] Prevent theme swaps from tweening every color transition on the page. Stamp next-themes' global transition:none lock in an insertion effect around the data-theme write so the switch and tokens flip in one frame. Co-authored-by: Cursor --- app/components/AppShell.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 81e67bc..db1a3ce 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -732,10 +732,8 @@ export function AppShell({ children }: PropsWithChildren) { // Starts false on both server and client so the first render matches; the // effect below reads the attribute the pre-paint script in layout.tsx set. const [dark, setDark] = useState(false); - // Insertion effect writes `data-theme`. It must not run on mount or on the - // hydration sync below: `dark` starts false to match SSR, and the pre-paint - // script may already have stamped dark. Flipping this in the click handler - // means Strict Mode's extra first-run cannot flash light. + // Stays false through mount and the hydration sync below so we don't overwrite + // the pre-paint script with the SSR default (`dark` starts false). const applyTheme = useRef(false); useEffect(() => { From 58c8174fce60f1d5f3b0f0d4b9944dbff7502c55 Mon Sep 17 00:00:00 2001 From: Max Barvian Date: Fri, 28 Aug 2026 11:18:15 -0700 Subject: [PATCH 06/14] Stop the theme switch from painting the wrong state on reload. It now follows html[data-theme] on first paint, and the page-wide no-transition stamp no longer overrides its 180ms slide. Co-authored-by: Cursor --- app/components/AppShell.tsx | 61 ++++++++++++++++++++++--------------- app/layout.tsx | 8 ++--- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index db1a3ce..35525c0 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -8,7 +8,7 @@ import { AnimatePresence, easeOut, motion, useMotionTemplate, useMotionValue, us import { Toaster } from 'sonner'; import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation'; -import { BLUE, BORDER, BRAND_BLUE, DISABLED, INK, MUTED, SELECTED } from '../theme'; +import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme'; import { getChangeBySlug } from '../upgrades/data/changes'; import { demoLabel } from '../vibenet/demos/catalogue'; import { getUpgradeById } from '../upgrades/data/upgrades'; @@ -154,11 +154,12 @@ const styles: Record = { width: 16, height: 16, borderRadius: '50%', - // Reads against both the grey off-track and the blue on-track. + // Reads against both the grey off-track and the blue on-track. On-state + // color and travel are Tailwind `dark:` variants on the thumb, keyed off + // `html[data-theme]` — React state lags a frame behind the pre-paint + // script, and driving those here flashes the switch on reload. background: 'var(--bds-gray-0)', - color: 'var(--bds-gray-50)', }, - switchThumbOn: { transform: 'translateX(14px)', color: BRAND_BLUE }, main: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }, // Grow on short pages so the activity drawer can sit at the bottom; don't // shrink, or tall pages compress instead of letting the document scroll. @@ -375,9 +376,15 @@ const SIDEBAR_FOOTER_LINK = const SIDEBAR_FOOTER_LINK_LABEL = 'inline-flex items-center gap-2.5 rounded-sm group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-brand-blue'; +// Track and thumb. Shared with the `:not(...)` list in disableAnimation so the +// page-wide no-transition stamp cannot override the 180ms slide. +const THEME_SWITCH_ANIM = 'theme-switch-anim'; + // Rides inside the switch thumb. Stroke is heavier than the nav glyphs' 1.8 -// because at 10px that weight all but disappears. -function ThemeIcon({ dark }: { dark: boolean }) { +// because at 10px that weight all but disappears. Both glyphs are in the +// tree so `dark:` (html[data-theme]) can pick the right one on first paint, +// before React hydrates the stored preference. +function ThemeIcon() { const common = { width: 10, height: 10, @@ -388,15 +395,16 @@ function ThemeIcon({ dark }: { dark: boolean }) { strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, }; - return dark ? ( - - - - ) : ( - - - - + return ( + <> + + + + + + + + ); } @@ -624,14 +632,14 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: SidebarC > - + @@ -701,12 +709,13 @@ function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerP // next-themes `disableTransitionOnChange`: stamp a global `transition: none` // rule, apply the theme, force a restyle, then drop the rule on the next tick // so color tokens don't animate through every `transition-colors` on the page. +// The switch is excluded: its motion is the control, not a side effect. // https://github.com/pacocoursey/next-themes/blob/main/next-themes/src/index.tsx function disableAnimation() { const css = document.createElement('style'); css.appendChild( document.createTextNode( - `*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`, + `*:not(.${THEME_SWITCH_ANIM}),*:not(.${THEME_SWITCH_ANIM})::before,*:not(.${THEME_SWITCH_ANIM})::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}`, ), ); document.head.appendChild(css); @@ -729,8 +738,9 @@ export function AppShell({ children }: PropsWithChildren) { const sidebarHeight = useMotionTemplate`calc(100dvh - ${bannerHeight})`; const [menuOpen, setMenuOpen] = useState(false); const [bannerDismissed, setBannerDismissed] = useState(false); - // Starts false on both server and client so the first render matches; the - // effect below reads the attribute the pre-paint script in layout.tsx set. + // aria-checked only. The switch's look is CSS against html[data-theme], + // which the pre-paint script already set — this state starts false so SSR + // and the first client render match, then catches up after mount. const [dark, setDark] = useState(false); // Stays false through mount and the hydration sync below so we don't overwrite // the pre-paint script with the SSR default (`dark` starts false). @@ -740,8 +750,9 @@ export function AppShell({ children }: PropsWithChildren) { setDark(document.documentElement.dataset.theme === 'dark'); }, []); - // Lock transitions before React commits so the attribute swap and the switch - // re-render share one no-transition frame (next-themes disableTransitionOnChange). + // Lock page transitions before React commits so color tokens don't tween + // (next-themes disableTransitionOnChange). The switch is opted out of that + // stamp and keeps its 180ms slide. useInsertionEffect(() => { if (!applyTheme.current) return; const next = dark ? 'dark' : 'light'; @@ -759,7 +770,7 @@ export function AppShell({ children }: PropsWithChildren) { const toggleTheme = () => { applyTheme.current = true; - setDark((prev) => !prev); + setDark(document.documentElement.dataset.theme !== 'dark'); }; return ( diff --git a/app/layout.tsx b/app/layout.tsx index bae4d28..b977a41 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -92,10 +92,10 @@ export default function RootLayout({ children }: PropsWithChildren) { suppressHydrationWarning > - {/* Applies the stored (or system) theme before first paint. Anything - later — even a layout effect — lands after the browser has already - painted the light spectrum, which reads as a white flash. The body - is a build-time constant with no interpolation, so nothing + {/* Applies the stored theme before first paint. Anything later — even + a layout effect — lands after the browser has already painted the + light spectrum, which reads as a white flash. The body is a + build-time constant with no interpolation, so nothing user-controlled can reach it. */}