Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 79 additions & 10 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Toaster } from 'sonner';

import { getActiveParent, NAV_ITEMS, NavChild, NavIcon } from '../navigation';
import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme';
import { spectrum } from '../spectrum';
import { getChangeBySlug } from '../upgrades/data/changes';
import { demoLabel } from '../demos/catalogue';
import { getUpgradeById } from '../upgrades/data/upgrades';
Expand Down Expand Up @@ -47,7 +46,7 @@ const styles: Record<string, CSSProperties> = {
sidebar: {
width: SIDEBAR_WIDTH,
flexShrink: 0,
borderRight: '1px solid rgba(0,0,0,0.06)',
borderRight: `1px solid ${SELECTED}`,
display: 'flex',
flexDirection: 'column',
padding: '0 12px 20px',
Expand Down Expand Up @@ -104,9 +103,20 @@ const styles: Record<string, CSSProperties> = {
padding: '9px 10px',
borderRadius: 8,
textDecoration: 'none',
color: spectrum.gray[50],
color: 'var(--bds-gray-50)',
},
footerIcon: { display: 'inline-flex', width: 18, height: 18 },
// Sits outside the sliding nav container so the toggle stays pinned to the
// bottom of the sidebar on sub-nav routes too, where `sidebarFooter` (which
// lives inside the main-nav pane) has slid away.
themeFooter: { flexShrink: 0, position: 'relative' },
themeButton: {
width: '100%',
border: 0,
background: 'transparent',
cursor: 'pointer',
textAlign: 'left',
},
main: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' },
topbar: {
height: 65,
Expand Down Expand Up @@ -214,7 +224,7 @@ function NavGlyph({ name }: NavGlyphProps) {
function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, layoutScope = 'desktop' }: NavRowProps) {
let color = DISABLED;
if (enabled) {
color = active ? spectrum.gray[80] : spectrum.gray[50];
color = active ? 'var(--bds-gray-80)' : 'var(--bds-gray-50)';
}

const row = (
Expand Down Expand Up @@ -252,7 +262,7 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l
<Text as="span" variant="label.medium" tone="inherit">{label}</Text>
{!enabled && <span style={styles.soon}>Soon</span>}
{hasChildren && (
<span style={{ marginLeft: 'auto', marginRight: -8, color: spectrum.gray[50] }}>
<span style={{ marginLeft: 'auto', marginRight: -8, color: 'var(--bds-gray-50)' }}>
<AnimatedArrowIcon size={16} strokeWidth={1.5} />
</span>
)}
Expand Down Expand Up @@ -301,7 +311,28 @@ const slideTransition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] as const };
// slipped through), so it just has to be longer than a slow route.
const PENDING_PATH_TIMEOUT_MS = 5000;

function SidebarContent({ onNavigate, hideBrand, layoutScope = 'desktop' }: { onNavigate?: () => void; hideBrand?: boolean; layoutScope?: string }) {
function ThemeIcon({ dark }: { dark: boolean }) {
return dark ? (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41" />
</svg>
) : (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z" />
</svg>
);
}

type SidebarContentProps = {
dark: boolean;
onToggleTheme: () => void;
onNavigate?: () => void;
hideBrand?: boolean;
layoutScope?: string;
};

function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScope = 'desktop' }: SidebarContentProps) {
const pathname = usePathname() || '/';
// The nav follows the tapped href immediately instead of waiting for the router:
// usePathname() only updates once the route commits, which left the pill and the
Expand Down Expand Up @@ -365,7 +396,7 @@ function SidebarContent({ onNavigate, hideBrand, layoutScope = 'desktop' }: { on
<Link
href="/"
className="nav-header-hover group"
style={{ ...styles.navLink, display: 'flex', alignItems: 'center', padding: '9px 6px 9px 2px', marginBottom: 4, color: spectrum.gray[50] }}
style={{ ...styles.navLink, display: 'flex', alignItems: 'center', padding: '9px 6px 9px 2px', marginBottom: 4, color: 'var(--bds-gray-50)' }}
onClick={(event) => {
if (opensInNewTab(event)) return;
selectPath('/');
Expand Down Expand Up @@ -486,6 +517,19 @@ function SidebarContent({ onNavigate, hideBrand, layoutScope = 'desktop' }: { on
)}
</AnimatePresence>
</div>

<div style={styles.themeFooter}>
<button
type="button"
onClick={onToggleTheme}
className="nav-header-hover"
aria-label={`Switch to ${dark ? 'light' : 'dark'} mode`}
style={{ ...styles.footerLink, ...styles.themeButton }}
>
<span style={styles.footerIcon}><ThemeIcon dark={dark} /></span>
<Text as="span" variant="label.medium" tone="inherit">{dark ? 'Light mode' : 'Dark mode'}</Text>
</button>
</div>
</>
);
}
Expand Down Expand Up @@ -519,7 +563,7 @@ function GlobalBanner({ dismissed, onDismiss, className }: GlobalBannerProps) {
<button
type="button"
onClick={onDismiss}
className="absolute right-4 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center text-bds-gray-40 transition-colors hover:text-black"
className="absolute right-4 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center text-bds-gray-40 transition-colors hover:text-foreground"
aria-label="Dismiss banner"
>
<CloseIcon size={10} />
Expand All @@ -533,6 +577,25 @@ export function AppShell({ children }: PropsWithChildren) {
const title = titleForPath(pathname);
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.
const [dark, setDark] = useState(false);

useEffect(() => {
setDark(document.documentElement.dataset.theme === 'dark');
}, []);

const toggleTheme = () => {
const nextDark = !dark;
document.documentElement.dataset.theme = nextDark ? 'dark' : 'light';
setDark(nextDark);
try {
localStorage.setItem('theme', nextDark ? 'dark' : 'light');
} catch {
// Private browsing or a blocked-storage profile — the theme still applies
// for this session, it just won't survive a reload.
}
};

useEffect(() => {
setMenuOpen(false);
Expand Down Expand Up @@ -563,7 +626,7 @@ export function AppShell({ children }: PropsWithChildren) {
<div style={styles.root}>
{/* Desktop sidebar */}
<aside className="sidebar-desktop" style={styles.sidebar}>
<SidebarContent />
<SidebarContent dark={dark} onToggleTheme={toggleTheme} />
</aside>

{/* Mobile header (logo + hamburger) */}
Expand Down Expand Up @@ -596,7 +659,13 @@ export function AppShell({ children }: PropsWithChildren) {
exit={{ x: '100%' }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
<SidebarContent onNavigate={() => setMenuOpen(false)} hideBrand layoutScope="mobile" />
<SidebarContent
dark={dark}
onToggleTheme={toggleTheme}
onNavigate={() => setMenuOpen(false)}
hideBrand
layoutScope="mobile"
/>
</motion.aside>
)}
</AnimatePresence>
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function Breadcrumb({ parentLabel, parentHref, childLabel, middle }: Brea
<span className="relative flex w-full items-center justify-center">
<Link
href={backHref}
className="group absolute left-0 flex h-8 w-8 items-center justify-center rounded-full text-bds-gray-40 no-underline transition-colors hover:text-black dark:hover:text-white"
className="group absolute left-0 flex h-8 w-8 items-center justify-center rounded-full text-bds-gray-40 no-underline transition-colors hover:text-foreground"
aria-label="Go back"
>
<svg width={16} height={16} viewBox="0 0 20 20" fill="none" aria-hidden="true" style={{ transform: 'scaleX(-1)' }}>
Expand Down
5 changes: 4 additions & 1 deletion app/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ type ButtonProps = ComponentPropsWithoutRef<'button'> & {
};

const variantClasses = {
primary: 'bg-base-blue text-white hover:bg-[#0000CC] border border-transparent',
// The dark Base blue is a light tint, so the label flips to near-black to
// keep contrast; the hover step darkens in light mode and lifts in dark.
primary:
'border border-transparent bg-base-blue text-white hover:bg-[#0000CC] dark:text-black dark:hover:bg-bds-blue-80',
secondary:
'text-foreground hover:bg-bds-gray-15 bg-bds-gray-10 dark:bg-white/10 dark:hover:bg-white/20',
outline:
Expand Down
8 changes: 5 additions & 3 deletions app/components/ui/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function Checkbox({ checked, className }: CheckboxProps) {
<span
className={cn(
'inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px] border-[1.5px] transition-[background-color,border-color] duration-150 ease-out',
checked ? 'border-black bg-black' : 'border-bds-gray-20 bg-white',
checked ? 'border-foreground bg-foreground' : 'border-bds-gray-20 bg-background',
className,
)}
>
Expand All @@ -22,12 +22,14 @@ export function Checkbox({ checked, className }: CheckboxProps) {
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none',
// The box fills with the foreground, so the tick has to be the
// background to stay legible in either mode.
'text-background transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none',
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0',
)}
>
Expand Down
4 changes: 2 additions & 2 deletions app/components/ui/CommandBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ export function CommandBox({ command, label = 'Command', className, onCopy }: Co
<button
type="button"
onClick={handleCopy}
className="rounded-md border border-bds-gray-10 bg-white px-2.5 py-[3px] text-[12px] text-bds-gray-60 transition-colors hover:bg-bds-gray-5"
className="rounded-md border border-bds-gray-10 bg-background px-2.5 py-[3px] text-[12px] text-bds-gray-60 transition-colors hover:bg-bds-gray-5"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<div className="overflow-x-auto px-3.5 py-3">
<code className="whitespace-nowrap font-mono text-[13px] text-black">{command}</code>
<code className="whitespace-nowrap font-mono text-[13px] text-foreground">{command}</code>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function EmptyState({ description, title, bordered = true, className }: E
<div
className={cn(
bordered &&
'rounded-lg border border-bds-gray-10 bg-white p-6 dark:border-white/10 dark:bg-white/5',
'rounded-lg border border-bds-gray-10 bg-background p-6 dark:border-white/10 dark:bg-white/5',
className,
)}
>
Expand Down
6 changes: 3 additions & 3 deletions app/components/ui/FilterSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function FilterSelect({ value, onChange, ariaLabel, options, minDropdownW
aria-label={ariaLabel}
aria-expanded={open}
aria-haspopup="listbox"
className="flex h-9 items-center gap-1.5 rounded-full border border-bds-gray-10 bg-white px-3 text-[14px] text-black outline-none transition-colors hover:bg-bds-gray-5"
className="flex h-9 items-center gap-1.5 rounded-full border border-bds-gray-10 bg-background px-3 text-[14px] text-foreground outline-none transition-colors hover:bg-bds-gray-5"
>
<span className="whitespace-nowrap">
{selected?.label ?? value}
Expand Down Expand Up @@ -100,7 +100,7 @@ export function FilterSelect({ value, onChange, ariaLabel, options, minDropdownW
role="listbox"
aria-label={ariaLabel}
style={{ minWidth: Math.max(dropdownW ?? 0, minDropdownWidth ?? 0) || undefined }}
className="absolute left-0 top-full z-50 mt-1 max-h-64 min-w-full overflow-y-auto rounded-xl border border-bds-gray-10 bg-white py-1 shadow-lg"
className="absolute left-0 top-full z-50 mt-1 max-h-64 min-w-full overflow-y-auto rounded-xl border border-bds-gray-10 bg-background py-1 shadow-lg"
>
{options.map((option) => (
<button
Expand All @@ -111,7 +111,7 @@ export function FilterSelect({ value, onChange, ariaLabel, options, minDropdownW
onClick={() => handleSelect(option.value)}
className={cn(
'flex w-full items-center px-3 py-2 text-left text-[14px] transition-colors hover:bg-bds-gray-5',
option.value === value ? 'text-black' : 'text-bds-gray-60',
option.value === value ? 'text-foreground' : 'text-bds-gray-60',
)}
>
{option.label}
Expand Down
4 changes: 2 additions & 2 deletions app/components/ui/InfoTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function InfoTooltip({ label, children, side = 'top', className }: InfoTo
type="button"
aria-label={label}
className={cn(
'inline-flex h-4 w-4 shrink-0 cursor-help items-center justify-center rounded-full border border-bds-gray-20 text-[10px] font-semibold leading-none text-bds-gray-50 outline-none transition-colors hover:border-base-blue hover:text-base-blue focus-visible:ring-2 focus-visible:ring-base-blue/40 dark:border-white/20 dark:text-bds-gray-30',
'inline-flex h-4 w-4 shrink-0 cursor-help items-center justify-center rounded-full border border-bds-gray-20 text-[10px] font-semibold leading-none text-bds-gray-50 outline-none transition-colors hover:border-base-blue hover:text-base-blue focus-visible:ring-2 focus-visible:ring-base-blue/40 dark:border-white/20',
className,
)}
>
Expand All @@ -48,7 +48,7 @@ export function InfoTooltip({ label, children, side = 'top', className }: InfoTo
side={side}
sideOffset={6}
collisionPadding={12}
className="z-[140] max-w-[18rem] rounded-lg border border-bds-gray-10 bg-white px-3 py-2 text-[12px] leading-relaxed text-bds-gray-70 shadow-lg dark:border-white/10 dark:bg-[#1a1a1a] dark:text-bds-gray-20"
className="z-[140] max-w-[18rem] rounded-lg border border-bds-gray-10 bg-background px-3 py-2 text-[12px] leading-relaxed text-bds-gray-70 shadow-lg dark:border-white/10 dark:bg-[#1a1a1a]"
>
{children}
<Tooltip.Arrow className="fill-white dark:fill-[#1a1a1a]" />
Expand Down
4 changes: 2 additions & 2 deletions app/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
transition={panelTransition}
onClick={stop}
className={cn(
'flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-bds-gray-10 bg-white text-black shadow-xl focus:outline-none dark:border-white/10 dark:bg-[#141414] dark:text-white',
'flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-bds-gray-10 bg-background text-foreground shadow-xl focus:outline-none dark:border-white/10 dark:bg-[#141414] dark:text-white',
className,
)}
>
Expand All @@ -93,7 +93,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
type="button"
onClick={onClose}
aria-label="Close"
className="-mr-1.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-bds-gray-60 transition-colors hover:bg-bds-gray-10 hover:text-black dark:text-bds-gray-40 dark:hover:bg-white/10 dark:hover:text-white"
className="-mr-1.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-bds-gray-60 transition-colors hover:bg-bds-gray-10 hover:text-foreground dark:text-bds-gray-40 dark:hover:bg-white/10 dark:hover:text-white"
>
<CloseIcon size={14} />
</button>
Expand Down
10 changes: 5 additions & 5 deletions app/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ export function Select({
<RadixSelect.Trigger
aria-label={ariaLabel}
className={cn(
'inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-bds-gray-10 bg-white px-3.5 text-[14px] text-black outline-none transition-colors focus:border-black data-[placeholder]:text-bds-gray-40 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus:border-white',
'inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg border border-bds-gray-10 bg-background px-3.5 text-[14px] text-foreground outline-none transition-colors focus:border-foreground data-[placeholder]:text-bds-gray-40 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus:border-white',
className,
)}
>
<span className="min-w-0 truncate">
<RadixSelect.Value placeholder={placeholder} />
</span>
<RadixSelect.Icon className="text-bds-gray-60 dark:text-bds-gray-20">
<RadixSelect.Icon className="text-bds-gray-60">
<svg
aria-hidden="true"
width="16"
Expand All @@ -76,7 +76,7 @@ export function Select({
// <body>, so it competes with the modal in the root stacking context;
// anything lower renders behind the panel when a Select sits in a Modal.
// See the layer scale in globals.css.
className="z-[130] max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-bds-gray-10 bg-white shadow-lg dark:border-white/10 dark:bg-[#1a1a1a]"
className="z-[130] max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-bds-gray-10 bg-background shadow-lg dark:border-white/10 dark:bg-[#1a1a1a]"
>
<RadixSelect.Viewport className="p-1">
{options.map((option) => (
Expand Down Expand Up @@ -107,9 +107,9 @@ function SelectItem({ option }: { option: SelectOption }) {
<RadixSelect.Item
value={option.value}
disabled={option.disabled}
className="relative flex cursor-pointer select-none items-center gap-2 rounded-md py-2 pl-3 pr-8 text-[14px] text-black outline-none data-[highlighted]:bg-bds-gray-5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40 dark:text-white dark:data-[highlighted]:bg-white/10"
className="relative flex cursor-pointer select-none items-center gap-2 rounded-md py-2 pl-3 pr-8 text-[14px] text-foreground outline-none data-[highlighted]:bg-bds-gray-5 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40 dark:text-white dark:data-[highlighted]:bg-white/10"
>
<RadixSelect.ItemIndicator className="absolute right-2.5 text-black dark:text-white">
<RadixSelect.ItemIndicator className="absolute right-2.5 text-foreground">
</RadixSelect.ItemIndicator>
<RadixSelect.ItemText>{option.label}</RadixSelect.ItemText>
Expand Down
Loading
Loading