Skip to content
Merged
22 changes: 5 additions & 17 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ type NavRowProps = {
enabled: boolean;
hasChildren?: boolean;
onNavigate?: () => void;
layoutScope?: string;
};

type NavGlyphProps = {
Expand Down Expand Up @@ -82,9 +81,8 @@ const styles: Record<string, CSSProperties> = {
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: {
Expand Down Expand Up @@ -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)';
Expand All @@ -311,12 +309,7 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l
}}
>
{active && (
<motion.div
layoutId={`nav-active-bg-${layoutScope}`}
// Only remasure when the highlighted row changes. Theme toggles,
// banner dismiss, and other sidebar rerenders shift this pill's
// page position; without a dependency Motion would slide it there.
layoutDependency={href}
<div
style={{
position: 'absolute',
inset: 0,
Expand All @@ -328,7 +321,6 @@ function NavRow({ icon, label, href, active, enabled, hasChildren, onNavigate, l
zIndex: -1,
pointerEvents: 'none',
}}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
/>
)}
{icon && (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -539,7 +530,6 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop
active={active}
enabled={true}
onNavigate={() => selectPath(child.href)}
layoutScope={`${layoutScope}-sub`}
/>
);
})}
Expand Down Expand Up @@ -568,7 +558,6 @@ function SidebarContent({ dark, onToggleTheme, onNavigate, hideBrand, layoutScop
enabled={item.enabled}
hasChildren={!!item.children}
onNavigate={() => selectPath(item.href)}
layoutScope={layoutScope}
/>
))}
</nav>
Expand Down Expand Up @@ -767,7 +756,6 @@ export function AppShell({ children }: PropsWithChildren) {
onToggleTheme={toggleTheme}
onNavigate={() => setMenuOpen(false)}
hideBrand
layoutScope="mobile"
/>
</Dialog.Popup>
</Dialog.Portal>
Expand Down
86 changes: 18 additions & 68 deletions app/components/ui/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -47,76 +31,30 @@ export function Tabs({
className,
size = 'md',
}: TabsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<Map<string, HTMLButtonElement>>(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<HTMLButtonElement>) => {
const next = event.currentTarget.dataset.value;
if (next) onChange(next);
},
[onChange],
);

return (
<div
ref={containerRef}
role="tablist"
aria-label={ariaLabel}
className={cn('relative inline-flex w-max rounded-full bg-bds-gray-5 p-1', className)}
>
{pill && (
<motion.span
animate={{ x: pill.x, width: pill.width }}
transition={pillTransition}
className="absolute top-1 bottom-1 left-0 rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.06)]"
/>
)}
{items.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
ref={(el) => {
if (el) buttonRefs.current.set(item.value, el);
}}
type="button"
role="tab"
aria-selected={active}
data-value={item.value}
onClick={handleClick}
onClick={() => !item.disabled && onChange(item.value)}
disabled={item.disabled}
className={cn(
'relative z-[1] flex shrink-0 select-none items-center gap-1.5 rounded-full font-sans whitespace-nowrap transition-colors',
'relative flex shrink-0 select-none items-center gap-1.5 rounded-full font-sans whitespace-nowrap transition-colors',
size === 'sm' ? 'px-2.5 py-1 text-[12px]' : 'px-3 py-1.5 text-[14px]',
item.disabled
? 'cursor-not-allowed text-bds-gray-40'
Expand All @@ -125,8 +63,20 @@ export function Tabs({
: 'text-bds-gray-60 hover:text-foreground dark:text-bds-gray-40 dark:hover:text-white',
)}
>
{item.icon && <span className="flex shrink-0">{item.icon}</span>}
{item.label}
{active ? (
<motion.span
layoutId={layoutId}
layoutDependency={value}
transition={pillTransition}
className="absolute inset-0 bg-background"
style={{
borderRadius: 9999,
boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
}}
/>
) : null}
{item.icon ? <span className="relative z-[1] flex shrink-0">{item.icon}</span> : null}
<span className="relative z-[1]">{item.label}</span>
</button>
);
})}
Expand Down
10 changes: 10 additions & 0 deletions app/components/ui/icons.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 (
<svg
Expand Down
3 changes: 2 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* 100 mobile drawer (.drawer, below)
* 110 mobile header (.mobile-header, below)
* 120 modals (components/ui/Modal.tsx)
* 130 poppers over modals (components/ui/Select.tsx)
* 130 poppers over modals (components/ui/Select.tsx,
* vibenet/demos/_shared/AccountSwitcher.tsx)
*
* A popper below the modal layer still opens, but paints behind the panel —
* which reads as "the dropdown does nothing". */
Expand Down
44 changes: 18 additions & 26 deletions app/snapshots/SnapshotsClient.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import { MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';

import { Card } from '../components/ui/Card';
import { Checkbox } from '../components/ui/Checkbox';
import { cn } from '../components/ui/cn';
import { COPY_SQUARES_PATH_40 } from '../components/ui/icons';
import { Tabs } from '../components/ui/Tabs';
import { Text } from '../components/ui/Text';

Expand Down Expand Up @@ -33,17 +34,15 @@ const NETWORK_LABELS: Record<string, string> = {
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<HTMLSpanElement>(null);
const shimmerRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [overflowPx, setOverflowPx] = useState(0);
const [shimmer, setShimmer] = useState(false);
const prevCommand = useRef(command);
const reducedMotion = useReducedMotion();

useEffect(() => {
const measure = () => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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"
>
<motion.span
animate={{ backgroundPosition: shimmer ? ['200% center', '0% center'] : '0% center' }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
onAnimationComplete={() => setShimmer(false)}
className="bg-[length:200%_100%] bg-clip-text"
style={{
backgroundImage: shimmer ? SHIMMER_GRADIENT : 'none',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: shimmer ? 'transparent' : undefined,
}}
<span
ref={shimmerRef}
className="bg-[length:300%_100%] bg-clip-text bg-no-repeat [--shimmer-highlight:var(--bds-blue-15)] dark:[--shimmer-highlight:var(--bds-brand)]"
style={{ backgroundImage: SHIMMER_GRADIENT, WebkitTextFillColor: 'transparent' }}
>
<Text as="span" variant="label.mono">
<span className="text-bds-gray-40" style={{ WebkitTextFillColor: shimmer ? 'initial' : undefined }}>$</span> {command}
<span className="text-bds-gray-40" style={{ WebkitTextFillColor: 'initial' }}>$</span> {command}
</Text>
</motion.span>
</span>
</motion.span>
{overflowPx > 0 && (
<motion.div
animate={{ opacity: hovered ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-white via-white/80 to-transparent"
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-background to-transparent"
/>
)}
</div>
Expand Down Expand Up @@ -157,7 +149,7 @@ function InlineCommand({ command, onCopy }: { command: string; onCopy?: () => vo
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.15 }}
>
<path d="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" stroke="currentColor" strokeWidth={2.5} />
<path d={COPY_SQUARES_PATH_40} stroke="currentColor" strokeWidth={2.5} />
</motion.svg>
)}
</AnimatePresence>
Expand Down
6 changes: 3 additions & 3 deletions app/upgrades/changelog/ChangelogClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</div>

<div className="hidden md:block">
<div className="hidden [@container(min-width:48rem)]:block">
<table className="w-full table-fixed text-left text-sm">
<thead className="border-b border-bds-gray-10 text-bds-gray-50">
<tr aria-label="Column headers">
Expand Down Expand Up @@ -275,7 +275,7 @@ export function ChangelogClient() {
</table>
</div>

<div className="grid gap-3 md:hidden">
<div className="grid gap-3 [@container(min-width:48rem)]:hidden">
{filtered.map((change) => (
<LinkCard
key={change.id}
Expand Down
Loading
Loading