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
+7
View File
@@ -2,9 +2,16 @@ name: CI
# Branch pushes are covered by the pull_request trigger. Listening to both for
# every branch ran the whole matrix twice per PR.
#
# website/ holds the GitHub Pages site and no Rust. Ignoring it here must stay
# in lockstep with the same list in release.yml: the two skip together, so the
# release ci-gate never waits for a CI run that was never queued. Deliberately
# not applied to pull_request — CI is a required check there, and a skipped
# required check leaves the PR permanently unmergeable.
on:
push:
branches: [master]
paths-ignore: ['website/**']
pull_request:
branches: ['**']
+73
View File
@@ -0,0 +1,73 @@
name: Pages
# Only the website. The Rust workflows ignore this same path, so a site-only
# push runs this alone and a code-only push never rebuilds the site.
on:
push:
branches: [master]
paths: ['website/**', '.github/workflows/pages.yml']
pull_request:
branches: ['**']
paths: ['website/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# --frozen-lockfile so a drifted bun.lock fails here rather than
# silently shipping different dependency versions than were tested.
- name: Install
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
# Emits one static HTML file per route plus a 404, so direct links
# answer 200 with real markup instead of the SPA shell.
- name: Build
run: bun run build
- uses: actions/upload-pages-artifact@v3
with:
path: website/dist
deploy:
name: Deploy
# Pull requests get the build as a check; only master publishes.
if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
# Scoped to the deploy, not the workflow: a workflow-level group would
# queue PR builds behind an in-flight master deploy for no reason. Never
# cancel in flight — a half-uploaded artifact leaves the live site broken.
concurrency:
group: pages
cancel-in-progress: false
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+8 -1
View File
@@ -2,7 +2,10 @@ name: Release
on:
push:
# Must match ci.yml's paths-ignore exactly. A website-only push skips both
# workflows, so ci-gate is never left polling for an absent CI run.
branches: [master]
paths-ignore: ['website/**']
workflow_dispatch:
inputs:
tag:
@@ -48,7 +51,11 @@ jobs:
2>/dev/null || echo "unreachable")
case "$verdict" in
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
absent|running|unreachable) ;;
# `cancelled` is what CI's own cancel-in-progress does to the
# older SHA when two pushes land back to back. Treating it as a
# failure turned every rapid push into a red release; keep
# waiting instead, and let the newer SHA's run decide.
absent|running|unreachable|cancelled) ;;
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
esac
sleep 20