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:
@@ -0,0 +1,25 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export const THEMES = ['light', 'dark', 'system'] as const
|
||||
export type Theme = (typeof THEMES)[number]
|
||||
export type ResolvedTheme = Exclude<Theme, 'system'>
|
||||
|
||||
export const isTheme = (value: unknown): value is Theme =>
|
||||
typeof value === 'string' && (THEMES as readonly string[]).includes(value)
|
||||
|
||||
export interface ThemeContextValue {
|
||||
/** What the user picked — may be 'system'. */
|
||||
theme: Theme
|
||||
/** What is actually painted right now — never 'system'. */
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
/*
|
||||
* Split from the provider so this module exports only values, which keeps the
|
||||
* provider file component-only for react-refresh.
|
||||
*/
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
/** Matches the key the design's own inline boot script reads. */
|
||||
export const THEME_STORAGE_KEY = 'ssrs-theme'
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
|
||||
import { ThemeContext, type Theme, type ThemeContextValue } from './theme-context'
|
||||
import {
|
||||
readResolvedTheme,
|
||||
readTheme,
|
||||
serverResolvedTheme,
|
||||
serverTheme,
|
||||
subscribe,
|
||||
writeTheme,
|
||||
} from './theme-store'
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const theme = useSyncExternalStore(subscribe, readTheme, serverTheme)
|
||||
const resolvedTheme = useSyncExternalStore(
|
||||
subscribe,
|
||||
readResolvedTheme,
|
||||
serverResolvedTheme,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset['theme'] = resolvedTheme
|
||||
// Paints native widgets (scrollbars, form controls) to match, which the
|
||||
// attribute alone does not do.
|
||||
root.style.colorScheme = resolvedTheme
|
||||
}, [resolvedTheme])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
writeTheme(next)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedTheme, setTheme }),
|
||||
[theme, resolvedTheme, setTheme],
|
||||
)
|
||||
|
||||
return <ThemeContext value={value}>{children}</ThemeContext>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { isTheme, THEME_STORAGE_KEY, type ResolvedTheme, type Theme } from './theme-context'
|
||||
|
||||
const DARK_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
/*
|
||||
* The theme lives outside React: the boot script in index.html resolves and
|
||||
* paints it before the bundle loads, and the OS can change it at any time.
|
||||
* That makes it an external store, which is what useSyncExternalStore is for
|
||||
* — and it is the piece that gives correct hydration for free.
|
||||
*
|
||||
* The server snapshots below must match what the prerendered HTML ships
|
||||
* (`<html data-theme="dark">`). React renders the server snapshot for the
|
||||
* hydration pass, then immediately re-renders with the real one, so a visitor
|
||||
* whose actual theme is light never sees a mismatch, only a corrected render.
|
||||
*/
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
/** Notifies subscribers after a local write; OS changes come via matchMedia. */
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export const subscribe = (onChange: () => void): (() => void) => {
|
||||
listeners.add(onChange)
|
||||
const query = window.matchMedia(DARK_QUERY)
|
||||
query.addEventListener('change', onChange)
|
||||
// Another tab changing the preference should move this one too.
|
||||
window.addEventListener('storage', onChange)
|
||||
return () => {
|
||||
listeners.delete(onChange)
|
||||
query.removeEventListener('change', onChange)
|
||||
window.removeEventListener('storage', onChange)
|
||||
}
|
||||
}
|
||||
|
||||
export const readTheme = (): Theme => {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return isTheme(stored) ? stored : 'system'
|
||||
} catch {
|
||||
// Safari in private mode throws on localStorage access.
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
export const readResolvedTheme = (): ResolvedTheme => {
|
||||
const theme = readTheme()
|
||||
if (theme !== 'system') return theme
|
||||
return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/** Both must agree with the `data-theme` baked into index.html. */
|
||||
export const serverTheme = (): Theme => 'system'
|
||||
export const serverResolvedTheme = (): ResolvedTheme => 'dark'
|
||||
|
||||
export const writeTheme = (next: Theme): void => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next)
|
||||
} catch {
|
||||
// Not persisting is survivable; the theme still applies this session.
|
||||
}
|
||||
emit()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { ThemeContext, type ThemeContextValue } from './theme-context'
|
||||
|
||||
export const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === null)
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user