Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
410 changes: 219 additions & 191 deletions app/components/AppShell.tsx

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions app/components/NavScrollArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { ScrollArea } from '@base-ui/react/scroll-area';
import { PropsWithChildren, Ref } from 'react';

type NavScrollAreaProps = PropsWithChildren<{
viewportRef?: Ref<HTMLDivElement>;
}>;

/**
* Scrollable middle of the sidebar: logo, then the sliding pane (main nav or
* a section submenu). Uses Base UI's scroll area so the native bar stays
* hidden and a thin custom thumb appears while scrolling. The bottom fade is
* a CSS mask on the viewport driven by `--scroll-area-overflow-y-end`.
*/
export function NavScrollArea({ children, viewportRef }: NavScrollAreaProps) {
return (
<ScrollArea.Root className="sidebar-scroll-area">
<ScrollArea.Viewport ref={viewportRef} className="sidebar-scroll-viewport">
<ScrollArea.Content>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" className="sidebar-scroll-bar">
<ScrollArea.Thumb className="sidebar-scroll-thumb" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
43 changes: 43 additions & 0 deletions app/components/nav-motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';

import { navSlideDirection, SCROLL_FADE_MAX_PX, scrollEdges } from './nav-motion';

describe('navSlideDirection', () => {
it('slides forward when entering a section', () => {
expect(navSlideDirection(null, '/vibenet')).toBe(1);
});

it('slides backward when leaving a section', () => {
expect(navSlideDirection('/vibenet', null)).toBe(-1);
});

it('slides forward when moving between sections', () => {
expect(navSlideDirection('/vibenet', '/benchmark')).toBe(1);
});

it('returns null when the parent is unchanged', () => {
expect(navSlideDirection('/vibenet', '/vibenet')).toBeNull();
expect(navSlideDirection(null, null)).toBeNull();
});
});

describe('scrollEdges', () => {
it('returns no fade when content fits the viewport', () => {
expect(scrollEdges(0, 200, 200)).toEqual({ top: 0, bottom: 0 });
expect(scrollEdges(0, 180, 200)).toEqual({ top: 0, bottom: 0 });
});

it('grows the bottom fade to the cap at the start of an overflowing list', () => {
expect(scrollEdges(0, 400, 200)).toEqual({ top: 0, bottom: SCROLL_FADE_MAX_PX });
});

it('grows the top fade with scrollTop until the cap', () => {
expect(scrollEdges(16, 400, 200)).toEqual({ top: 16, bottom: 40 });
expect(scrollEdges(80, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: SCROLL_FADE_MAX_PX });
});

it('shrinks the bottom fade as the end approaches', () => {
expect(scrollEdges(180, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: 20 });
expect(scrollEdges(200, 400, 200)).toEqual({ top: SCROLL_FADE_MAX_PX, bottom: 0 });
});
});
45 changes: 45 additions & 0 deletions app/components/nav-motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Slide direction for the sidebar pane swap. Computed during render (not in an
* effect) so AnimatePresence sees the new value on the same frame the pane
* changes — otherwise the first back-navigation still uses the inbound +1.
*
* Returns null when the parent did not change, so the caller can leave its
* last direction in place.
*/
export function navSlideDirection(
prevParentHref: string | null,
currParentHref: string | null,
): 1 | -1 | null {
if (prevParentHref === currParentHref) return null;
return currParentHref ? 1 : -1;
}

export const SCROLL_FADE_MAX_PX = 40;

export type ScrollEdges = {
top: number;
bottom: number;
};

/**
* Fade band size in px at each vertical edge. The sidebar viewport only
* paints the bottom band (`--scroll-area-overflow-y-end`); the start value
* is kept so callers can still reason about both edges. The CSS variable is
* the pixel distance from that edge; the mask grows from 0 to 40px as you
* scroll away, then holds. Both are 0 when content fits the viewport.
*/
export function scrollEdges(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
maxFadePx = SCROLL_FADE_MAX_PX,
): ScrollEdges {
const overflow = Math.max(0, scrollHeight - clientHeight);
if (overflow === 0) return { top: 0, bottom: 0 };
const start = Math.min(Math.max(0, scrollTop), overflow);
const end = overflow - start;
return {
top: Math.min(maxFadePx, start),
bottom: Math.min(maxFadePx, end),
};
}
132 changes: 113 additions & 19 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,107 @@ button {
background: rgba(255, 255, 255, 0.35);
}

/* Base UI scroll area for each sliding pane (main nav and section submenu).
Native bars stay hidden; the custom thumb is the only visible scroller. */
.sidebar-scroll-area {
position: relative;
height: 100%;
min-height: 0;
}

.sidebar-scroll-viewport {
--fade-size: 40px;
height: 100%;
overscroll-behavior: contain;
scrollbar-width: none;
background: var(--bds-gray-0);
/* Bottom fade only — the logo sits at the top of this scroller, so a top
band would wash it out. Base UI's overflow var is the px distance from
the end; the mask grows from 0 to --fade-size as you scroll, then holds.
0px fallback so a short list does not flash a fade before hydrate. */
mask-image: linear-gradient(
to bottom,
black 0,
black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px))),
transparent 100%
);
mask-repeat: no-repeat;
-webkit-mask-image: linear-gradient(
to bottom,
black 0,
black calc(100% - min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px))),
transparent 100%
);
-webkit-mask-repeat: no-repeat;
}

.sidebar-scroll-viewport::-webkit-scrollbar {
display: none;
}

.sidebar-scroll-bar {
display: flex;
justify-content: center;
width: 5px;
margin: 2px;
opacity: 0;
transition: opacity 150ms ease;
pointer-events: none;
}

.sidebar-scroll-bar[data-hovering],
.sidebar-scroll-bar[data-scrolling] {
opacity: 1;
pointer-events: auto;
}

.sidebar-scroll-bar[data-scrolling] {
transition-duration: 0ms;
}

.sidebar-scroll-thumb {
width: 100%;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.15);
}

.sidebar-scroll-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}

[data-theme='dark'] .sidebar-scroll-thumb {
background: rgba(255, 255, 255, 0.2);
}

[data-theme='dark'] .sidebar-scroll-thumb:hover {
background: rgba(255, 255, 255, 0.35);
}

@media (prefers-reduced-motion: reduce) {
.sidebar-scroll-bar {
transition: none;
}
}

/* Horizontal inset lives on the content inside the scroller (and on the logo /
footer, which sit outside it) so overflow is full-bleed. */
.sidebar-gutter {
box-sizing: border-box;
width: 100%;
padding-left: 12px;
padding-right: 12px;
}

/* Extra 8px so the mark still lines up with the nav icons (row padding 10px).
18px above and below the 28px mark — the old 64px brand row, now inside
the scroller so it rolls away with the list. */
.sidebar-brand.sidebar-gutter {
padding-top: 18px;
padding-bottom: 18px;
padding-left: 20px;
padding-right: 20px;
}

/* Sidebar hover surfaces. Nav rows paint theirs on a layer at z-index -2 — below
the selected pill at -1 and below every label — so the pill can animate *over*
the hover surface of the row it lands on (light gray deepens to the selected
Expand Down Expand Up @@ -484,23 +585,6 @@ button {
background-color: var(--bds-gray-5);
}

/* Pulled out of flow so the switch shares the sidebar's bottom row with the last
footer link, which lives inside the sliding nav pane the switch sits outside of.
The insets match each container's own bottom/right padding: the desktop sidebar
pads `0 12px 20px`, the mobile drawer `12px 8px 20px`. Both are already
positioned (`relative` and `fixed`), so each is its own containing block. */
.theme-switch-footer {
position: absolute;
right: 12px;
bottom: 20px;
}

@media (max-width: 767px) {
.theme-switch-footer {
right: 8px;
}
}

/* 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 {
Expand Down Expand Up @@ -676,6 +760,16 @@ button {
}

@media (max-width: 767px) {
.sidebar-gutter {
padding-left: 8px;
padding-right: 8px;
}

.sidebar-brand.sidebar-gutter {
padding-left: 16px;
padding-right: 16px;
}

.sidebar-desktop {
display: none !important;
}
Expand Down Expand Up @@ -709,8 +803,8 @@ button {
bottom: 0;
z-index: 100;
background: var(--bds-gray-0);
padding: 12px 8px 20px;
overflow-y: auto;
padding: 12px 0 20px;
overflow: hidden;
}

.mobile-content-offset {
Expand Down
52 changes: 43 additions & 9 deletions app/vibenet/components/CopyableValue.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useCallback, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';

import { cn } from '../../components/ui/cn';

Expand All @@ -10,6 +11,8 @@ type CopyableValueProps = {
className?: string;
};

const ICON_TRANSITION = { duration: 0.15 };

export function CopyableValue({ value, display, className }: CopyableValueProps) {
const [copied, setCopied] = useState(false);

Expand Down Expand Up @@ -41,15 +44,46 @@ export function CopyableValue({ value, display, className }: CopyableValueProps)
)}
>
<code className="truncate">{shown}</code>
{copied ? (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-bds-green-60">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg width={20} height={20} viewBox="0 0 40 40" fill="none" className="shrink-0 text-bds-gray-50 transition-colors group-hover:text-foreground">
<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} />
</svg>
)}
<span className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.svg
key="check"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={ICON_TRANSITION}
className="text-bds-green-60"
>
<path d="M20 6 9 17l-5-5" />
</motion.svg>
) : (
<motion.svg
key="copy"
width={20}
height={20}
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={ICON_TRANSITION}
className="text-bds-gray-50 transition-colors group-hover:text-foreground"
>
<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} />
</motion.svg>
)}
</AnimatePresence>
</span>
</button>
);
}
Loading
Loading