- Follow SvelteKit and Svelte idioms; reuse existing content, components, and patterns rather than porting React-style structure.
- When extending the site, migrate and normalize content first, then build UI to match the existing look.
- Keep the work page intro prominent; list past tech roles in a minimal, de-emphasized way with no company logos.
- Avoid implicit
any; type route+page.tsloaders withPageLoadfrom./$types. - Keep thoughts as local markdown on disk; syndicate to the ATmosphere with standard.site, do not render posts from the PDS.
- Use
@lucide/sveltefor UI icons; brand/site logos (BrandIcon,MsLogo) are inline SVG components (not<img>) socurrentColorworks and size is set viaclass; Lucide excludes brand icons.
- SvelteKit site at the repo root:
@sveltejs/adapter-node(hybrid — most routes prerendered; Node server required for/listening,/api/status,/api/cron/sync-listening, andhooks.server.tsSQLite/cron — not removable without architecture change),$lib/content/*plus$lib/config/*for posts, work, games, and site config. Thoughts are local markdown (src/content/thoughts/*.md,markdown-it,js-yamlv3 frontmatter; optionalherofor cover/OG); syndicate to standard.site via@ewanc26/svelte-standard-site(pnpm publish-publication,publish-thought,publish-thoughts,write-well-known; markpub.at content;herouploads ascoverImageblob viascripts/lib/cover-image.mjs). Verification<link rel="site.standard.document">on individual thought pages via postbuild inject (scripts/inject-standard-site-links.mjs; clears stale.br/.gzso sirv serves updated HTML; not<svelte:head>—prerender crawler rejectsat://);pnpm-workspace.yamlpins@ewanc26/*overrides. /listeningreads recent Last.fm scrobbles from SQLite (better-sqlite3); sync runs via in-processnode-cron(~5×/day at server startup) and stale refresh on page load;GET /api/cron/sync-listening(BearerCRON_SECRET) is optional for external/manual HTTP sync only.- Homepage status line reads
com.michaelschultz.status/selffrom Bluesky via/api/status(60s server cache); publish withpnpm statususingBLUESKY_APP_PASSWORDlocally (not needed at runtime). - Docker deployment: multi-stage
Dockerfile+docker-compose.ymlon external networkpangolinfor reverse-proxy routing; shared pnpm store cache mount with separateprod-depsandbuildstages (runtime copies prodnode_modulesfromprod-deps,build/frombuild);pnpm-workspace.yamlsetstrustLockfile: true; production image is built in GitHub Actions (.github/workflows/deploy.yml) and pushed toghcr.io/michaelwschultz/michaelschultz.com; VPS runsdeploy/deploy.sh(ephemeralDOCKER_CONFIGfor GHCR login, quietdocker compose pull, optionalDEPLOY_DOCKER_CPU_QUOTAon dockerd, thenup --no-build, thendocker image prune -a -f; no on-host build);webservicecpus: 0.8;kokoro-jsand@ewanc26/svelte-standard-siteare devDependencies only (client TTS/publish scripts, not runtimenode_modules); do not pass--config.child-concurrency=2topnpm installin the Dockerfile (pnpm 11 lifecycle-hook bug); SQLite atDATABASE_PATH(default/app/data/listening.db);deploy/docker-entrypoint.shchowns thelistening-datavolume; app and infrastructure settings live in GitHub Environment secrets, not in the repo. - Thought read-aloud: client-side Kokoro TTS (
kokoro-js) on/thoughts/[slug]/only; engine in$lib/page-reader/*(ported from standard-reader);thoughts/[slug]/+layout.sveltelazy-loads the engine andPageReaderBar; attributes standard-reader.app; WebGPU whennavigator.gpuon desktop, WASM q8 on mobile (WebGPU OOMs on phones); mobile: syncunlockReaderAudio()on tap, one-sentence generation lookahead, drop played buffers; first play:beginReaderAudioSession()inaudio-context.ts(silent keep-alive loop) so AudioContext stays running during async model load; speech text vianormalizeReaderWhitespace()inword-highlight.tscollapses editor soft line wraps before sentence split (real breaks stay as DOM block boundaries); word highlighting via CSS::highlight(reader-word)instatic/reader-highlight.css(outside Tailwind pipeline). - Static assets live under
static/static/so URLs stay/static/images/...; site favicons instatic/static/favicons/(linked fromsrc/app.html), derived fromMsLogowith transparent PNG backgrounds. - Search is Pagefind:
pnpm buildrunsvite buildthenscripts/postbuild.mjsto indexbuild/; UI uses thepagefind-modalweb component; dev search needs a production build (index not available invite dev);export const trailingSlash = 'always'insrc/routes/+layout.tsemits directory-style HTML for correct paths; layout setsdata-pagefind-metawith clean route URLs (e.g.url:/work);content-checkroutes usedata-pagefind-ignoreso they are not indexed. - Node is locked to 24.x (
enginesinpackage.json,.nvmrc,.node-version). - Open Graph and Twitter meta come from
SocialMetain the root layout andgetPageSocialMetain$lib/utils/social-meta.ts; thought pages useherothenimages[0]; default image issite.socialBanner(/static/images/michael-schultz-social.jpg). pnpm checkrunssvelte-checkthentsc --noEmitso untyped route loaders are caught in CI and locally.
- Node 24 is required (
engine-strict=truein.npmrc); pnpm install will refuse to run on older Node. The VM has an/exec-daemon/node(Node 22) that shadows nvm onPATH, so run commands in a login shell (bash -lc '...') where~/.bashrcputs nvm's Node 24 first. Verify withnode --versionbefore running pnpm. - Standard commands are in
README.md/package.json:pnpm dev(Vite dev server onhttp://localhost:5173, routes usetrailingSlash: 'always'so bare paths 308-redirect to the trailing-slash URL),pnpm check,pnpm build(also runs Pagefind indexing overbuild/),pnpm start(serves the built app on port 3000). - No secrets are needed to run/lint/build/test locally. Without
LASTFM_API_KEYthe/listeningpage renders normally but shows "No recent plays yet";pnpm status(Bluesky) is a publishing script and not needed to run the site. - There is no automated test suite;
pnpm check(svelte-check + tsc) is the type/lint gate and the.husky/pre-commithook runs it on commit.