feat(website): add the project site and deploy it to GitHub Pages

A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.

The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.

Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.

Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.

CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
This commit is contained in:
Michel de Graaf
2026-07-24 23:42:41 +02:00
parent 54feb3bc5a
commit 33bfc34dc8
58 changed files with 5239 additions and 1 deletions
+27
View File
@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
interface LogoProps {
/** Height in px. Width follows the artwork's aspect ratio. */
size?: number
className?: string
}
/** Native aspect ratio of the logo artwork (186.407 × 211). */
const ASPECT = 186.407 / 211
/*
* Two assets rather than one recoloured file — the ink and light cuts differ
* in more than colour — swapped by CSS.
*
* Background images, not <img>: a hidden <img> is still downloaded, so the
* two-element version fetched both cuts on every visit (~12-23 kB gzip wasted
* above the fold). A background on a non-painted rule is never requested.
*/
export const Logo = ({ size = 180, className }: LogoProps) => (
<div
role="img"
aria-label="soulseek-rs"
className={cn('logo-mark shrink-0 bg-contain bg-no-repeat', className)}
style={{ height: size, width: size * ASPECT }}
/>
)
+32
View File
@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils'
interface WordmarkProps {
/** Font size in px. */
size?: number
blink?: boolean
className?: string
}
/**
* The primary soulseek-rs mark: lowercase mono wordmark plus a solid block
* cursor. Live text rather than an asset, so it blinks for free and stays
* crisp at any size.
*/
export const Wordmark = ({ size = 28, blink = false, className }: WordmarkProps) => (
<span
className={cn(
'inline-flex items-center whitespace-nowrap font-mono font-medium leading-none tracking-[-0.01em] text-primary',
className,
)}
style={{ fontSize: size }}
>
soulseek-rs
<span
aria-hidden="true"
className={cn(
'ml-[0.06em] inline-block h-[1.02em] w-[0.58em] translate-y-[0.04em] rounded-[1.5px] bg-accent',
blink && 'motion-safe:animate-cursor-blink',
)}
/>
</span>
)
+98
View File
@@ -0,0 +1,98 @@
import { Link } from 'react-router'
import { Wordmark } from '@/components/brand/wordmark'
import { Wrap } from '@/components/ui/layout'
import { LINKS, VERSION } from '@/lib/links'
interface FooterLink {
label: string
href: string
}
interface FooterColumn {
heading: string
links: readonly FooterLink[]
}
const COLUMNS: readonly FooterColumn[] = [
{
heading: 'Project',
links: [
{ label: 'GitHub', href: LINKS.gh },
{ label: 'Issues', href: LINKS.issues },
{ label: 'Releases', href: LINKS.releases },
{ label: 'Changelog', href: LINKS.changelog },
{ label: 'soulfind server', href: LINKS.soulfind },
],
},
{
heading: 'Packages',
links: [
{ label: 'crates.io · client', href: LINKS.cratesClient },
{ label: 'crates.io · lib', href: LINKS.cratesLib },
{ label: 'docs.rs', href: LINKS.docsrs },
{ label: 'rustup', href: LINKS.rustup },
],
},
{
heading: 'Read',
links: [
{ label: 'Docs', href: '/docs' },
{ label: 'Install', href: '/install' },
{ label: 'Community', href: '/community' },
{ label: 'License · MIT', href: LINKS.license },
],
},
]
const LINK_CLASS = 'text-[13px] text-secondary hover:text-primary'
const FooterAnchor = ({ label, href }: FooterLink) =>
href.startsWith('/') ? (
<Link to={href} className={LINK_CLASS}>
{label}
</Link>
) : (
<a href={href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{label}
</a>
)
export const Footer = () => (
<footer className="border-t border-hairline bg-base pt-11 pb-10">
<Wrap>
<div className="grid grid-cols-1 gap-8 min-[520px]:grid-cols-2 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
<div className="flex max-w-[320px] flex-col gap-3.5">
<Wordmark size={22} />
<p className="text-[13px] leading-[22px] text-secondary">
A Soulseek client and protocol library for Rust.
</p>
<p className="font-forum text-xs text-muted">
Not affiliated with or endorsed by the Soulseek project.
</p>
</div>
{COLUMNS.map((column) => (
<div key={column.heading}>
<h2 className="mb-3.5 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
{column.heading}
</h2>
<ul>
{column.links.map((link) => (
<li key={link.label} className="mb-2.5">
<FooterAnchor {...link} />
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-11 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-hairline pt-5.5 text-[12.5px] text-muted">
<span>MIT · © 2026 Michel de Graaf</span>
<span className="text-success">No telemetry. Ever.</span>
<span className="ml-auto">{VERSION}</span>
</div>
</Wrap>
</footer>
)
+63
View File
@@ -0,0 +1,63 @@
import { NavLink } from 'react-router'
import { Wordmark } from '@/components/brand/wordmark'
import { ThemeToggle } from '@/components/layout/theme-toggle'
import { LINKS } from '@/lib/links'
import { cn } from '@/lib/utils'
const ITEMS = [
{ label: 'home', to: '/' },
{ label: 'install', to: '/install' },
{ label: 'docs', to: '/docs' },
{ label: 'community', to: '/community' },
] as const
/*
* No hamburger. Below 820px the links become their own horizontally
* scrollable row, which keeps every destination one tap away instead of
* two — the site has four pages, a drawer would be ceremony.
*/
export const Nav = () => (
<header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]">
<div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0">
<NavLink to="/" aria-label="soulseek-rs home" className="mr-auto flex items-center">
<Wordmark size={19} blink />
</NavLink>
<nav
aria-label="Main"
className="order-3 flex w-full items-center gap-0.5 overflow-x-auto border-t border-hairline py-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:order-none sm:w-auto sm:border-t-0 sm:py-0 [&::-webkit-scrollbar]:hidden"
>
{ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'relative inline-flex shrink-0 items-center rounded-md px-3.5 py-1.5 text-label uppercase tracking-[var(--tracking-label)] max-sm:min-h-11 sm:px-3',
isActive
? "bg-raised text-primary before:text-accent before:content-['›_'] after:absolute after:inset-x-3 after:bottom-px after:h-0.5 after:bg-accent"
: 'text-secondary hover:bg-[var(--bg-selection)] hover:text-primary',
)
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2">
<a
href={LINKS.gh}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-[38px] items-center gap-1.5 rounded-md border border-hairline px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
>
GitHub <span aria-hidden="true">↗</span>
</a>
<ThemeToggle />
</div>
</div>
</header>
)
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { Outlet, useLocation } from 'react-router'
import { Footer } from '@/components/layout/footer'
import { Nav } from '@/components/layout/nav'
import { usePageMeta } from '@/hooks/use-page-meta'
/**
* On navigation: jump to the anchor if the URL carries one, otherwise back to
* the top, and move keyboard focus into the main landmark.
*
* That last part matters — without it focus stays on the link that was
* clicked, so a screen reader never announces that the page changed.
*
* replaces react-router's <ScrollRestoration>, which only works
* under a data router. Restoring exact scroll offsets on back/forward would
* mean adopting createBrowserRouter for four static pages.
*/
const useNavigationEffects = () => {
const { pathname, hash } = useLocation()
useEffect(() => {
if (hash !== '') {
document.querySelector(hash)?.scrollIntoView()
} else {
window.scrollTo(0, 0)
}
document.getElementById('main')?.focus({ preventScroll: true })
}, [pathname, hash])
}
export const SiteLayout = () => {
useNavigationEffects()
usePageMeta()
return (
<>
<a
href="#main"
className="sr-only rounded-md border border-line bg-panel px-4 py-2 focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-100"
>
Skip to content
</a>
<Nav />
<main id="main" tabIndex={-1} className="outline-none">
<Outlet />
</main>
<Footer />
</>
)
}
@@ -0,0 +1,42 @@
import { useTheme } from '@/theme/use-theme'
import type { Theme } from '@/theme/theme-context'
/*
* Cycles system → light → dark → system rather than a plain two-way flip, so
* "follow the OS" stays reachable after the first click. The glyph shows what
* is painted; the label says which mode is selected.
*/
const NEXT: Record<Theme, Theme> = {
system: 'light',
light: 'dark',
dark: 'system',
}
const LABEL: Record<Theme, string> = {
system: 'system',
light: 'light',
dark: 'dark',
}
export const ThemeToggle = () => {
const { theme, resolvedTheme, setTheme } = useTheme()
return (
<button
type="button"
onClick={() => {
setTheme(NEXT[theme])
}}
aria-label={`Theme: ${LABEL[theme]}. Switch to ${LABEL[NEXT[theme]]}.`}
title={`Theme: ${LABEL[theme]}`}
className="inline-flex h-[38px] items-center justify-center gap-1.5 rounded-md border border-hairline bg-transparent px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
>
<span aria-hidden="true">{resolvedTheme === 'light' ? '☀' : '☾'}</span>
{theme === 'system' && (
<span aria-hidden="true" className="text-[10px] opacity-70">
auto
</span>
)}
</button>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { VERSION } from '@/lib/links'
import { cn } from '@/lib/utils'
import { CLOCK, DATE, HOST, SESSION, SHORTCUTS, WINDOWS } from './data'
interface TuiPaneProps {
title: string
num?: number
active?: boolean
children: React.ReactNode
className?: string
bodyClassName?: string
}
/**
* The TUI's bordered pane: a legend that breaks the top border, green when
* the pane has focus. Mirrors `pane_block()` in the Rust client.
*/
export const TuiPane = ({
title,
num,
active = false,
children,
className,
bodyClassName,
}: TuiPaneProps) => (
<div
className={cn(
'relative border bg-base',
active ? 'border-[var(--color-phosphor)]' : 'border-[color-mix(in_srgb,var(--color-dust)_45%,transparent)]',
className,
)}
>
<span
className={cn(
'absolute -top-[9px] left-3 bg-base px-1.5 text-[12.5px] whitespace-nowrap',
active ? 'text-[var(--color-phosphor)]' : 'text-[var(--color-dust)]',
)}
>
{num !== undefined && <span className="text-[var(--color-signal)]">[{num}] </span>}
{title}
</span>
<div className={bodyClassName}>{children}</div>
</div>
)
/** The macOS window bezel the whole demo sits in. */
export const MacWindow = ({ children }: { children: React.ReactNode }) => (
<div
className="w-[1360px] overflow-hidden rounded-xl border bg-base font-mono tabular-nums"
style={{ borderColor: 'var(--tui-bezel)' }}
>
{children}
</div>
)
export const TmuxBar = () => (
<div className="flex h-[26px] items-center border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] bg-[var(--tmux-bg)] text-[12.5px] text-[var(--color-phosphor)]">
<span className="flex h-full items-center bg-[var(--color-phosphor)] px-2.5 font-bold text-[var(--tmux-bg)]">
{SESSION}
</span>
{WINDOWS.map((w) => (
<span
key={w.n}
className={cn(
'flex h-full items-center gap-1.5 px-3',
w.active === true
? 'bg-[var(--tmux-hl)] font-bold text-[var(--tmux-hl-fg)]'
: 'text-[var(--color-phosphor)]',
)}
>
{w.n} <span className="opacity-55">›</span> {w.name}
</span>
))}
<span className="ml-auto flex gap-4 px-3 text-[var(--color-paper)]">
<span className="bg-[var(--tmux-hl)] px-2 font-semibold text-[var(--tmux-hl-fg)]">
{DATE} · {CLOCK}
</span>
<span className="font-bold">{HOST}</span>
</span>
</div>
)
export interface StatusCounts {
active: number
done: number
fail: number
queued: number
}
interface StatusLineProps {
counts: StatusCounts
pct: number
}
export const StatusLine = ({ counts, pct }: StatusLineProps) => (
<TuiPane title="Status" bodyClassName="px-3 py-2">
<div className="flex items-center gap-3.5 text-[13px] whitespace-nowrap text-[var(--color-paper)]">
<span>
<b className="font-semibold">soulseek-rs</b> 🦀 {VERSION}
</span>
<span className="text-[var(--color-dust)]">
Downloads: <span className="text-[var(--color-oxide)]">{counts.active} active</span>
, <span className="text-[var(--color-phosphor)]">{counts.done} completed</span>,{' '}
<span className="text-[var(--color-alarm)]">{counts.fail} failed</span>,{' '}
<span className="text-[var(--color-tape)]">{counts.queued} queued</span>, 0 paused
</span>
<span className="ml-auto flex items-center gap-2.5 text-[var(--color-paper)]">
67.7/205.2 MB · 33.38 MB/s
<span className="relative inline-block h-[13px] w-[340px] border border-[var(--tm-bd)] bg-[var(--tui-track)]">
<span
className="absolute inset-y-0 left-0 bg-[var(--color-signal)] opacity-55 transition-[width] duration-300"
style={{ width: `${String(pct)}%` }}
/>
</span>
<b>{pct}%</b>
</span>
</div>
</TuiPane>
)
export const ShortcutBar = ({ share }: { share: string }) => (
<TuiPane title="Shortcuts · Sharing" className="mt-2.5" bodyClassName="px-3 py-2">
<div className="mb-1.5 text-xs text-[var(--color-dust)]">
Sharing: <span className="text-[var(--color-phosphor)]">{share}</span>
</div>
<div className="flex flex-wrap gap-3.5 text-[12.5px] text-[var(--color-dust)]">
{SHORTCUTS.map(([key, action]) => (
<span key={key}>
[<span className="text-[var(--color-signal)]">{key}</span> → {action}]
</span>
))}
</div>
</TuiPane>
)
+216
View File
@@ -0,0 +1,216 @@
/*
* Sample data for the TUI recreation in the hero.
*
* Everything here is invented, public-domain, or Creative Commons. No real
* artist, album, or track names — see TONE-OF-VOICE.md §5 "Banned for legal
* reasons". Anything added here has to keep that true.
*/
export interface SearchRow {
id: string
query: string
results: number | string
status: string
}
export interface ResultRow {
name: string
size: string
user: string
bitrate: string
speed: string
slots: number
/** Marked for download with Space, not yet queued. */
marked?: boolean
/** Handed to the transfer list. */
queued?: boolean
}
export type TransferStatus = 'queued' | 'active' | 'done' | 'fail'
export interface TransferRow {
id: string
status: TransferStatus
name: string
user: string
progress: string
speed: string
/** Present only on transfers the demo drives; the static rows leave it out. */
pct?: number
sizeMb?: number
rateMbps?: number
}
interface TmuxWindow {
n: number
name: string
active?: boolean
}
export const HOST = 'michels-macbook-pro-m4'
export const CLOCK = '01:45 PM'
export const DATE = '2026-07-24'
export const SESSION = 'develop'
export const SHARE = '/Users/micheldegraaf/Downloads/Soulseek'
export const WINDOWS: readonly TmuxWindow[] = [
{ n: 1, name: 'fish #' },
{ n: 2, name: 'fish #' },
{ n: 3, name: 'fish #' },
{ n: 4, name: 'fish #-' },
{ n: 5, name: 'fish *', active: true },
]
export const SEARCHES: readonly SearchRow[] = [
{ id: 's1', query: 'public domain field recordings', results: 606, status: 'Done' },
{ id: 's2', query: 'creative commons ambient', results: 33, status: 'Done' },
{ id: 's3', query: 'netlabel 2004', results: 608, status: 'Done' },
]
const pd = (name: string, size: string, bitrate: string): ResultRow => ({
name: `shared\\archive\\PD Field Recordings\\${name}`,
size,
user: 'loopdigger',
bitrate,
speed: '3.35 MB/s',
slots: 1,
})
export const RESULTS: Readonly<Record<string, readonly ResultRow[]>> = {
s1: [
pd('01 - Coastal Dawn (Sunrise Take).flac', '30.5 MB', '675 kbps'),
pd('02 - Reservoir Wind.flac', '22.6 MB', '569 kbps'),
pd('cover.jpg', '0.0 MB', '-'),
pd('03 - Marsh at Night (Long).flac', '48.0 MB', '920 kbps'),
pd('04 - Tram Depot, 06h.flac', '32.2 MB', '725 kbps'),
pd('05 - Harbour Bells.flac', '52.5 MB', '902 kbps'),
pd('06 - Rain on Zinc Roof.flac', '30.4 MB', '804 kbps'),
pd('07 - Empty Chapel Tone.flac', '17.4 MB', '836 kbps'),
pd('08 - Forest Floor.flac', '38.0 MB', '741 kbps'),
pd('09 - Quarry Echo.flac', '34.6 MB', '753 kbps'),
pd('10 - Night Ferry.flac', '18.7 MB', '723 kbps'),
pd('11 - Last Train.flac', '19.5 MB', '621 kbps'),
{
name: 'shared\\netlabel\\Explorers of the Interior [CC-BY]\\readme.txt',
size: '1.0 MB',
user: 'loopdigger',
bitrate: '128 kbps',
speed: '3.35 MB/s',
slots: 1,
},
],
s2: [
{
name: 'shared\\cc\\Ambient Works [CC0]\\01 - Slow Meadow.flac',
size: '41.2 MB',
user: 'tapehiss',
bitrate: '911 kbps',
speed: '2.10 MB/s',
slots: 2,
},
{
name: 'shared\\cc\\Ambient Works [CC0]\\02 - Held Note.flac',
size: '28.9 MB',
user: 'tapehiss',
bitrate: '740 kbps',
speed: '2.10 MB/s',
slots: 2,
},
{
name: 'shared\\cc\\Ambient Works [CC0]\\03 - Grain.flac',
size: '33.1 MB',
user: 'tapehiss',
bitrate: '812 kbps',
speed: '2.10 MB/s',
slots: 2,
},
],
s3: [
{
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\01 - Opener.mp3',
size: '9.4 MB',
user: 'nectoUA',
bitrate: '320 kbps',
speed: '1.05 MB/s',
slots: 0,
},
{
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\02 - Interlude.mp3',
size: '5.1 MB',
user: 'nectoUA',
bitrate: '320 kbps',
speed: '1.05 MB/s',
slots: 0,
},
],
}
export const TRANSFERS: readonly TransferRow[] = [
{
id: 'x1',
status: 'done',
name: '@@lrfkq\\Contents\\Rotterdam',
user: 'Dan Dillion',
progress: 'Completed',
speed: '-',
},
{
id: 'x2',
status: 'done',
name: '@@lrfkq\\Contents\\Journal',
user: 'Dan Dillion',
progress: 'Completed',
speed: '-',
},
{
id: 'x3',
status: 'active',
name: '@@lrfkq\\Contents\\Context',
user: 'Dan Dillion',
progress: '37.5 MB/53.4 MB (70%)',
speed: '12.4 MB/s',
},
{
id: 'x4',
status: 'fail',
name: '@@gufoo\\put all musics',
user: 'publicdomainbot',
progress: 'Failed',
speed: '-',
},
{
id: 'x5',
status: 'active',
name: '02-RawMusic\\Embark Studio',
user: 'tapehiss',
progress: '10.4 MB/14.3 MB (72%)',
speed: '11.5 MB/s',
},
{
id: 'x6',
status: 'fail',
name: '@@medvw\\Soulseek\\complete',
user: 'x100',
progress: 'Failed',
speed: '-',
},
{
id: 'x7',
status: 'active',
name: '@@hnttf\\complete\\(2004 netlabel)',
user: 'nectoUA',
progress: '10.5 MB/25.5 MB (41%)',
speed: '9.3 MB/s',
},
]
export const SHORTCUTS: readonly (readonly [string, string])[] = [
['Space', 'select'],
['Enter', 'download'],
['b', 'browse owner'],
['c', 'chat'],
['/', 'filter'],
['a/A', 'select all/none'],
['1-3', 'focus pane'],
['q', 'quit'],
]
@@ -0,0 +1,482 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { MacWindow, ShortcutBar, StatusLine, TmuxBar, type StatusCounts } from './chrome'
import {
RESULTS,
SEARCHES,
SHARE,
TRANSFERS,
type ResultRow,
type SearchRow,
type TransferRow,
} from './data'
import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
import { useFitScale } from './use-fit-scale'
const DESIGN_WIDTH = 1360
const TICK_MS = 480
const MAX_CONCURRENT = 2
/** Where the scripted run leaves the cursor. */
const RESTING_ROW = 5
type Phase = 'searching' | 'results' | 'interactive'
/** Pages are prerendered in Node, where there is no window. */
const isBrowser = typeof window !== 'undefined'
const matches = (query: string): boolean =>
isBrowser && window.matchMedia(query).matches
const prefersReducedMotion = (): boolean => matches('(prefers-reduced-motion: reduce)')
const sizeMb = (size: string): number => {
const parsed = Number.parseFloat(size)
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
}
/*
* Deterministic on purpose: the id is derived from the filename and the rate
* from its length, so queueing the same row twice is a no-op and nothing
* random runs inside a state updater. Both matter under StrictMode, which
* double-invokes updaters in development.
*/
const toTransfer = (row: ResultRow): TransferRow => ({
id: `t:${row.name}`,
status: 'queued',
name: row.name,
user: row.user,
progress: 'Queued',
speed: '-',
pct: 0,
sizeMb: sizeMb(row.size),
rateMbps: 1.4 + (row.name.length % 7) * 0.4,
})
/**
* The hero: a working recreation of the TUI.
*
* It plays one scripted run (search → results → queue two files) and then
* hands over to the keyboard. Only transfers carrying a `pct` are simulated;
* the seeded rows stay put so the pane looks lived-in from the first frame.
*
* Under `prefers-reduced-motion` it starts at the end state instead, so the
* page never animates at someone who asked it not to.
*/
export const HeroTerminal = () => {
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES)
const [results, setResults] =
useState<Readonly<Record<string, readonly ResultRow[]>>>(RESULTS)
const [activeId, setActiveId] = useState('s1')
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
const [focus, setFocus] = useState(2)
// Reduced motion skips the performance, so the resting state is the initial
// state. Resolved once, in the initialiser, to keep it out of an effect.
const [phase, setPhase] = useState<Phase>(() =>
prefersReducedMotion() ? 'interactive' : 'searching',
)
const [selected, setSelected] = useState(() =>
prefersReducedMotion() ? RESTING_ROW : 0,
)
const shellRef = useRef<HTMLDivElement>(null)
// Mirrors `results` so queueFrom can read the latest rows without taking a
// dependency on them. Synced after commit, which is soon enough: every
// reader is a timer or an event handler, never a render.
const resultsRef = useRef(results)
useEffect(() => {
resultsRef.current = results
}, [results])
const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
const activeSearch = searches.find((search) => search.id === activeId)
const counts = useMemo<StatusCounts>(() => {
const tally: StatusCounts = { active: 0, done: 0, fail: 0, queued: 0 }
for (const transfer of transfers) tally[transfer.status] += 1
return tally
}, [transfers])
// Derived, not stored: the status bar is a pure function of what is moving.
const pct = useMemo(() => {
const live = transfers.filter(
(t) => t.pct !== undefined && (t.status === 'active' || t.status === 'done'),
)
if (live.length === 0) return 0
return Math.round(live.reduce((sum, t) => sum + (t.pct ?? 0), 0) / live.length)
}, [transfers])
/** True while any simulated transfer still has somewhere to go. */
const hasWork = useMemo(
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
[transfers],
)
// Stops once everything has finished. Without the `hasWork` gate the timer
// would tick twice a second for as long as the tab is open, re-rendering
// the whole TUI to produce identical output.
useEffect(() => {
if (!hasWork || prefersReducedMotion()) return
const timer = setInterval(() => {
setTransfers((list) => {
let live = list.filter((t) => t.status === 'active' && t.pct !== undefined).length
const next = list.map((t) => {
if (t.pct === undefined) return t
const total = t.sizeMb ?? 12
const rate = t.rateMbps ?? 2
if (t.status === 'queued' && live < MAX_CONCURRENT) {
live += 1
return {
...t,
status: 'active' as const,
speed: `${rate.toFixed(2)} MB/s`,
progress: `0.0/${total.toFixed(1)} MB (0%)`,
}
}
if (t.status !== 'active') return t
// Step size varies per file but is a pure function of it, so the
// updater stays free of side effects under StrictMode's double call.
const step = 4 + ((t.name.length + Math.round(t.pct)) % 9)
const pct = Math.min(100, t.pct + step)
const finished = pct >= 100
return {
...t,
status: finished ? ('done' as const) : ('active' as const),
pct,
speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
progress: finished
? 'Complete'
: `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
}
})
// map() always allocates, so without this every tick would hand React
// a fresh array and force a re-render that produces identical output.
// Returning the original lets React bail out instead.
const moved = next.some((t, i) => t !== list[i])
return moved ? next : list
})
}, TICK_MS)
return () => {
clearInterval(timer)
}
}, [hasWork])
/*
* Queue rows by index within a named search.
*
* Reads the current rows through a ref rather than closing over `results`,
* so the callback identity never changes — the opening script can then
* depend on it without restarting itself every time state moves.
*/
const queueFrom = useCallback((searchId: string, indexes: readonly number[]) => {
if (indexes.length === 0) return
const current = resultsRef.current[searchId] ?? []
const picked = indexes
.map((i) => current[i])
.filter((row): row is ResultRow => row !== undefined && row.queued !== true)
if (picked.length === 0) return
const wanted = new Set(picked.map((row) => row.name))
setResults((prev) => ({
...prev,
[searchId]: (prev[searchId] ?? []).map((row) =>
wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
),
}))
setTransfers((list) => {
const seen = new Set(list.map((transfer) => transfer.id))
const additions = picked.map(toTransfer).filter((t) => !seen.has(t.id))
return additions.length === 0 ? list : [...additions, ...list]
})
}, [])
const queueIndexes = useCallback(
(indexes: readonly number[]) => {
queueFrom(activeId, indexes)
},
[queueFrom, activeId],
)
const toggleMark = useCallback(
(index: number) => {
setResults((prev) => ({
...prev,
[activeId]: (prev[activeId] ?? []).map((row, i) =>
i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
),
}))
},
[activeId],
)
const markAll = useCallback(
(on: boolean) => {
setResults((prev) => ({
...prev,
[activeId]: (prev[activeId] ?? []).map((row) =>
row.queued === true ? row : { ...row, marked: on },
),
}))
},
[activeId],
)
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLInputElement) return
const list = results[activeId] ?? []
switch (event.key) {
case 'ArrowDown':
case 'j':
event.preventDefault()
setFocus(2)
setSelected((s) => Math.min(list.length - 1, s + 1))
return
case 'ArrowUp':
case 'k':
event.preventDefault()
setFocus(2)
setSelected((s) => Math.max(0, s - 1))
return
case ' ':
event.preventDefault()
setFocus(2)
toggleMark(selected)
return
case 'Enter': {
event.preventDefault()
const marked = list.flatMap((row, i) =>
row.marked === true && row.queued !== true ? [i] : [],
)
queueIndexes(marked.length > 0 ? marked : [selected])
setFocus(3)
return
}
case 'a':
event.preventDefault()
markAll(true)
return
case 'A':
event.preventDefault()
markAll(false)
return
case '1':
case '2':
case '3':
event.preventDefault()
setFocus(Number(event.key))
return
default:
return
}
}
/*
* The scripted opening run.
*
* No "has this already started" ref: the cleanup cancels every timer, so a
* StrictMode remount simply replays from the top. A ref guard would let the
* first cleanup cancel the run and the re-run skip scheduling it, leaving
* the demo frozen on its first frame in development.
*
* It targets 's1' directly rather than whatever is selected, because the
* script only ever describes the opening state.
*/
useEffect(() => {
if (prefersReducedMotion()) return
const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const mark = (i: number) => {
setResults((prev) => ({
...prev,
s1: (prev['s1'] ?? []).map((row, ix) => (ix === i ? { ...row, marked: true } : row)),
}))
}
at(() => {
setPhase('results')
}, 1050)
at(() => {
setSelected(3)
}, 1550)
at(() => {
setSelected(7)
}, 2050)
at(() => {
setSelected(10)
}, 2550)
at(() => {
mark(10)
}, 2850)
at(() => {
queueFrom('s1', [10])
}, 3050)
at(() => {
setSelected(3)
}, 3950)
at(() => {
mark(3)
}, 4150)
at(() => {
queueFrom('s1', [3])
}, 4350)
at(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, 5050)
return () => {
for (const timer of timers) clearTimeout(timer)
}
}, [queueFrom])
const submitSearch = (query: string) => {
const id = `s${String(searches.length + 1)}`
setSearches((prev) => [
...prev,
{ id, query, results: 40 + Math.floor(Math.random() * 900), status: 'Done' },
])
setResults((prev) => ({
...prev,
[id]: Array.from({ length: 6 }, (_, i) => ({
name: `shared\\results\\${query.replace(/[^a-z0-9]+/gi, '_')}\\0${String(i + 1)} - track.flac`,
size: `${(10 + i * 7).toFixed(1)} MB`,
user: ['loopdigger', 'tapehiss', 'nectoUA'][i % 3] ?? 'loopdigger',
bitrate: `${String(500 + i * 60)} kbps`,
speed: '2.20 MB/s',
slots: i % 2,
})),
}))
setActiveId(id)
setSelected(0)
setFocus(2)
setPhase('interactive')
}
// Below 820px, stop shrinking at half size and let the wrapper scroll.
const [minScale, setMinScale] = useState(() =>
matches('(max-width: 820px)') ? 0.5 : 0,
)
useEffect(() => {
const query = window.matchMedia('(max-width: 820px)')
const sync = (event: MediaQueryListEvent) => {
setMinScale(event.matches ? 0.5 : 0)
}
query.addEventListener('change', sync)
return () => {
query.removeEventListener('change', sync)
}
}, [])
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH, minScale)
const shownSearches = searches.map((search, i) =>
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
)
const shownRows = phase === 'searching' ? [] : rows
return (
<div
ref={wrapRef}
className="w-full overflow-x-auto overflow-y-hidden"
style={ready ? { height } : undefined}
>
<div style={ready ? { width: DESIGN_WIDTH * scale, height } : undefined}>
<div
ref={innerRef}
className="origin-top-left"
style={{
width: DESIGN_WIDTH,
transform: ready ? `scale(${String(scale)})` : undefined,
visibility: ready ? 'visible' : 'hidden',
}}
>
<MacWindow>
<TmuxBar />
{/*
role="application" is the ARIA escape hatch for a widget that
handles its own keys: it stops screen readers intercepting
j/k/arrows so the demo behaves like the real TUI. Every control
inside is still a native button or input.
*/}
<div
ref={shellRef}
tabIndex={0}
role="application"
aria-label="Interactive soulseek-rs terminal demo"
onKeyDown={onKeyDown}
className="tui-content p-2.5 outline-none"
>
<StatusLine counts={counts} pct={pct} />
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
<div className="min-h-[498px]">
<SearchesPane
searches={shownSearches}
activeId={activeId}
active={focus === 1}
onPick={(id) => {
setActiveId(id)
setSelected(0)
setFocus(1)
setPhase('interactive')
}}
onSubmit={submitSearch}
/>
</div>
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
<div className="relative min-h-[250px] overflow-hidden">
<ResultsPane
query={activeSearch?.query ?? ''}
rows={shownRows}
selected={selected}
active={focus === 2}
onSelect={(i) => {
setSelected(i)
setFocus(2)
}}
onQueue={(i) => {
queueIndexes([i])
setFocus(3)
}}
/>
{phase === 'searching' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
searching the network
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</div>
)}
</div>
<div
className="grid min-h-0 gap-2.5"
style={{ gridTemplateColumns: '1fr 340px' }}
>
<div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
</div>
</div>
</div>
</div>
<ShortcutBar share={SHARE} />
</div>
</MacWindow>
</div>
</div>
</div>
)
}
+286
View File
@@ -0,0 +1,286 @@
import { useState, type SyntheticEvent } from 'react'
import { cn } from '@/lib/utils'
import { TuiPane } from './chrome'
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
interface SearchesPaneProps {
searches: readonly SearchRow[]
activeId: string
active: boolean
onPick: (id: string) => void
onSubmit: (query: string) => void
}
export const SearchesPane = ({
searches,
activeId,
active,
onPick,
onSubmit,
}: SearchesPaneProps) => {
const [query, setQuery] = useState('')
const submit = (event: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
event.preventDefault()
const trimmed = query.trim()
if (trimmed === '') return
onSubmit(trimmed)
setQuery('')
}
return (
<TuiPane
title="Searches"
num={1}
active={active}
className="h-full"
bodyClassName="px-3 pt-4 pb-3"
>
<form
onSubmit={submit}
className="mb-3 flex items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] pb-2.5"
>
<span className="text-[13px] text-[var(--color-oxide)]">/</span>
<input
value={query}
onChange={(event) => {
setQuery(event.target.value)
}}
placeholder="search the network…"
aria-label="Search the network"
className="grow border-0 bg-transparent font-mono text-[13px] text-[var(--color-paper)] outline-none placeholder:text-[var(--color-dust)]"
/>
<span className="h-[15px] w-2 rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
</form>
<div
className="grid px-1 pb-1.5 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: '56px 1fr 58px' }}
>
<span>Status</span>
<span>Query</span>
<span className="text-right">Results</span>
</div>
{searches.map((search) => (
<button
type="button"
key={search.id}
onClick={() => {
onPick(search.id)
}}
className={cn(
'grid w-full cursor-pointer p-1 text-left text-[13px] text-[var(--color-paper)]',
search.id === activeId && 'bg-[color-mix(in_srgb,var(--color-signal)_14%,transparent)]',
)}
style={{ gridTemplateColumns: '56px 1fr 58px' }}
>
<span className="text-[var(--color-phosphor)]">
{search.id === activeId ? '→' : ' '}
{search.status}
</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{search.query}
</span>
<span className="text-right text-[var(--color-dust)]">{search.results}</span>
</button>
))}
</TuiPane>
)
}
const resultGlyph = (row: ResultRow): { glyph: string; tone: string } => {
if (row.queued === true) return { glyph: '✓', tone: 'text-[var(--color-phosphor)]' }
if (row.marked === true) return { glyph: '[✓]', tone: 'text-[var(--color-oxide)]' }
return { glyph: '[ ]', tone: 'text-[var(--color-dust)]' }
}
interface ResultsPaneProps {
query: string
rows: readonly ResultRow[]
selected: number
active: boolean
onSelect: (index: number) => void
onQueue: (index: number) => void
}
export const ResultsPane = ({
query,
rows,
selected,
active,
onSelect,
onQueue,
}: ResultsPaneProps) => (
<TuiPane
title={`Results: ${query}`}
num={2}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: RESULT_COLS }}
>
<span>✓</span>
<span>Filename</span>
<span>Size</span>
<span>User</span>
<span>Bitrate</span>
<span>Speed</span>
<span>Slots</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row, i) => {
const { glyph, tone } = resultGlyph(row)
const isSelected = selected === i
return (
<button
type="button"
key={row.name}
onClick={() => {
onSelect(i)
}}
onDoubleClick={() => {
onQueue(i)
}}
className={cn(
'grid w-full cursor-pointer gap-2 px-3.5 py-[3px] text-left text-[12.5px] text-[var(--color-paper)]',
isSelected && 'bg-[color-mix(in_srgb,var(--color-oxide)_16%,transparent)]',
)}
style={{ gridTemplateColumns: RESULT_COLS }}
>
<span className={cn('whitespace-nowrap', tone)}>{glyph}</span>
<span
className={cn(
'overflow-hidden text-ellipsis whitespace-nowrap',
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
)}
>
{row.name}
</span>
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className="text-[var(--color-dust)]">{row.bitrate}</span>
<span className="text-[var(--color-dust)]">{row.speed}</span>
<span className="text-center text-[var(--color-dust)]">{row.slots}</span>
</button>
)
})}
</div>
</TuiPane>
)
const TRANSFER_GLYPH: Record<TransferStatus, readonly [string, string]> = {
queued: ['⋯', 'text-[var(--color-tape)]'],
done: ['✓', 'text-[var(--color-phosphor)]'],
active: ['↯', 'text-[var(--color-oxide)]'],
fail: ['✗', 'text-[var(--color-alarm)]'],
}
const PROGRESS_TONE: Record<TransferStatus, string> = {
queued: 'text-[var(--color-paper)]',
active: 'text-[var(--color-paper)]',
done: 'text-[var(--color-phosphor)]',
fail: 'text-[var(--color-alarm)]',
}
interface TransfersPaneProps {
rows: readonly TransferRow[]
active: boolean
}
export const TransfersPane = ({ rows, active }: TransfersPaneProps) => (
<TuiPane
title="Downloads/Uploads"
num={3}
active={active}
className="flex h-full flex-col"
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
>
<div
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span>Status</span>
<span>Filename</span>
<span>User</span>
<span>Progress</span>
<span>Speed</span>
</div>
<div className="flex-1 overflow-y-auto">
{rows.map((row) => {
const [glyph, tone] = TRANSFER_GLYPH[row.status]
return (
<div
key={row.id}
className="grid gap-2 px-3.5 py-[3px] text-[12.5px] text-[var(--color-paper-dim)]"
style={{ gridTemplateColumns: TRANSFER_COLS }}
>
<span className={tone}>{glyph}</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{row.name}
</span>
<span className="text-[var(--color-signal)]">{row.user}</span>
<span className={PROGRESS_TONE[row.status]}>{row.progress}</span>
<span className="text-[var(--color-tape)]">{row.speed}</span>
</div>
)
})}
</div>
</TuiPane>
)
interface InfoPaneProps {
row: ResultRow | undefined
}
export const InfoPane = ({ row }: InfoPaneProps) => {
const parts = (row?.name ?? '').split('\\')
const file = parts.at(-1) ?? '—'
const path = parts.slice(0, -1).join('/')
const queued = row?.queued === true
const fields: readonly (readonly [string, string, string])[] = [
['User', row?.user ?? '—', 'text-[var(--color-signal)]'],
['Size', row?.size ?? '—', 'text-[var(--color-tape)]'],
[
'Status',
queued ? 'Queued' : 'Available',
queued ? 'text-[var(--color-tape)]' : 'text-[var(--color-phosphor)]',
],
['Bitrate', row?.bitrate ?? '-', 'text-[var(--color-paper)]'],
]
return (
<TuiPane title="Info" active className="h-full" bodyClassName="px-3.5 pt-4 pb-3.5">
<div className="mb-1 text-[13.5px] break-all text-[var(--color-oxide)]">{file}</div>
<div className="mb-4 text-xs break-all text-[var(--color-dust)]">
{path === '' ? '—' : path}
</div>
<div
className="grid gap-y-[7px] text-[12.5px]"
style={{ gridTemplateColumns: '88px 1fr' }}
>
{fields.map(([key, value, tone]) => (
<div key={key} className="contents">
<span className="text-[var(--color-dust)]">{key}</span>
<span className={cn('break-all', tone)}>{value}</span>
</div>
))}
<span className="mt-2.5 text-[var(--color-dust)]">Queue position</span>
<span className="mt-2.5 text-[var(--color-paper)]">{queued ? '3' : 'unknown'}</span>
<span className="text-[var(--color-dust)]">Free slots</span>
<span className="text-[var(--color-phosphor)]">
{row !== undefined && row.slots > 0 ? `${String(row.slots)} available` : '0'}
</span>
</div>
</TuiPane>
)
}
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
interface FitScale {
wrapRef: RefObject<HTMLDivElement | null>
innerRef: RefObject<HTMLDivElement | null>
scale: number
height: number
ready: boolean
}
/**
* Scales a fixed-width design down to whatever width it is given.
*
* The TUI is laid out on a 1360px character grid; reflowing it would stop it
* looking like a terminal. So it renders at native width and gets a CSS
* transform, and the wrapper is told the resulting height so the page below
* does not sit under it.
*
* `minScale` stops the shrink at a floor on small screens — past that the
* wrapper scrolls sideways instead, which beats unreadable 4px text.
*/
export const useFitScale = (designWidth: number, minScale: number): FitScale => {
const wrapRef = useRef<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(null)
const [{ scale, height }, setState] = useState({ scale: 0, height: 0 })
useEffect(() => {
// ResizeObserver is browser-only; prerendering never reaches this effect.
if (typeof ResizeObserver === 'undefined') return
const measure = () => {
const wrap = wrapRef.current
const inner = innerRef.current
if (wrap === null || inner === null) return
const width = wrap.clientWidth
if (width === 0) return
const next = Math.max(minScale, Math.min(1, width / designWidth))
const nextHeight = inner.offsetHeight * next
if (nextHeight === 0) return
setState((prev) =>
Math.abs(prev.scale - next) < 0.001 && Math.abs(prev.height - nextHeight) < 0.5
? prev
: { scale: next, height: nextHeight },
)
}
measure()
// Both boxes matter: the wrapper for available width, the inner for the
// height that width produces once the webfont has swapped in.
const observer = new ResizeObserver(measure)
if (wrapRef.current !== null) observer.observe(wrapRef.current)
if (innerRef.current !== null) observer.observe(innerRef.current)
return () => {
observer.disconnect()
}
}, [designWidth, minScale])
return { wrapRef, innerRef, scale, height, ready: scale > 0 }
}
+57
View File
@@ -0,0 +1,57 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Link } from 'react-router'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center gap-2 rounded-md border font-mono font-medium transition-[background,border-color] duration-[120ms] disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'border-line bg-transparent text-primary hover:border-line-strong hover:bg-[var(--bg-selection)]',
accent:
'border-accent bg-accent text-white hover:border-accent-hover hover:bg-accent-hover',
},
size: {
default: 'h-11 px-5 text-sm max-sm:h-[42px] max-sm:px-4 max-sm:text-[13px]',
sm: 'h-8 px-2.5 text-xs',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
type ButtonVariants = VariantProps<typeof buttonVariants>
interface ButtonProps extends ButtonVariants {
/** Internal route path, or an absolute URL for an off-site link. */
href: string
children: React.ReactNode
className?: string
}
const isExternal = (href: string): boolean => /^https?:\/\//.test(href)
/**
* Every call to action on the site is a link, so this renders an anchor
* rather than a `<button>`. External links open in a new tab and carry
* `rel="noopener"`; internal ones route through React Router so navigation
* stays client-side.
*/
export const Button = ({ href, variant, size, children, className }: ButtonProps) => {
const classes = cn(buttonVariants({ variant, size }), className)
if (isExternal(href))
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={classes}>
{children}
</a>
)
return (
<Link to={href} className={classes}>
{children}
</Link>
)
}
@@ -0,0 +1,43 @@
import { useCopy } from '@/hooks/use-copy'
import { cn } from '@/lib/utils'
interface CommandPillProps {
text: string
className?: string
}
/** The one-line install command, click-to-copy. */
export const CommandPill = ({ text, className }: CommandPillProps) => {
const { copied, copy } = useCopy()
return (
<button
type="button"
title="Copy command"
onClick={() => {
copy(text)
}}
className={cn(
'inline-flex h-11 items-center gap-2.5 rounded-md border border-line bg-panel py-0 pr-2.5 pl-3.5 font-mono text-[13.5px] tabular-nums text-primary hover:border-line-strong hover:bg-raised max-sm:w-full max-sm:justify-between',
className,
)}
>
<span aria-hidden="true" className="text-accent">
$
</span>
<code className="font-mono">{text}</code>
<span
aria-hidden="true"
className={cn(
'border-l border-hairline pl-2.5 text-[11px]',
copied ? 'text-success' : 'text-secondary',
)}
>
{copied ? '✓ copied' : '⧉ copy'}
</span>
<span className="sr-only" aria-live="polite">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useCopy } from '@/hooks/use-copy'
import { cn } from '@/lib/utils'
interface CopyButtonProps {
text: string
className?: string
}
/** The copy control in a terminal header. Styled against the terminal
* surface tokens, not the page ones, so it reads as part of the window. */
export const CopyButton = ({ text, className }: CopyButtonProps) => {
const { copied, copy } = useCopy()
return (
<button
type="button"
onClick={() => {
copy(text)
}}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] font-mono text-[11px] transition-colors',
copied
? 'border-[var(--tm-ok)] text-[var(--tm-ok)]'
: 'border-[var(--tm-bd)] text-[var(--tm-dim)] hover:border-[var(--tm-bd3)] hover:text-[var(--tm-fg)]',
className,
)}
>
<span aria-hidden="true">{copied ? '✓' : '⧉'}</span>
{copied ? 'copied' : 'copy'}
</button>
)
}
@@ -0,0 +1,81 @@
import { useCopy } from '@/hooks/use-copy'
import { cn } from '@/lib/utils'
interface FeatureCommandProps {
text: string
}
const FeatureCommand = ({ text }: FeatureCommandProps) => {
const { copied, copy } = useCopy()
return (
<button
type="button"
title="Copy command"
onClick={() => {
copy(text)
}}
className={cn(
'mt-auto flex w-full items-center gap-2 rounded-md border bg-[var(--cmd-bg)] px-2.5 py-2 text-left font-mono text-[11.5px] leading-[1.5] text-[var(--tm-fg)]',
copied
? 'border-[var(--tm-ok)]'
: 'border-[var(--tm-bd)] hover:border-[var(--tm-bd3)]',
)}
>
<span aria-hidden="true" className="shrink-0 text-[var(--tm-pr)]">
$
</span>
<code className="grow font-mono break-words whitespace-pre-wrap">{text}</code>
<span
aria-hidden="true"
className={cn('shrink-0', copied ? 'text-[var(--tm-ok)]' : 'text-[var(--tm-dim)]')}
>
{copied ? '✓' : '⧉'}
</span>
</button>
)
}
interface FeatureCardProps {
/** A single glyph. Decorative — the title carries the meaning. */
glyph?: string
/** A CSS colour for the glyph, from the semantic token set. */
color?: string
title: string
children: React.ReactNode
cmd?: string
className?: string
}
export const FeatureCard = ({
glyph,
color,
title,
children,
cmd,
className,
}: FeatureCardProps) => (
<div
className={cn(
'flex flex-col gap-2.5 rounded-md border border-hairline bg-panel p-[18px] transition-[border-color,background] duration-[120ms] hover:border-line hover:bg-raised sm:p-[22px]',
className,
)}
>
<div className="flex items-center gap-3">
{glyph !== undefined && (
<div
aria-hidden="true"
className="flex size-[34px] shrink-0 items-center justify-center rounded-md border border-hairline text-xl leading-none"
style={color !== undefined ? { color } : undefined}
>
{glyph}
</div>
)}
<div className="text-[15px] font-medium text-primary">{title}</div>
</div>
<div className="text-[13px] leading-[21px] text-pretty text-secondary [&_code]:font-mono [&_code]:text-primary">
{children}
</div>
{cmd !== undefined && <FeatureCommand text={cmd} />}
</div>
)
+18
View File
@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils'
interface CodeProps {
children: React.ReactNode
className?: string
}
/** Anything typeable: flags, commands, env vars, crate names, paths. */
export const Code = ({ children, className }: CodeProps) => (
<code
className={cn(
'whitespace-nowrap rounded-md border border-hairline bg-raised px-1.5 py-px font-mono text-[0.92em] text-primary',
className,
)}
>
{children}
</code>
)
+136
View File
@@ -0,0 +1,136 @@
import { cn } from '@/lib/utils'
interface WrapProps {
children: React.ReactNode
className?: string
}
/** The site's single content column. One max-width, one gutter, everywhere. */
export const Wrap = ({ children, className }: WrapProps) => (
<div className={cn('mx-auto w-full max-w-[1140px] px-[18px] sm:px-7', className)}>
{children}
</div>
)
interface SectionProps {
children: React.ReactNode
/** Drops the top hairline — for the first section under the nav. */
flush?: boolean
/** Panel background plus rules top and bottom. */
band?: boolean
id?: string
className?: string
}
export const Section = ({ children, flush, band, id, className }: SectionProps) => (
<section
id={id}
className={cn(
'py-6 pb-10 sm:pt-[30px] sm:pb-13',
band
? 'border-y border-hairline bg-panel'
: !flush && 'border-t border-hairline',
className,
)}
>
<Wrap>{children}</Wrap>
</section>
)
interface EyebrowProps {
children: React.ReactNode
className?: string
}
export const Eyebrow = ({ children, className }: EyebrowProps) => (
<span
className={cn(
'text-label uppercase tracking-[var(--tracking-label)] text-accent',
className,
)}
>
{children}
</span>
)
interface SectionHeadProps {
eyebrow?: string
title: React.ReactNode
children?: React.ReactNode
className?: string
}
export const SectionHead = ({
eyebrow,
title,
children,
className,
}: SectionHeadProps) => (
<div className={cn('mb-11 flex max-w-[660px] flex-col gap-3.5', className)}>
{eyebrow !== undefined && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
{title}
</h2>
{children !== undefined && (
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
)}
</div>
)
interface PageHeadProps {
eyebrow: string
title: string
children: React.ReactNode
}
/** The h1 block every subpage opens with. */
export const PageHead = ({ eyebrow, title, children }: PageHeadProps) => (
<Section flush className="pt-10">
<div className="flex max-w-[660px] flex-col gap-3.5">
<Eyebrow>{eyebrow}</Eyebrow>
<h1 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
{title}
</h1>
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
</div>
</Section>
)
interface ColsProps {
children: React.ReactNode
/** Aligns columns to their tops instead of stretching. */
start?: boolean
center?: boolean
className?: string
}
/** The site's only multi-column layout: two up on desktop, stacked below. */
export const Cols = ({ children, start, center, className }: ColsProps) => (
<div
className={cn(
'grid grid-cols-1 gap-7 md:grid-cols-2 md:gap-10',
start === true && 'items-start',
center === true && 'items-center',
className,
)}
>
{children}
</div>
)
interface ProseProps {
children: React.ReactNode
className?: string
}
/** Body copy: secondary colour, generous leading, `+p` spacing. */
export const Prose = ({ children, className }: ProseProps) => (
<div
className={cn(
'[&>p+p]:mt-4 [&>p]:leading-[26px] [&>p]:text-pretty [&>p]:text-secondary',
className,
)}
>
{children}
</div>
)
+22
View File
@@ -0,0 +1,22 @@
export type OsName = 'macOS' | 'Linux' | 'Windows'
const PATHS: Record<Exclude<OsName, 'Windows'>, string> = {
macOS:
'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
Linux:
'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139z',
}
const WINDOWS_PATH =
'M2 3.6l8.5-1.2v9.1H2V3.6zm10 -1.35L22.5 1v10.5H12V2.25zM2 12.5h8.5v9.1L2 20.4v-7.9zm10 0h10.5V23L12 21.75V12.5z'
/** Decorative — the adjacent label already names the platform. */
export const OsIcon = ({ name }: { name: OsName }) => (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="block size-[22px] shrink-0 text-primary"
>
<path fill="currentColor" d={name === 'Windows' ? WINDOWS_PATH : PATHS[name]} />
</svg>
)
+62
View File
@@ -0,0 +1,62 @@
import { cn } from '@/lib/utils'
interface PanelProps {
/** Pane number, rendered in signal blue like the TUI's `[2]` tags. */
num?: number | undefined
title: string
children: React.ReactNode
className?: string
}
/**
* The fieldset/legend motif lifted from the TUI: a bordered box whose title
* breaks the top border. `bg-base` on the legend is what punches the gap, so
* it has to match whatever surface the panel sits on.
*/
export const Panel = ({ num, title, children, className }: PanelProps) => (
<div
className={cn(
'relative rounded-md border border-hairline bg-panel px-[18px] py-[22px] sm:px-6 sm:py-[26px]',
className,
)}
>
<span className="absolute -top-2.5 left-4 bg-base px-2 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
{/* --color-info, not the raw --color-signal: this panel renders on the
page, outside .tui-content, where the raw palette never re-cuts. */}
{num !== undefined && <span className="text-info">[{num}] </span>}
{title}
</span>
{children}
</div>
)
interface CalloutProps {
title?: string
tone?: 'accent' | 'warn'
children: React.ReactNode
className?: string
}
/** An aside with a coloured left rule. `warn` carries the tape amber. */
export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => (
<div
className={cn(
'rounded-md border border-hairline border-l-[3px] bg-panel px-[18px] py-5 sm:px-[22px]',
tone === 'warn' ? 'border-l-warning' : 'border-l-accent',
'[&>p]:leading-[25px] [&>p]:text-secondary',
className,
)}
>
{title !== undefined && (
<div
className={cn(
'mb-2 text-label uppercase tracking-[var(--tracking-label)]',
tone === 'warn' ? 'text-warning' : 'text-accent',
)}
>
{title}
</div>
)}
{children}
</div>
)
+71
View File
@@ -0,0 +1,71 @@
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/utils'
/** `cmd` gets a prompt glyph; `cm` is a comment; `code` and `out` are plain. */
export type TermLineKind = 'cmd' | 'cm' | 'code' | 'out'
export interface TermLine {
t: TermLineKind
text: string
}
const LINE_TONE: Record<TermLineKind, string> = {
cmd: '',
cm: 'text-[var(--tm-cm)]',
code: '',
out: '',
}
interface TerminalProps {
label?: string
lines: readonly TermLine[]
/** Overrides what the copy button yields. Defaults to the commands only. */
copy?: string
className?: string
}
/**
* A terminal window. Paints from the `--tm-*` token set rather than the page
* tokens, so it stays a dark terminal in light mode instead of dissolving
* into the page.
*/
export const Terminal = ({ label, lines, copy, className }: TerminalProps) => {
const copyText =
copy ??
lines
.filter((line) => line.t === 'cmd')
.map((line) => line.text)
.join('\n')
return (
<div
className={cn(
'overflow-hidden rounded-md border border-[var(--tm-bd)] bg-[var(--tm-bg)] font-mono tabular-nums',
className,
)}
>
<div className="flex items-center justify-between border-b border-[var(--tm-bd2)] bg-[var(--tm-hd)] px-3 py-2">
<span className="text-label uppercase tracking-[var(--tracking-label)] text-[var(--tm-dim)]">
{label ?? 'terminal'}
</span>
<CopyButton text={copyText} />
</div>
<div className="overflow-x-auto px-4 py-4 text-[12.5px] leading-[1.75] text-[var(--tm-fg)] sm:text-[13.5px]">
{lines.map((line, i) => (
<span
// Lines are static content, never reordered — index is stable here.
key={i}
className={cn('block whitespace-pre', LINE_TONE[line.t])}
>
{line.t === 'cmd' && (
<>
<span className="text-[var(--tm-pr)]">$</span>{' '}
</>
)}
{line.text}
</span>
))}
</div>
</div>
)
}