refactor(website): split the hero terminal and the page components

HeroTerminal was a single 367-line component doing three separate jobs:
simulating the transfer queue, holding the per-search result sets, and
rendering. It is now useTransfers, useResults and useHeroDemo behind a
presentational component. The dead shellRef went with it.

The home, docs and install pages each returned one long JSX block; every
section is now a named component, so the page body reads as an outline.

The prerendered HTML is byte-identical for all five pages, and the extracted
transfer reducer matches the original over 200 ticks.
This commit is contained in:
Michel de Graaf
2026-07-26 15:40:58 +02:00
parent 093f32b360
commit 2e9cd6b860
7 changed files with 1128 additions and 1028 deletions
+20 -344
View File
@@ -1,328 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { MacWindow, ShortcutBar, StatusLine, type StatusCounts } from './chrome'
import {
RESULTS,
SEARCHES,
SHARE,
TRANSFERS,
type ResultRow,
type SearchRow,
type TransferRow,
} from './data'
import { MacWindow, ShortcutBar, StatusLine } from './chrome'
import { SHARE } from './data'
import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
import { useFitScale } from './use-fit-scale'
import { useHeroDemo } from './use-hero-demo'
const DESIGN_WIDTH = 1360
const DESIGN_HEIGHT = 863
const TICK_MS = 480
const MAX_CONCURRENT = 2
const RESTING_ROW = 5
type Phase = 'searching' | 'results' | 'interactive'
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
}
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,
})
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)
const [phase, setPhase] = useState<Phase>('searching')
const [selected, setSelected] = useState(0)
const shellRef = useRef<HTMLDivElement>(null)
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])
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])
const hasWork = useMemo(
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
[transfers],
)
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
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))}%)`,
}
})
const moved = next.some((t, i) => t !== list[i])
return moved ? next : list
})
}, TICK_MS)
return () => {
clearInterval(timer)
}
}, [hasWork])
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
}
}
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const cancel = () => {
for (const timer of timers) clearTimeout(timer)
}
if (prefersReducedMotion()) {
at(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, 0)
return cancel
}
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 cancel
}, [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')
}
const demo = useHeroDemo()
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH)
const shownSearches = searches.map((search, i) =>
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
)
const shownRows = phase === 'searching' ? [] : rows
return (
<div
ref={wrapRef}
@@ -341,48 +29,36 @@ export const HeroTerminal = () => {
>
<MacWindow>
<div
ref={shellRef}
tabIndex={0}
role="application"
aria-label="Interactive soulseek-rs terminal demo"
onKeyDown={onKeyDown}
onKeyDown={demo.onKeyDown}
className="tui-content p-2.5 outline-none"
>
<StatusLine counts={counts} pct={pct} />
<StatusLine counts={demo.counts} pct={demo.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}
searches={demo.searches}
activeId={demo.activeId}
active={demo.focus === 1}
onPick={demo.pickSearch}
onSubmit={demo.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)
}}
query={demo.query}
rows={demo.rows}
selected={demo.selected}
active={demo.focus === 2}
onSelect={demo.selectRow}
onQueue={demo.queueRow}
/>
{phase === 'searching' && (
{demo.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" />
@@ -395,10 +71,10 @@ export const HeroTerminal = () => {
style={{ gridTemplateColumns: '1fr 340px' }}
>
<div className="min-h-0 min-w-0 overflow-hidden">
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} />
<TransfersPane rows={demo.transfers.slice(0, 7)} active={demo.focus === 3} />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
<InfoPane row={demo.infoRow} />
</div>
</div>
</div>
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react'
import { SEARCHES, type ResultRow, type SearchRow } from './data'
import { useResults } from './use-results'
import { useTransfers } from './use-transfers'
const RESTING_ROW = 5
type Phase = 'searching' | 'results' | 'interactive'
const isBrowser = typeof window !== 'undefined'
const prefersReducedMotion = (): boolean =>
isBrowser && window.matchMedia('(prefers-reduced-motion: reduce)').matches
const fakeResults = (query: string): readonly ResultRow[] =>
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,
}))
interface Keys {
rows: readonly ResultRow[]
selected: number
moveRow: (delta: number) => void
focusPane: (pane: number) => void
toggleMark: (index: number) => void
markAll: (on: boolean) => void
queueIndexes: (indexes: readonly number[]) => void
}
const handleKey = (k: Keys, event: KeyboardEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLInputElement) return
switch (event.key) {
case 'ArrowDown':
case 'j':
event.preventDefault()
k.moveRow(1)
return
case 'ArrowUp':
case 'k':
event.preventDefault()
k.moveRow(-1)
return
case ' ':
event.preventDefault()
k.focusPane(2)
k.toggleMark(k.selected)
return
case 'Enter': {
event.preventDefault()
const marked = k.rows.flatMap((row, i) =>
row.marked === true && row.queued !== true ? [i] : [],
)
k.queueIndexes(marked.length > 0 ? marked : [k.selected])
k.focusPane(3)
return
}
case 'a':
event.preventDefault()
k.markAll(true)
return
case 'A':
event.preventDefault()
k.markAll(false)
return
case '1':
case '2':
case '3':
event.preventDefault()
k.focusPane(Number(event.key))
return
default:
return
}
}
/** Plays the scripted intro once: the search resolves, rows walk, two downloads queue. */
const useIntroTimeline = (
animate: boolean,
rest: () => void,
setPhase: (phase: Phase) => void,
setSelected: (row: number) => void,
mark: (row: number) => void,
queueFrom: (searchId: string, indexes: readonly number[]) => void,
) => {
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
const cancel = () => {
for (const timer of timers) clearTimeout(timer)
}
if (!animate) {
at(rest, 0)
return cancel
}
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(rest, 5050)
return cancel
}, [animate, rest, setPhase, setSelected, mark, queueFrom])
}
export const useHeroDemo = () => {
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES)
const [focus, setFocus] = useState(2)
const [phase, setPhase] = useState<Phase>('searching')
const animate = !prefersReducedMotion()
const { transfers, counts, pct, queue } = useTransfers(animate)
const {
rows,
activeId,
selected,
setSelected,
open,
mark,
queueFrom,
queueIndexes,
toggleMark,
markAll,
} = useResults(queue)
const rest = useCallback(() => {
setSelected(RESTING_ROW)
setPhase('interactive')
}, [setSelected])
useIntroTimeline(animate, rest, setPhase, setSelected, mark, queueFrom)
const searching = phase === 'searching'
const moveRow = (delta: number) => {
setFocus(2)
setSelected((s) => Math.min(rows.length - 1, Math.max(0, s + delta)))
}
const submitSearch = (query: string) => {
const id = `s${String(searches.length + 1)}`
const results = 40 + Math.floor(Math.random() * 900)
setSearches((prev) => [...prev, { id, query, results, status: 'Done' }])
open(id, fakeResults(query))
setFocus(2)
setPhase('interactive')
}
return {
searches: searches.map((search, i) =>
i === 0 && searching ? { ...search, status: '⋯', results: '—' } : search,
),
rows: searching ? [] : rows,
infoRow: searching ? undefined : rows[selected],
query: searches.find((search) => search.id === activeId)?.query ?? '',
activeId,
transfers,
counts,
pct,
focus,
selected,
searching,
submitSearch,
pickSearch: (id: string) => {
open(id)
setFocus(1)
setPhase('interactive')
},
selectRow: (i: number) => {
setSelected(i)
setFocus(2)
},
queueRow: (i: number) => {
queueIndexes([i])
setFocus(3)
},
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
handleKey(
{ rows, selected, moveRow, focusPane: setFocus, queueIndexes, toggleMark, markAll },
event,
)
},
}
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RESULTS, type ResultRow } from './data'
type Results = Readonly<Record<string, readonly ResultRow[]>>
/** Result sets per search, which row is selected, and what is marked or already queued. */
export const useResults = (queue: (rows: readonly ResultRow[]) => void) => {
const [results, setResults] = useState<Results>(RESULTS)
const [activeId, setActiveId] = useState('s1')
const [selected, setSelected] = useState(0)
const resultsRef = useRef(results)
useEffect(() => {
resultsRef.current = results
}, [results])
const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
const patch = useCallback(
(searchId: string, edit: (row: ResultRow, index: number) => ResultRow) => {
setResults((prev) => ({ ...prev, [searchId]: (prev[searchId] ?? []).map(edit) }))
},
[],
)
const queueFrom = useCallback(
(searchId: string, indexes: readonly number[]) => {
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))
patch(searchId, (row) =>
wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
)
queue(picked)
},
[patch, queue],
)
const mark = useCallback(
(row: number) => {
patch('s1', (r, i) => (i === row ? { ...r, marked: true } : r))
},
[patch],
)
const open = useCallback((id: string, next?: readonly ResultRow[]) => {
if (next) setResults((prev) => ({ ...prev, [id]: next }))
setActiveId(id)
setSelected(0)
}, [])
return {
rows,
activeId,
selected,
setSelected,
open,
mark,
queueFrom,
queueIndexes: (indexes: readonly number[]) => {
queueFrom(activeId, indexes)
},
toggleMark: (index: number) => {
patch(activeId, (row, i) =>
i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
)
},
markAll: (on: boolean) => {
patch(activeId, (row) => (row.queued === true ? row : { ...row, marked: on }))
},
}
}
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { StatusCounts } from './chrome'
import { TRANSFERS, type ResultRow, type TransferRow } from './data'
const TICK_MS = 480
const MAX_CONCURRENT = 2
const sizeMb = (size: string): number => {
const parsed = Number.parseFloat(size)
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
}
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,
})
const begin = (t: TransferRow, total: number, rate: number): TransferRow => ({
...t,
status: 'active',
speed: `${rate.toFixed(2)} MB/s`,
progress: `0.0/${total.toFixed(1)} MB (0%)`,
})
const advance = (t: TransferRow, total: number, rate: number): TransferRow => {
const from = t.pct ?? 0
const pct = Math.min(100, from + 4 + ((t.name.length + Math.round(from)) % 9))
const finished = pct >= 100
return {
...t,
status: finished ? 'done' : 'active',
pct,
speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
progress: finished
? 'Complete'
: `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
}
}
const tick = (list: readonly TransferRow[]): readonly TransferRow[] => {
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 begin(t, total, rate)
}
return t.status === 'active' ? advance(t, total, rate) : t
})
return next.some((t, i) => t !== list[i]) ? next : list
}
export const useTransfers = (animate: boolean) => {
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
const hasWork = useMemo(
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
[transfers],
)
useEffect(() => {
if (!hasWork || !animate) return
const timer = setInterval(() => {
setTransfers(tick)
}, TICK_MS)
return () => {
clearInterval(timer)
}
}, [hasWork, animate])
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])
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])
const queue = useCallback((rows: readonly ResultRow[]) => {
setTransfers((list) => {
const seen = new Set(list.map((transfer) => transfer.id))
const additions = rows.map(toTransfer).filter((t) => !seen.has(t.id))
return additions.length === 0 ? list : [...additions, ...list]
})
}, [])
return { transfers, counts, pct, queue }
}
+50 -43
View File
@@ -71,17 +71,7 @@ const KeyRow = ({ combo, description }: { combo: string; description: string })
</div>
)
export const Docs = () => {
return (
<>
<PageHead
eyebrow="docs · quick start"
title="Install it, run it, drive it from the keyboard."
>
This is the short version. The full reference (every flag, every message) lives in
the README and on docs.rs.
</PageHead>
const FirstRun = () => (
<Section>
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
Homebrew puts the binary on your PATH. With a Rust toolchain,{' '}
@@ -97,9 +87,8 @@ export const Docs = () => {
/>
<Prose>
<p>
On first run you get a login screen: sign in with an existing Soulseek
account or register a new one. Your password goes to the OS keychain, not a
plain-text file.
On first run you get a login screen: sign in with an existing Soulseek account or
register a new one. Your password goes to the OS keychain, not a plain-text file.
</p>
<p>
Downloads land in a conventional folder, and TUI state (searches, downloads,
@@ -108,7 +97,9 @@ export const Docs = () => {
</Prose>
</Cols>
</Section>
)
const Layout = () => (
<Section>
<SectionHead eyebrow="2 · the layout" title="Four panes.">
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
@@ -122,7 +113,9 @@ export const Docs = () => {
))}
</div>
</Section>
)
const Keys = () => (
<Section>
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
The bottom bar always shows the keys for where you are. The essentials:
@@ -148,12 +141,11 @@ export const Docs = () => {
</div>
</Cols>
</Section>
)
const FromTheShell = () => (
<Section>
<SectionHead
eyebrow="4 · from the shell"
title="Every command also runs as a one-off."
>
<SectionHead eyebrow="4 · from the shell" title="Every command also runs as a one-off.">
Skip the TUI when you&rsquo;re scripting or mid-task. stdout is data, stderr is
progress, and the exit code is the verdict.
</SectionHead>
@@ -177,26 +169,25 @@ export const Docs = () => {
/>
<Prose>
<p>
The command groups: <Code>search</Code>, <Code>get</Code>,{' '}
<Code>download</Code>, <Code>browse</Code>, <Code>room</Code>,{' '}
<Code>message</Code>, <Code>serve</Code>, <Code>user</Code>,{' '}
<Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records
are tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
The command groups: <Code>search</Code>, <Code>get</Code>, <Code>download</Code>,{' '}
<Code>browse</Code>, <Code>room</Code>, <Code>message</Code>, <Code>serve</Code>,{' '}
<Code>user</Code>, <Code>whoami</Code>, <Code>shares</Code>, <Code>config</Code>,{' '}
<Code>portmap</Code>, <Code>skills</Code>, <Code>completions</Code>. Records are
tab-separated fields, one per line, or NDJSON with <Code>--json</Code>.
</p>
<p>
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code>{' '}
into <Code>download --stdin</Code>. Every ending has its own exit code: 4 found
No TTY, no daemon. <Code>search --json</Code> pipes through <Code>jq</Code> into{' '}
<Code>download --stdin</Code>. Every ending has its own exit code: 4 found
nothing, 3 could not reach the server, 6 the transfer died.
</p>
<p>
<Code>completions install</Code> gives bash, zsh and fish tab completion for
all of it, generated from the same definition the binary parses with.{' '}
<Code>completions install</Code> gives bash, zsh and fish tab completion for all
of it, generated from the same definition the binary parses with.{' '}
<Code>completions uninstall</Code> takes it back out.
</p>
<p>
<Code>portmap</Code> tests whether your router will let peers connect back,
worth running once.{' '}
<Code>portmap</Code> tests whether your router will let peers connect back, worth
running once.{' '}
<Link to="/install" className="text-link hover:text-link-hover">
More on being reachable
</Link>
@@ -206,16 +197,15 @@ export const Docs = () => {
<div className="mt-6">
<Callout title="give it to your agent">
<p>
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into
every coding agent on the machine: Claude Code and opencode today,{' '}
<Code>--dir</Code> for anything else. After it lands you describe the
outcome instead of the pipeline: &ldquo;mirror that peer&rsquo;s FLAC
folder&rdquo;, &ldquo;watch this room for an hour and save anything
mentioning the label&rdquo;.
<Code>soulseek-rs skills install</Code> writes a <Code>SKILL.md</Code> into every
coding agent on the machine: Claude Code and opencode today, <Code>--dir</Code>{' '}
for anything else. After it lands you describe the outcome instead of the
pipeline: &ldquo;mirror that peer&rsquo;s FLAC folder&rdquo;, &ldquo;watch this
room for an hour and save anything mentioning the label&rdquo;.
</p>
<p>
It carries what <Code>--help</Code> cannot: the JSON keys of every record,
and what each exit code means. Rerun it after an upgrade;{' '}
It carries what <Code>--help</Code> cannot: the JSON keys of every record, and
what each exit code means. Rerun it after an upgrade;{' '}
<Code>skills uninstall</Code> takes it back out.
</p>
</Callout>
@@ -223,21 +213,23 @@ export const Docs = () => {
<div className="mt-4">
<Callout tone="warn" title="the one limit worth knowing up front">
<p>
If both you and a peer are behind routers with no forwarded port, browsing
that peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer
If both you and a peer are behind routers with no forwarded port, browsing that
peer can&rsquo;t work, that&rsquo;s a fundamental Soulseek/peer-to-peer
limitation, not a bug.
</p>
</Callout>
</div>
</Section>
)
const TheRest = () => (
<Section band>
<Panel title="the rest" className="bg-raised [&>span]:bg-panel">
<Cols center className="gap-6">
<Prose>
<p>
The README on GitHub and the library API reference on docs.rs stay current
with the code.
The README on GitHub and the library API reference on docs.rs stay current with
the code.
</p>
</Prose>
<div className="flex flex-wrap gap-2 sm:gap-3">
@@ -250,6 +242,21 @@ export const Docs = () => {
</Cols>
</Panel>
</Section>
)
export const Docs = () => (
<>
<PageHead
eyebrow="docs · quick start"
title="Install it, run it, drive it from the keyboard."
>
This is the short version. The full reference (every flag, every message) lives in the
README and on docs.rs.
</PageHead>
<FirstRun />
<Layout />
<Keys />
<FromTheShell />
<TheRest />
</>
)
}
+44 -36
View File
@@ -117,9 +117,7 @@ const FEATURES = [
},
] as const
export const Home = () => {
return (
<>
const Hero = () => (
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
<Wrap>
<div className="flex flex-col gap-[22px]">
@@ -127,13 +125,12 @@ export const Home = () => {
<div className="flex items-center gap-3.5 sm:gap-6 md:items-start md:gap-11">
<Logo size={172} className="max-md:!h-32 md:mt-2" />
<h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]">
A soulseek client for the terminal. Built for agents and people who live
there.
A soulseek client for the terminal. Built for agents and people who live there.
</h1>
</div>
<p className="text-lg leading-[29px] text-pretty text-secondary max-md:hidden">
Search the network, share your files, browse someone&rsquo;s collection, join
a room. It runs over ssh on the machine where your music already lives.
Search the network, share your files, browse someone&rsquo;s collection, join a
room. It runs over ssh on the machine where your music already lives.
</p>
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
<div className="flex flex-wrap items-center gap-2 max-md:w-full max-md:justify-center sm:gap-3">
@@ -152,7 +149,9 @@ export const Home = () => {
</div>
</Wrap>
</section>
)
const RunsOn = () => (
<section className="border-t border-hairline py-6">
<Wrap>
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
@@ -164,9 +163,7 @@ export const Home = () => {
className={cn(
'flex w-auto border-hairline px-[18px] first:border-l-0 first:pl-0 not-first:border-l',
'min-[520px]:w-full min-[520px]:border-l-0 min-[520px]:px-0',
i === 0
? 'sm:w-auto sm:pr-6.5'
: 'sm:w-auto sm:border-l sm:px-6.5',
i === 0 ? 'sm:w-auto sm:pr-6.5' : 'sm:w-auto sm:border-l sm:px-6.5',
)}
>
<Link
@@ -187,33 +184,32 @@ export const Home = () => {
</div>
</Wrap>
</section>
)
const About = () => (
<Section id="about">
<SectionHead
eyebrow="what it is"
title="A client for a network that runs on people."
>
<SectionHead eyebrow="what it is" title="A client for a network that runs on people.">
No charts, no recommendations. You search, you find the person who has it, you
download it from them.
</SectionHead>
<Cols>
<Prose>
<p>
Soulseek is a peer-to-peer network from the early 2000s. People still use it
to share niche music by hand: the album that exists in one person&rsquo;s
folder and nowhere else.
Soulseek is a peer-to-peer network from the early 2000s. People still use it to
share niche music by hand: the album that exists in one person&rsquo;s folder and
nowhere else.
</p>
<p>
soulseek-rs speaks that protocol. It&rsquo;s a third-party client, not the
official app. It shares, browses, and does rooms and private messages,
because a client that only downloads takes without giving anything back.
official app. It shares, browses, and does rooms and private messages, because a
client that only downloads takes without giving anything back.
</p>
</Prose>
<div className="flex flex-col gap-4">
<Callout title="build your own">
<p>
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent
on the protocol.{' '}
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent on
the protocol.{' '}
<Link to="/install" className="text-link hover:text-link-hover">
Use the library
</Link>
@@ -222,9 +218,8 @@ export const Home = () => {
<Callout title="agents and automation">
<p>
Every command runs as a one-off from the shell. Point cron, a script, or an
agent at them and read JSON records back off stdout. No daemon, no API to
stand up. <Code>skills install</Code> teaches the agent the surface for
you.{' '}
agent at them and read JSON records back off stdout. No daemon, no API to stand
up. <Code>skills install</Code> teaches the agent the surface for you.{' '}
<Link to="/docs" className="text-link hover:text-link-hover">
See the commands
</Link>
@@ -233,7 +228,9 @@ export const Home = () => {
</div>
</Cols>
</Section>
)
const Features = () => (
<Section id="features">
<SectionHead eyebrow="features" title="What it does.">
Everything happens in the TUI, and every command also runs as a one-off from the
@@ -254,11 +251,12 @@ export const Home = () => {
))}
</div>
</Section>
)
const Agents = () => (
<Section id="agents">
<SectionHead eyebrow="agent native" title="Ask for the record. Skip the pipeline.">
An agent can run any CLI. <Code>skills install</Code> teaches it to run this one
well.
An agent can run any CLI. <Code>skills install</Code> teaches it to run this one well.
</SectionHead>
<img
src={`${import.meta.env.BASE_URL}agent-demo.svg`}
@@ -267,26 +265,27 @@ export const Home = () => {
/>
<Prose className="mt-11 max-w-[660px]">
<p>
<Code>--help</Code> lists flags. It cannot say which JSON keys a record
carries, or that exit 4 means widen the query rather than retry it. An agent
without that guesses, and guessing against a stranger&rsquo;s file server
hangs the job.
<Code>--help</Code> lists flags. It cannot say which JSON keys a record carries, or
that exit 4 means widen the query rather than retry it. An agent without that
guesses, and guessing against a stranger&rsquo;s file server hangs the job.
</p>
<p>
<Code>skills install</Code> fills that in. The requests stop being pipelines
you write: mirror a peer&rsquo;s FLAC folder overnight, or check whether a
user is worth queueing behind before a 2 GB transfer.
<Code>skills install</Code> fills that in. The requests stop being pipelines you
write: mirror a peer&rsquo;s FLAC folder overnight, or check whether a user is worth
queueing behind before a 2 GB transfer.
</p>
<p>
It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary
upgrades the skill; rerun <Code>skills install</Code> to take it.{' '}
It stays a CLI: no daemon, no MCP server, no API key. Upgrading the binary upgrades
the skill; rerun <Code>skills install</Code> to take it.{' '}
<Link to="/docs" className="text-link hover:text-link-hover">
See the commands
</Link>
</p>
</Prose>
</Section>
)
const GetStarted = () => (
<Section band>
<Cols center>
<div className="flex flex-col gap-3.5">
@@ -313,6 +312,15 @@ export const Home = () => {
/>
</Cols>
</Section>
)
export const Home = () => (
<>
<Hero />
<RunsOn />
<About />
<Features />
<Agents />
<GetStarted />
</>
)
}
+21 -13
View File
@@ -250,14 +250,7 @@ const Step = ({ n, title, children }: StepProps) => (
</div>
)
export const Install = () => {
return (
<>
<PageHead eyebrow="install" title="Three ways in.">
One command with Homebrew or cargo. Build from source if you&rsquo;d rather. Or
depend on the library and write your own client.
</PageHead>
const Steps = () => (
<Section>
<div className="flex flex-col gap-9">
<Step n={1} title="Install the client">
@@ -376,7 +369,8 @@ export const Install = () => {
</Step>
</div>
</Section>
)
const Reachable = () => (
<Section>
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
Browsing and downloading are peer-to-peer, so at least one side has to accept an
@@ -418,7 +412,8 @@ export const Install = () => {
</Callout>
</Cols>
</Section>
)
const Platforms = () => (
<Section id="platforms">
<SectionHead eyebrow="platform notes" title="Where things live.">
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
@@ -481,7 +476,8 @@ export const Install = () => {
</FeatureCard>
</Cols>
</Section>
)
const Config = () => (
<Section id="config">
<SectionHead eyebrow="config.toml" title="Nine settings, all optional.">
Every one has a working default, so an empty file, or no file at all, is valid.{' '}
@@ -522,7 +518,8 @@ export const Install = () => {
</div>
</Cols>
</Section>
)
const Uninstall = () => (
<Section band>
<SectionHead eyebrow="uninstall" title="Removing it completely.">
Three things exist on disk: the binary, the config, and the state &mdash; plus
@@ -570,6 +567,17 @@ export const Install = () => {
</Callout>
</div>
</Section>
)
export const Install = () => (
<>
<PageHead eyebrow="install" title="Three ways in.">
One command with Homebrew or cargo. Build from source if you&rsquo;d rather. Or depend
on the library and write your own client.
</PageHead>
<Steps />
<Reachable />
<Platforms />
<Config />
<Uninstall />
</>
)
}