diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 096665b..1113e76 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -36,7 +36,6 @@ type NavRowProps = { enabled: boolean; hasChildren?: boolean; onNavigate?: () => void; - layoutScope?: string; }; type NavGlyphProps = { @@ -82,9 +81,8 @@ const styles: Record = { navSlideClip: { overflow: 'hidden', }, - // `isolation` makes the nav a stacking context so the active-row pill (which - // renders at z-index -1 and travels across rows while animating) paints behind - // every row's label instead of on top of the rows it passes over. + // `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' }, navLink: { textDecoration: 'none', color: 'inherit' }, navRow: { @@ -294,7 +292,7 @@ function NavGlyph({ name }: NavGlyphProps) { } } -function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, layoutScope = 'desktop' }: NavRowProps) { +function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate }: NavRowProps) { let color = DISABLED; if (enabled) { color = active ? 'var(--bds-gray-80)' : 'var(--bds-gray-50)'; @@ -311,12 +309,7 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l }} > {active && ( - )} {icon && ( @@ -415,10 +407,9 @@ type SidebarContentProps = { onToggleTheme: () => void; onNavigate?: () => void; hideBrand?: boolean; - layoutScope?: string; }; -function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScope = 'desktop' }: SidebarContentProps) { +function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand }: 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 @@ -539,7 +530,6 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop active={active} enabled={true} onNavigate={() => selectPath(child.href)} - layoutScope={`${layoutScope}-sub`} /> ); })} @@ -568,7 +558,6 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop enabled={item.enabled} hasChildren={!!item.children} onNavigate={() => selectPath(item.href)} - layoutScope={layoutScope} /> ))} @@ -767,7 +756,6 @@ export function AppShell({ children }: PropsWithChildren) { onToggleTheme={toggleTheme} onNavigate={() => setMenuOpen(false)} hideBrand - layoutScope="mobile" /> diff --git a/app/components/ui/Tabs.tsx b/app/components/ui/Tabs.tsx index 38538b2..b292893 100644 --- a/app/components/ui/Tabs.tsx +++ b/app/components/ui/Tabs.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { MouseEvent } from 'react'; +import { useId } from 'react'; import { motion, useReducedMotion } from 'motion/react'; import { cn } from './cn'; @@ -24,21 +23,6 @@ type TabsProps = { const PILL_TRANSITION = { type: 'spring', bounce: 0, duration: 0.3 } as const; -// Breathing room left between a scrolled-into-view tab and the scroller edge. -const SCROLL_MARGIN = 8; - -function findScrollParent(el: HTMLElement): HTMLElement | null { - let node = el.parentElement; - while (node) { - if (node.scrollWidth > node.clientWidth) { - const overflowX = getComputedStyle(node).overflowX; - if (overflowX === 'auto' || overflowX === 'scroll') return node; - } - node = node.parentElement; - } - return null; -} - export function Tabs({ items, value, @@ -47,76 +31,30 @@ export function Tabs({ className, size = 'md', }: TabsProps) { - const containerRef = useRef(null); - const buttonRefs = useRef>(new Map()); - const [pill, setPill] = useState<{ x: number; width: number } | null>(null); + const layoutId = useId(); const reducedMotion = useReducedMotion(); const pillTransition = reducedMotion ? { type: 'spring' as const, bounce: 0, duration: 0 } : PILL_TRANSITION; - const measure = useCallback(() => { - const container = containerRef.current; - const btn = buttonRefs.current.get(value); - if (!container || !btn) return; - const cr = container.getBoundingClientRect(); - const br = btn.getBoundingClientRect(); - setPill({ x: br.left - cr.left, width: br.width }); - - // On narrow screens the tab row is wider than its scroll container, so the - // selected tab can sit off-screen. Nudge it into view horizontally only — - // scrollIntoView would also move the page vertically. - const scroller = findScrollParent(container); - if (!scroller) return; - const sr = scroller.getBoundingClientRect(); - const overflowLeft = sr.left - br.left; - const overflowRight = br.right - sr.right; - if (overflowLeft > 0) scroller.scrollLeft -= overflowLeft + SCROLL_MARGIN; - else if (overflowRight > 0) scroller.scrollLeft += overflowRight + SCROLL_MARGIN; - }, [value]); - - useEffect(() => { - measure(); - }, [measure]); - - const handleClick = useCallback( - (event: MouseEvent) => { - const next = event.currentTarget.dataset.value; - if (next) onChange(next); - }, - [onChange], - ); - return (
- {pill && ( - - )} {items.map((item) => { const active = item.value === value; return ( ); })} diff --git a/app/components/ui/icons.tsx b/app/components/ui/icons.tsx index 2751fe2..d990b19 100644 --- a/app/components/ui/icons.tsx +++ b/app/components/ui/icons.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react'; +import { fitIcon } from 'morphicons'; const VIBENET_PATH = 'M30.2895 14.8575L20.0038 20.0002M20.0038 20.0002L10.2895 14.2861M20.0038 20.0002L20.0038 30.8571M30.8608 22.8275V17.1724C30.8608 15.3861 29.9078 13.7354 28.3608 12.8423L22.4737 9.44331C20.9267 8.55015 19.0207 8.55015 17.4737 9.44331L11.5865 12.8423C10.0395 13.7354 9.08649 15.3861 9.08649 17.1724V22.8275C9.08649 24.6138 10.0395 26.2644 11.5865 27.1576L17.4737 30.5566C19.0207 31.4497 20.9267 31.4497 22.4737 30.5566L28.3608 27.1576C29.9078 26.2644 30.8608 24.6138 30.8608 22.8275Z'; @@ -100,6 +101,15 @@ export const CLIPBOARD_MORPH_ICON = 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'; export const CHECK_MORPH_ICON = 'M5 13l4 4L19 7'; +// Overlapping-squares copy glyph drawn on a 40x40 grid (InlineCommand on +// the snapshots page). fitIcon regrids it onto morphicons' 24x24 baseline +// so it can morph against CHECK_MORPH_ICON. Stroke 2.5 on 40x40 is 1.5 on +// 24x24 at the same display size (2.5 * 24 / 40). +export const COPY_SQUARES_PATH_40 = + 'M16.6667 23.3333V26.6667C16.6667 28.5076 18.1591 30 20 30H26.6667C28.5076 30 30 28.5076 30 26.6667V20C30 18.1591 28.5076 16.6667 26.6667 16.6667H23.3333M23.3333 16.6667V13.3333C23.3333 11.4924 21.8409 10 20 10H13.3333C11.4924 10 10 11.4924 10 13.3333V20C10 21.8409 11.4924 23.3333 13.3333 23.3333H20C21.8409 23.3333 23.3333 21.8409 23.3333 20V16.6667Z'; +export const COPY_SQUARES_MORPH_ICON = fitIcon(COPY_SQUARES_PATH_40, 40); +export const COPY_SQUARES_MORPH_STROKE_WIDTH = 2.5 * (24 / 40); + export function CheckIcon({ size = 16, className }: { size?: number; className?: string }) { return ( = { const REQUIRED_COMPONENTS = new Set(['state', 'headers']); const SHIMMER_GRADIENT = - 'linear-gradient(90deg, currentColor 0%, currentColor 30%, var(--bds-brand) 50%, currentColor 70%, currentColor 100%)'; + 'linear-gradient(90deg, currentColor 0%, currentColor 40%, var(--shimmer-highlight) 50%, currentColor 60%, currentColor 100%)'; function InlineCommand({ command, onCopy }: { command: string; onCopy?: () => void }) { const [copied, setCopied] = useState(false); const [hovered, setHovered] = useState(false); const textRef = useRef(null); + const shimmerRef = useRef(null); const containerRef = useRef(null); const [overflowPx, setOverflowPx] = useState(0); - const [shimmer, setShimmer] = useState(false); - const prevCommand = useRef(command); - const reducedMotion = useReducedMotion(); useEffect(() => { const measure = () => { @@ -57,13 +56,12 @@ function InlineCommand({ command, onCopy }: { command: string; onCopy?: () => vo }, [command]); useEffect(() => { - if (prevCommand.current !== command) { - prevCommand.current = command; - if (!reducedMotion) { - setShimmer(true); - } - } - }, [command, reducedMotion]); + const animation = shimmerRef.current?.animate( + [{ backgroundPosition: '100% 0%' }, { backgroundPosition: '0% 0%' }], + { duration: 700, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' }, + ); + return () => animation?.cancel(); + }, [command]); const handleCopy = useCallback(() => { void navigator.clipboard.writeText(command).then(() => { @@ -95,27 +93,21 @@ function InlineCommand({ command, onCopy }: { command: string; onCopy?: () => vo transition={hovered ? { duration: overflowPx / 100, ease: 'linear' } : { duration: 0.3, ease: 'easeOut' }} className="block whitespace-nowrap" > - setShimmer(false)} - className="bg-[length:200%_100%] bg-clip-text" - style={{ - backgroundImage: shimmer ? SHIMMER_GRADIENT : 'none', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: shimmer ? 'transparent' : undefined, - }} + - $ {command} + $ {command} - + {overflowPx > 0 && ( )}
@@ -157,7 +149,7 @@ function InlineCommand({ command, onCopy }: { command: string; onCopy?: () => vo exit={{ opacity: 0, scale: 0.5 }} transition={{ duration: 0.15 }} > - + )} diff --git a/app/upgrades/changelog/ChangelogClient.tsx b/app/upgrades/changelog/ChangelogClient.tsx index 3a9c009..db732ab 100644 --- a/app/upgrades/changelog/ChangelogClient.tsx +++ b/app/upgrades/changelog/ChangelogClient.tsx @@ -190,11 +190,11 @@ export function ChangelogClient() { onChange={handleQueryChange} placeholder="Search by title, EIP, or summary keyword" aria-label="Search changes" - className="h-[34px] min-w-0 flex-1 rounded-full border border-bds-gray-10 bg-background px-4 text-[14px] text-foreground outline-none placeholder:text-bds-gray-50" + className="h-[34px] min-w-[min(100%,14rem)] flex-1 rounded-full border border-bds-gray-10 bg-background px-4 text-[14px] text-foreground outline-none placeholder:text-bds-gray-50" /> -
+
@@ -275,7 +275,7 @@ export function ChangelogClient() {
-
+
{filtered.map((change) => ( {shown}