diff --git a/.env-sample b/.env-sample deleted file mode 100644 index 2004362..0000000 --- a/.env-sample +++ /dev/null @@ -1 +0,0 @@ -GITHUB_OAUTH_CLIENT_ID=your-client-id \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e734d..d6ce15a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,17 @@ on: pull_request: branches: [master] +# Every job here only reads the repo and runs the build/test tooling. +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -23,6 +29,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -35,6 +43,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -47,6 +57,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index db49f91..8dc84ed 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,9 +3,6 @@ name: Deploy on: push: branches: [master] - # Refresh the deployed GitHub stats on the 1st of each month, even without a push. - schedule: - - cron: '0 7 1 * *' workflow_dispatch: concurrency: @@ -27,13 +24,6 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - # Fetch the latest public GitHub stats at build time. The committed - # data/github-stats.json is only a fallback, so a transient API failure - # degrades to slightly stale stats instead of breaking the deploy. - - name: Fetch GitHub stats - env: - GH_STATS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm run fetch-github || echo "Stats fetch failed; building with committed data/github-stats.json" - run: pnpm run build - uses: cloudflare/wrangler-action@v3 with: diff --git a/.gitignore b/.gitignore index 3a8a09c..277fab7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ package-lock.json /.svelte-kit /build +# Local tooling scratch (playwright-cli sessions, static-analysis reports) +.playwright-cli +.static-analysis + # OS .DS_Store Thumbs.db diff --git a/.prettierignore b/.prettierignore index 5df8b82..15a4bce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,8 @@ package-lock.json pnpm-lock.yaml yarn.lock -# Generated data -data/github-stats.json +# Generated by the static-analysis skill (git-ignored globally) +.static-analysis/ + +# Machine-local Claude Code settings (git-ignored globally, never reaches CI) +.claude/settings.local.json diff --git a/WARP.md b/WARP.md index 1a4af06..9d22c0e 100644 --- a/WARP.md +++ b/WARP.md @@ -4,239 +4,128 @@ This file provides guidance to WARP (warp.dev) when working with code in this re ## Overview -Nathan Arthur's personal website built with SvelteKit as a static site generator. Features dynamic GitHub statistics integration, project showcase, and responsive design with Tailwind CSS. +Nathan Arthur's personal website: a small static SvelteKit site — a home page, +`/writing`, `/uses`, and one case study — deployed to Cloudflare Workers assets +at nathanarthur.com. See `knowledge.md` for the design and content rules; they +are load-bearing, not decoration. ## Development Commands -### Core Development - ```bash -# Install dependencies (project uses pnpm) -pnpm install - -# Start development server (avoid in WARP to prevent blocking) -pnpm dev - -# Build static site -pnpm build - -# Preview production build locally -pnpm preview - -# Type checking -pnpm check - -# Watch mode type checking +pnpm install # project uses pnpm +pnpm dev # dev server (avoid in WARP to prevent blocking) +pnpm build # static build into ./build +pnpm preview # preview the production build +pnpm check # svelte-check pnpm check:watch +pnpm test # vitest, single run +pnpm lint # prettier --check + eslint +pnpm format # prettier --write ``` -### Testing & Quality - -```bash -# Run tests (use --run to avoid watch mode in WARP) -pnpm test - -# Run Vitest directly with no-watch -pnpm vitest --run - -# Lint code (ESLint + Prettier) -pnpm lint - -# Format code -pnpm format -``` - -### GitHub Statistics Integration - -```bash -# Install dependencies for GitHub stats fetching -pnpm prefetch-github +## Architecture -# Fetch GitHub stats (requires GITHUB_OAUTH_CLIENT_ID in .env) -pnpm fetch-github -``` +### Static site generation -## High-Level Architecture +- **SvelteKit 5** with `@sveltejs/adapter-static` +- Pre-rendering enabled via `export const prerender = true` in `src/routes/+layout.ts` +- **No runtime data fetching at all.** `/uses` reads `src/routes/uses/uses.yaml` + at build time in `+page.ts` (Vite `?raw` import, parsed with js-yaml), so it + prerenders to static HTML and renders with JS disabled -### Static Site Generation +### Technology stack -- **SvelteKit** with `@sveltejs/adapter-static` for GitHub Pages deployment -- **Pre-rendering** enabled via `export const prerender = true` in root layout -- **Build-time data fetching** using static JSON files rather than runtime API calls +- SvelteKit 5, TypeScript, Tailwind CSS (no plugins), Vite, Vitest, ESLint + Prettier -### GitHub Integration Flow +### Project structure -1. **OAuth Device Flow**: `scripts/fetch-github-stats.ts` handles authentication -2. **Data Fetching**: Pulls user profile, repositories, and language statistics -3. **Static Generation**: Data stored in `data/github-stats.json` for build-time consumption -4. **Display**: `src/services/github/api.ts` processes static data for components - -### Technology Stack - -- **SvelteKit 5** for framework and routing -- **TypeScript** for type safety throughout -- **Tailwind CSS** with plugins for forms, typography, container queries -- **Vite** for build tooling and dev server -- **Vitest** for unit testing -- **ESLint + Prettier** for code quality - -## Project Structure - -### Key Directories - -``` +```text src/ -├── routes/ # SvelteKit file-based routing -│ ├── +layout.svelte # Root layout with dark mode support -│ ├── +layout.ts # Layout load function (prerender: true) -│ ├── +page.svelte # Home page with project showcase -│ └── uses/ # "/uses" page route -├── components/ # Reusable Svelte components -│ ├── GithubStats.svelte # GitHub statistics display -│ ├── ProjectList.svelte # Searchable project showcase -│ └── SubscribeForm.svelte -├── services/ # Business logic layer -│ └── github/ # GitHub API integration -│ ├── api.ts # Data processing from static JSON -│ ├── types.ts # TypeScript interfaces -│ └── colors.ts # Language color mapping -└── types/ # Global type definitions -``` - -### Data Flow - -1. **Build Time**: Static JSON (`data/github-stats.json`) consumed by services layer -2. **Component Layer**: `GithubStats.svelte` calls `fetchGithubStats()` from `services/github/api.ts` -3. **Data Processing**: API service calculates statistics and language percentages -4. **Rendering**: Components receive processed data for display - -## GitHub Statistics System - -### Authentication Setup - -1. Create GitHub OAuth app and get client ID -2. Copy `.env-sample` to `.env` and set `GITHUB_OAUTH_CLIENT_ID` -3. Run `pnpm prefetch-github` to install required dependencies - -### Data Fetching Process - -```bash -# Authenticate via OAuth device flow (opens browser) -pnpm fetch-github +├── app.html # and the Supascribe loader script +├── app.css # Tailwind entry point +└── routes/ + ├── +layout.svelte # page column, footer, global link/focus styles + ├── +layout.ts # prerender: true + ├── +page.svelte # home page (content lives in this file) + ├── +error.svelte # 404 page (emitted as build/404.html) + ├── audioverse/+page.svelte + ├── writing/+page.svelte # newsletter + Beeminder articles + └── uses/ + ├── +page.ts # parses uses.yaml at build time + ├── +page.svelte # renders it; owns only the tag-filter state + ├── filter.ts # tag/category logic — the only tested code + ├── filter.spec.ts + └── uses.yaml ``` -The script: +There is no `src/components/`. The footer lives in the layout, its only consumer. -1. Initiates OAuth device flow authentication -2. Fetches user profile data for 'narthur' -3. Retrieves all public repositories (up to 100) -4. Collects language statistics for each repository -5. Saves complete dataset to `data/github-stats.json` +Home page content — the positioning line, featured work, "also built" — is plain +data at the top of `src/routes/+page.svelte`. Editing the site's content means +editing those arrays; there is no CMS. -### Data Processing +## Development patterns -- **Stars**: Aggregated across all repositories -- **Languages**: Byte counts summed and converted to percentages -- **Top Languages**: Shows top 5, with remainder grouped as "Other" -- **Caching**: No runtime caching needed - data is static at build time +### SvelteKit conventions -## Development Patterns +- `+page.svelte` for pages, `+layout.svelte` for layouts +- PascalCase for `.svelte` component filenames -### SvelteKit Conventions +### Styling -- **File Routes**: `+page.svelte` for pages, `+layout.svelte` for layouts -- **Load Functions**: `+layout.ts` with `prerender: true` for static generation -- **Component Naming**: PascalCase for `.svelte` components +- **Tailwind first** — utility classes directly in markup +- **Dark only.** There is no light theme, no `dark:` variants, and no toggle. + The palette is six tokens in `tailwind.config.js`: `bg`, `ink`, `mute`, + `faint`, `rule`, `accent`. Don't add colors outside them. `accent` resolves to + the CSS variable `--accent`, declared on `:root` in `+layout.svelte` — change + the accent there, not in the Tailwind config. +- **No `@apply`** in Svelte ` diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 18dc6d6..0279f37 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,71 +1,106 @@ - + + Nathan Arthur + + + + +

Nathan Arthur

+ +

+ {positioning} +

-
-
-

Nathan Arthur

-

Full-stack web developer

+
    + {#each contact as link} +
  • + {link.name} +
  • + {/each} +
- -
- {#each profileLinks as link, i} - - {link.name} - - {#if i < profileLinks.length - 1} - - {/if} - {/each} -
-
+
+

Selected work

-
- - - -
-
+ {#each featured as project} +
+
+

+ {project.name} +

+ {project.years} +
+

{project.meta}

+

{project.description}

+
+ {/each} + -{#if showBackToTop} - -{/if} +
+

Also built

+ +
diff --git a/src/routes/audioverse/+page.svelte b/src/routes/audioverse/+page.svelte index 5d511b2..c9496e4 100644 --- a/src/routes/audioverse/+page.svelte +++ b/src/routes/audioverse/+page.svelte @@ -33,58 +33,53 @@ /> -
-

AudioVerse

-

- Technology Director, 2019–2025 -

- -
-

- AudioVerse is a non-profit media platform hosting a large library of audio and video - recordings. I led the rebuild of its public frontend on Next.js, working from designs by an - outside design firm, and directed the work on the GraphQL backend and admin dashboard behind - it. -

-

- The rebuild shipped with an accessibility backlog too large to clear in one pass, which is why - I built Pa11y Ratchet. The CI action only fails on an increase in number of accessibility issues, allowing a team - to whittle an existing backlog down over time. -

-

- These are captures from 2024. The live audioverse.org has changed since, and no longer represents my work. -

-
+

+ ← Nathan Arthur +

-
- {#each shots as shot, i} -
-
- {shot.caption} -
- {shot.alt} -
- {/each} -
+

AudioVerse

+

technology director · 2019—2025 · next.js · graphql

-

- ← Back home +

+

+ AudioVerse is a non-profit media platform hosting a large library of audio and video recordings. + I led the rebuild of its public frontend on Next.js, working from designs by an outside design + firm, and directed the work on the GraphQL backend and admin dashboard behind it. +

+

+ The rebuild shipped with an accessibility backlog too large to clear in one pass, which is why I + built Pa11y Ratchet. The CI action only fails on an increase in number of accessibility issues, allowing a team to + whittle an existing backlog down over time.

+

+ These are captures from 2024. The live audioverse.org has changed since, and no longer represents my work. +

+
+ +
+ {#each shots as shot, i} +
+
+ {shot.caption} +
+ {shot.alt} +
+ {/each}
diff --git a/src/routes/uses/+page.svelte b/src/routes/uses/+page.svelte index 439ce94..3f1528e 100644 --- a/src/routes/uses/+page.svelte +++ b/src/routes/uses/+page.svelte @@ -1,232 +1,126 @@ -
-
-

Uses

-

- Here's a list of hardware, software, and tools I use on a daily basis for work and personal - projects. This page is inspired by uses.tech. -

- - {#if isLoading} -
-
Loading...
-
- {:else if loadError} -
-

Error Loading Data

-

- Sorry, there was a problem loading the tools and equipment data. Please try again later. -

-
- {:else} -
- - - Filter by tag - {#if selectedTags.size > 0} - - {selectedTags.size} selected - - {/if} - -
- {#each allTags as tag} - - {/each} -
- {#if selectedTags.size > 0} - - {/if} -
- - {#if items.filter((item) => shouldDisplayItem(item)).length === 0} -
-

No items match your selected tags

-

- Try selecting different tags or clear your filters to see all items. -

- -
- {:else} - {#each categories as category} - {@const categoryItems = itemsForCategory(category)} - {#if categoryItems.length > 0} -
-

- {category} -

-
- {#each categoryItems as item} -
+ Uses — Nathan Arthur + + + +

+ ← Nathan Arthur +

+ +

Uses

+

+ Hardware, software, and tools I use day to day for work and personal projects. Inspired by + uses.tech. +

+ +
+ + Filter by tag + {#if selectedTags.size > 0} + ({selectedTags.size}) + {/if} + +
+ {#each data.tags as tag} + + {/each} +
+ {#if selectedTags.size > 0} + + {/if} +
+ +{#if visible.length === 0} +

+ Nothing matches those tags. + . +

+{:else} + {#each visible as group (group.category)} +
+

{group.category}

+
    + {#each group.items as item (item.name)} +
  • +

    + {item.name} +

    +

    {item.description}

    + {#if item.tags?.length} +
    + {#each item.tags as tag} + - {/each} -
    -
+ {tag} + {/each}
-
- {/if} + {/if} + {/each} - {/if} - -
-

Last updated: {meta?.lastUpdated}

- {#if meta?.affiliateDisclaimer} -

{meta.affiliateDisclaimer}

- {/if} -
- {/if} -
+ + + {/each} +{/if} + +
+

Last updated: {data.meta.lastUpdated}

+ {#if data.meta.affiliateDisclaimer} +

{data.meta.affiliateDisclaimer}

+ {/if}
diff --git a/src/routes/uses/+page.ts b/src/routes/uses/+page.ts new file mode 100644 index 0000000..020ebc4 --- /dev/null +++ b/src/routes/uses/+page.ts @@ -0,0 +1,10 @@ +import yaml from 'js-yaml'; +import raw from './uses.yaml?raw'; +import { allTags, categoriesInOrder, type UsesData } from './filter'; + +// Parsed at build time — the page prerenders to static HTML with the list baked in, +// so /uses needs no fetch and no JS to render. +export function load() { + const { items, meta } = yaml.load(raw) as UsesData; + return { items, meta, categories: categoriesInOrder(items), tags: allTags(items) }; +} diff --git a/src/routes/uses/filter.spec.ts b/src/routes/uses/filter.spec.ts new file mode 100644 index 0000000..aff7bea --- /dev/null +++ b/src/routes/uses/filter.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { allTags, categoriesInOrder, itemsInCategory, matchesTags, type UsesItem } from './filter'; + +const items: UsesItem[] = [ + { name: 'Zed', description: '', url: '', category: 'Editors', tags: ['terminal', 'ai'] }, + { name: 'Ghostty', description: '', url: '', category: 'Editors', tags: ['terminal'] }, + { name: 'Obsidian', description: '', url: '', category: 'Apps', tags: ['notes'] }, + { name: 'Mystery', description: '', url: '' } +]; + +describe('categoriesInOrder', () => { + it('keeps first-seen order rather than sorting', () => { + expect(categoriesInOrder(items)).toEqual(['Editors', 'Apps', 'Other']); + }); +}); + +describe('allTags', () => { + it('dedupes and sorts, tolerating items with no tags', () => { + expect(allTags(items)).toEqual(['ai', 'notes', 'terminal']); + }); +}); + +describe('matchesTags', () => { + it('matches everything when nothing is selected', () => { + expect(matchesTags(items[3], new Set())).toBe(true); + }); + + it('matches on any selected tag, not all of them', () => { + expect(matchesTags(items[0], new Set(['ai', 'notes']))).toBe(true); + }); + + it('excludes an untagged item once a filter is active', () => { + expect(matchesTags(items[3], new Set(['terminal']))).toBe(false); + }); +}); + +describe('itemsInCategory', () => { + it('sorts by name within the category', () => { + expect(itemsInCategory(items, 'Editors', new Set()).map((i) => i.name)).toEqual([ + 'Ghostty', + 'Zed' + ]); + }); + + it('applies the tag filter within the category', () => { + expect(itemsInCategory(items, 'Editors', new Set(['ai'])).map((i) => i.name)).toEqual(['Zed']); + }); + + it('groups uncategorised items under Other', () => { + expect(itemsInCategory(items, 'Other', new Set()).map((i) => i.name)).toEqual(['Mystery']); + }); +}); diff --git a/src/routes/uses/filter.ts b/src/routes/uses/filter.ts new file mode 100644 index 0000000..0a83c70 --- /dev/null +++ b/src/routes/uses/filter.ts @@ -0,0 +1,45 @@ +export interface UsesItem { + name: string; + description: string; + url: string; + category?: string; + tags?: string[]; +} + +export interface UsesMeta { + lastUpdated: string; + affiliateDisclaimer: string; +} + +export interface UsesData { + items: UsesItem[]; + meta: UsesMeta; +} + +export const UNCATEGORIZED = 'Other'; + +export const categoryOf = (item: UsesItem) => item.category ?? UNCATEGORIZED; + +/** Categories in the order they first appear in the YAML — the file is the running order. */ +export function categoriesInOrder(items: UsesItem[]): string[] { + return [...new Set(items.map(categoryOf))]; +} + +export function allTags(items: UsesItem[]): string[] { + return [...new Set(items.flatMap((item) => item.tags ?? []))].sort(); +} + +/** An empty selection matches everything; otherwise an item needs at least one selected tag. */ +export function matchesTags(item: UsesItem, selected: Set): boolean { + return selected.size === 0 || (item.tags?.some((tag) => selected.has(tag)) ?? false); +} + +export function itemsInCategory( + items: UsesItem[], + category: string, + selected: Set +): UsesItem[] { + return items + .filter((item) => categoryOf(item) === category && matchesTags(item, selected)) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/static/data/uses.yaml b/src/routes/uses/uses.yaml similarity index 100% rename from static/data/uses.yaml rename to src/routes/uses/uses.yaml diff --git a/src/routes/writing/+page.svelte b/src/routes/writing/+page.svelte new file mode 100644 index 0000000..b5cf406 --- /dev/null +++ b/src/routes/writing/+page.svelte @@ -0,0 +1,75 @@ + + + + Writing — Nathan Arthur + + + +

+ ← Nathan Arthur +

+ +

Writing

+

+ I write a mostly-weekly newsletter, and I've written for the Beeminder blog. +

+ +
+

Newsletter

+
+
+

+ + Narthur Online + +

+ weekly-ish +
+

+ Where most of my writing goes. Tools I'm using, things I've built, and what I'm figuring out + about running a one-person software business. Sign up at the bottom of any page. +

+
+
+ +
+

Beeminder blog

+ {#each posts as post} + + {/each} +
diff --git a/src/services/github/api.ts b/src/services/github/api.ts deleted file mode 100644 index 7673699..0000000 --- a/src/services/github/api.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { GithubStats, GithubRepo } from './types'; -import githubData from '../../../data/github-stats.json'; - -export function fetchGithubStats(): GithubStats { - // Calculate stats from static data - const totalStars = githubData.repos.reduce( - (sum: number, repo: GithubRepo) => sum + (repo.stargazers_count ?? 0), - 0 - ); - - // Aggregate language bytes - const languages: Record = {}; - Object.entries(githubData.languages).forEach(([_repo, repoLanguages]) => { - Object.entries(repoLanguages).forEach(([lang, bytes]) => { - languages[lang] = (languages[lang] || 0) + bytes; - }); - }); - - // Convert to percentages and sort - const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0); - // Get sorted language entries - const sortedLanguages = Object.entries(languages) - .map(([lang, bytes]) => [lang, Number((bytes / totalBytes) * 100)]) - .sort((a, b) => Number(b[1]) - Number(a[1])); - - // Take top 4 languages and sum the rest into "Other" - const topLanguages = sortedLanguages.slice(0, 5); - const otherPercentage = sortedLanguages - .slice(5) - .reduce((sum: number, [_, percent]) => sum + Number(percent), 0); - - const languagePercentages = Object.fromEntries([ - ...topLanguages, - ...(otherPercentage > 0 ? [['Other', otherPercentage]] : []) - ]); - - return { - publicRepos: githubData.user.public_repos, - followers: githubData.user.followers, - totalStars, - languages: languagePercentages - }; -} diff --git a/src/services/github/colors.ts b/src/services/github/colors.ts deleted file mode 100644 index 4d869ca..0000000 --- a/src/services/github/colors.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Common GitHub language colors -export const languageColors: Record = { - TypeScript: '#3178c6', - JavaScript: '#f1e05a', - HTML: '#e34c26', - CSS: '#563d7c', - Python: '#3572A5', - PHP: '#4F5D95', - Ruby: '#701516', - Java: '#b07219', - Swift: '#ffac45', - Go: '#00ADD8', - Rust: '#dea584', - Shell: '#89e051', - Vue: '#41b883', - 'C++': '#f34b7d', - C: '#555555' -}; - -export function getLanguageColor(language: string): string { - return languageColors[language] || '#8b949e'; -} diff --git a/src/services/github/types.ts b/src/services/github/types.ts deleted file mode 100644 index 20cc372..0000000 --- a/src/services/github/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface GithubStats { - publicRepos: number; - followers: number; - totalStars: number; - languages: Record; -} - -export interface GithubUser { - public_repos: number; - followers: number; -} - -export interface GithubRepo { - name: string; - stargazers_count: number; -} - -export interface GithubLanguages { - [repo: string]: { - [language: string]: number; - }; -} - -export interface GithubData { - user: GithubUser; - repos: GithubRepo[]; - languages: GithubLanguages; - fetchedAt: string; -} diff --git a/src/types/github-stats.d.ts b/src/types/github-stats.d.ts deleted file mode 100644 index 005e150..0000000 --- a/src/types/github-stats.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { GithubData } from '../services/github/types'; - -declare module '*.json' { - const value: GithubData; - export default value; -} diff --git a/static/favicon.png b/static/favicon.png index 825b9e6..bf47393 100644 Binary files a/static/favicon.png and b/static/favicon.png differ diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..f3c6ed5 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,16 @@ + + + + + n + + diff --git a/svelte.config.js b/svelte.config.js index ae784dd..83c1953 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -5,7 +5,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter() + // fallback emits build/404.html from +error.svelte. Without it adapter-static + // writes nothing for unmatched paths and the host serves its own generic 404. + adapter: adapter({ fallback: '404.html' }) } }; diff --git a/tailwind.config.js b/tailwind.config.js index 1d994f6..3ad916b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,13 +1,24 @@ -import typography from '@tailwindcss/typography'; -import forms from '@tailwindcss/forms'; -import containerQueries from '@tailwindcss/container-queries'; - /** @type {import('tailwindcss').Config} */ export default { content: ['./src/**/*.{html,js,svelte,ts}'], - darkMode: 'class', theme: { - extend: {} - }, - plugins: [typography, forms, containerQueries] + extend: { + // ponytail: dark-only site, so these are the whole palette — no light variants. + // Text tokens clear WCAG AA (4.5:1) against `bg`: ink 16.1:1, mute 6.2:1, + // faint 4.8:1, accent 13.4:1. `rule` is decorative hairlines only — never text. + // `accent` reads --accent, defined in +layout.svelte, so it can be themed in one place. + colors: { + bg: '#0a0c10', + ink: '#e8e9ec', + mute: '#8a919e', + faint: '#767e8b', + rule: '#1c212a', + accent: 'var(--accent)' + }, + fontFamily: { + sans: ['Instrument Sans', 'system-ui', 'sans-serif'], + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'] + } + } + } }; diff --git a/vite.config.ts b/vite.config.ts index 5d4b6bb..8213048 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,31 +1,9 @@ import { defineConfig } from 'vitest/config'; import { sveltekit } from '@sveltejs/kit/vite'; -import type { HmrContext } from 'vite'; export default defineConfig({ - plugins: [sveltekit(), YamlHmr()], + plugins: [sveltekit()], test: { include: ['src/**/*.{test,spec}.{js,ts}'] - }, - server: { - fs: { - allow: ['./data'] - } } }); - -// SOURCE: https://www.reddit.com/r/sveltejs/comments/15i61h1/comment/jusdljf/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button -function YamlHmr() { - return { - name: 'yaml-hmr', - enforce: 'post' as const, - handleHotUpdate({ file, server }: HmrContext) { - if (file.endsWith('.yaml')) { - server.ws.send({ - type: 'full-reload', - path: '*' - }); - } - } - }; -} diff --git a/wrangler.jsonc b/wrangler.jsonc index f8b8f7e..83dc23a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,7 +2,10 @@ "name": "nathanarthur-com", "compatibility_date": "2025-06-26", "assets": { - "directory": "./build" + "directory": "./build", + // Serve the generated 404.html (from +error.svelte) for unmatched paths + // instead of Cloudflare's generic one. + "not_found_handling": "404-page" }, // Custom domains require the zone to live in this Cloudflare account. "routes": [