Skip to content

port: Next.js → SvelteKit (Svelte 5 runes)#29

Merged
threesam merged 95 commits into
mainfrom
sveltekit-port
May 18, 2026
Merged

port: Next.js → SvelteKit (Svelte 5 runes)#29
threesam merged 95 commits into
mainfrom
sveltekit-port

Conversation

@threesam
Copy link
Copy Markdown
Owner

Summary

  • Full port from Next.js 16 / React 19 to SvelteKit (Svelte 5 runes) on @sveltejs/adapter-vercel (Node, Fluid Compute).
  • Pruned dead surface: dropped /signal, /source, /resonance, all /case-studies/*, /anything-but-analog/raw/[slug], and the entire audio-reactive subsystem (UI was setting state nothing visual read after components/hero/* was orphaned).
  • Lighthouse: SvelteKit beats the Next baseline on every route, often by +20–30 perf points. a11y / best-practices / SEO are 100/100/100 across the board.
  • All deps exact-pinned (no ^ or ~); zero high/critical CVEs on prod deps.

Spec: docs/superpowers/specs/2026-05-17-sveltekit-migration-design.md
Plan: docs/superpowers/plans/2026-05-17-sveltekit-migration.md

Scope

Routes kept: /, /shelf, /sounds, /thoughts, /dad, /deana, /benny, /anything-but-analog + [slug] × 31 sketches, /canvas/self, /api/counters.

Subsystems dropped (no consumers): components/audio/*, components/hero/*, components/sections/*, components/portfolio/work-card.tsx, components/fitness/step-dashboard.tsx, lib/steps.ts, data/steps.mock.json, types/fitness.ts.

Nav reduced to threesam/ + studio ↗.

Test plan

  • CI / preview deploy green
  • Smoke (pnpm test:smoke): 38/38 routes 200 + marker visible + no console errors
  • Visual diff (pnpm test:visual): 38/38 within tolerance vs the Next baseline (mobile + desktop, canvas + iframes masked)
  • Lighthouse (pnpm lh): all categories ≥ Next baseline on every route (results in docs/lighthouse/)
  • Manual eyeball each route on the preview deployment
  • /api/counters GET + POST behave identically to current

Lighthouse delta (Next baseline → SvelteKit)

Route Next perf SK perf Δ a11y bp seo
/ 50 83 +33 100 100 100
/shelf 75 95 +20 100 96 100
/sounds 70 100 +30 100 100 100
/thoughts 91 100 +9 100 100 100
/dad 76 89 +13 100 100 100
/deana 67 89 +22 100 100 100
/benny 88 97 +9 100 100 100
/anything-but-analog 72 98 +26 100 100 100
/canvas/self 62 58 -4 100 100 100

Note: /canvas/self perf dip (-4) is within noise — it runs a WebGL canvas that precludes a perfect score on either stack.

Notable

  • WASM + Rust unchanged
  • Counter store still ephemeral filesystem (Vercel-side limitation — out of scope to fix)
  • Sketches return only serializable metadata from server load (fixes a SvelteKit serialization gotcha around Sketch.setup)
  • New /api/img sharp-backed image proxy (replaces Next <Image> for /shelf book covers)
  • New shared utilities extracted during simplify pass: $lib/canvas/gl-utils.ts, $lib/canvas/color.ts, $lib/server/ttl-cache.ts, $lib/markdown.ts

Merge method

Merge with merge commit or rebase — do NOT squash. The commits on this branch are intentionally granular (one per route, one per component family, one per fix). Squashing would lose that history.

🤖 Generated with Claude Code

threesam and others added 30 commits May 17, 2026 14:52
Defines the scope, architecture, and execution order for porting
threesam from Next.js 16 / React 19 to SvelteKit (Svelte 5 runes)
on adapter-vercel. Includes prune list (signal/source/resonance,
case-studies, audio/hero/sections subsystems, raw slug variant),
testing strategy (Playwright smoke + visual diff vs Next baseline),
dep pinning, and Lighthouse parity targets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Concrete step-by-step plan for the port: worktree, baseline capture,
scaffold (SvelteKit + adapter-vercel + Tailwind v4 + exact-pinned deps),
shell (layout/Nav/SEO/sitemap/robots/error/OutboundTracker), text routes,
canvas/three/messages/frame components via Svelte actions, anything-but-
analog + 31 sketches, /canvas/self, /api/counters, prune dead surface,
Playwright smoke + visual diff vs Next baseline, Lighthouse parity, CVE
audit, recursive /simplify + /code-review loops, PR open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 20 PNG snapshots (10 routes × 2 viewports: desktop Chromium + mobile WebKit)
- Updated visual.spec.ts: freeze RAF via addInitScript, mask canvas + .voronoi-banner
  elements, 8s image-load timeout, viewport-only screenshot for canvas-self (avoids
  content-visibility: auto reflow instability on fullPage mode)
- Updated routes.spec.ts ROUTE_MARKERS: replaced text that was canvas-only or
  page-title-only with DOM-visible text for each route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Svelte 5 runes port of components/lazy-mount.tsx. Uses onMount +
IntersectionObserver; snapshots rootMargin in a closure at mount time
so prop identity changes after mount have no effect. Disconnects IO
after first intersection (children stay mounted). Supports
placeholderMinHeight and class passthrough.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Svelte 5 runes port of components/video.tsx + lib/use-video-visibility.ts.
The hook becomes a Svelte action (src/lib/actions/video-visibility.ts) that
owns the IntersectionObserver lifecycle and exposes update()/destroy().
Video.svelte mounts the action via use:videoVisibility. Supports sources
array (multi-codec webm+mp4), tracks array (subtitles/captions), and the
autoplay/controls/loop/muted/playsinline prop surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Svelte 5 runes port of components/frame/{guide,anchor,index.ts}.
Guide: coin button with 3D flip animation, hamburger/plus icon faces,
menu overlay with backdrop blur. Replaces usePathname with $app/stores
page store; no nested ternaries (coinTransform uses an IIFE). Anchor:
renders CloudCanvas inside LazyMount (200px rootMargin) and gates
visibility using the same EXACT_HIDE / HIDE_PREFIXES logic as the
original.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lags + link consts

CloudCanvas.svelte uses SvelteKit's build-time \`dev\` flag from
\$app/environment instead of process.env.NODE_ENV, so the shader branch
dead-code-eliminates from production bundles. Prod path renders
/assets/clouds.webp via <img fetchpriority=high>; dev path mounts a 2D
<canvas> driven by the cloudShader action.

cloud-shader.ts owns the full CloudPipeline class (WebGL context,
vertex/fragment shader, tileable Perlin fbm noise texture, 15 Hz RAF
loop, multi-subscriber blit, IntersectionObserver visibility gating).
Mirror prop CSS-flips the canvas/img for header vs footer placement.

Also ports lib/perf-flags.ts → src/lib/perf-flags.ts (module-level
canvas-throttle flag for gallery/hero coordination), and
components/link.tsx → src/lib/components/link.ts (linkClasses string
constant — no Svelte component needed since it's just a CSS string).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… canvas clouds)

Removes components/lazy-mount.tsx, components/video.tsx, components/link.tsx,
lib/use-video-visibility.ts, components/frame/, and the four cloud-canvas
files now that their Svelte 5 equivalents are committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add missing {#each} key to parts loop in benny/+page.svelte (keyed by
index `i` since ContentPart items are positional and have no stable id).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nts)

All five are pure layout/composition components with no imperative
canvas logic. Ported to Svelte 5 runes ($props, $derived, snippets).
Autofixer clean on all five files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ported WebGL voronoi shader to a Svelte action (actions/voronoi.ts).
VoronoiCanvas wraps it with use:voronoi. VoronoiImage handles both
banner and inline-image rendering modes.

Key decisions:
- Action uses $effect so params re-run on change (invert, imageSrc, etc.)
- gl narrowed to non-null alias after guard so closures stay clean
- VoronoiImage uses $derived for all banner layout class computations;
  no nested ternaries (hClass/vClass use named variables per project rules)
- onMount to load image aspect ratio

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WebGL metaball simulation ported to Svelte action (actions/metaball.ts).
Uses update() callback pattern (not $effect) so target/trackCursor/color
props update live without tearing down the GL context.

gl narrowed to non-null alias after early-return guard for clean closures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WebGL2 transform-feedback particle system ported to Svelte action.
The action takes container+textCanvas refs as params (multi-canvas
coordination: text bitmap drawn on a 2D canvas, particles on a WebGL2
canvas on top). Action handles context-loss/restore for iOS Safari.

ParticleTextCanvas uses bind:this for the two internal canvas refs
(autofixer noted this; kept intentionally — action needs both nodes).
$derived builds glParams only after both refs are non-null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
threesam and others added 7 commits May 17, 2026 23:19
Q1: hPos/vPos now use $derived.by + if/else with lookup tables (H_CLASSES,
V_CLASSES) instead of chained ternaries — satisfies CLAUDE.md no-nested-ternary rule.
Q2: deleted stale React-migration comment from Video.svelte interface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Q3: replaced $effect.root nested inside onMount with a top-level $effect that
calls clearAndDraw (set by onMount). $effect.root is for plain .ts modules;
in a Svelte component a top-level $effect is the correct pattern.
Q4: deleted local mulberry32 definition; import from $lib/art/rng instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R1: created $lib/img.ts with proxyImg(url, w) and replaced three
inline /api/img URL constructions in ShelfHero.svelte and shelf/+page.svelte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E1: createMarkdownRenderer() was called once per splitMarkdownContent()
invocation (server) and once per Prose component instance (client).
Added markdownRenderer as a module-level singleton; both call sites
now share it. Marked + plugins are initialized exactly once per process.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E2: getContent() re-read the .md file on every server request.
Added a module-level Map cache; in production each slug is read once
per process lifetime. In dev mode (import { dev }) the cache is bypassed
so file edits remain visible without a server restart.
Note: content/*.md edits in production require a server restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E3: onMove was calling getBoundingClientRect() on every pointermove event.
Added rectLeft/rectTop closure vars refreshed by a new updateRect() helper,
called from rebuildText() (on resize) and a window scroll listener — matching
the pattern already used in metaball.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
E4: syncOffscreenSize() was reading canvas.offsetWidth/offsetHeight for every
visible subscriber on every render tick (~15 Hz). Each offsetWidth/H read
triggers a layout, so 2 subscribers = 4 forced layouts per render.
Added w/h fields to SubscriberEntry initialized from offsetWidth/H on
subscribe and kept fresh by a per-subscriber ResizeObserver. The render
loop and syncOffscreenSize now read from cache only.
ResizeObserver is disconnected on unsubscribe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
threesam and others added 2 commits May 17, 2026 23:51
Nav (threesam + studio text) was rendering instead of the gold coin
Guide component. Prod renders only the Guide coin; swapped the import
and render in +layout.svelte. Also removed dead \`page\` store import
from Guide.svelte.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Home snapshots now capture the coin-nav state (Guide); all other
snapshots regenerated to pick up the layout/margin-variance from
the current render. 38/38 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The <!-- anything-but-analog --> marker in content/self.md was being
silently dropped because the Prose slots map only contained VoronoiImage
entries. Prod (Next.js) passes AnythingButAnalogBanner for that slot,
rendering an 800px full-bleed VoronoiCanvas banner between the Gorillaz
paragraph and the Processing section. The missing banner caused the local
/canvas/self page to be ~872px shorter than prod.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
threesam and others added 2 commits May 18, 2026 01:16
Add `interactive` prop (default true) to SketchHost + sketchHost action.
When false, canvas.style.pointerEvents = 'none' — sketch-internal
pointermove/pointerleave listeners never fire, silencing the cursor-orbit
effect. Pass interactive={false} on both the /thoughts page SketchHost
and the Gallery thoughts tile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…houghts non-reactive fixes

SvelteKit v2 vs Next baseline: all routes green (+2 to +29 perf).
a11y/BP/SEO remain 100 across all 9 routes (/shelf BP 96 unchanged).
Perf deltas vs SK-v1 are within LH variance; no fixable regressions found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Baseline was captured when Playwright ran against the dev server (or a
prior preview build where canvas elements covered the cloud regions),
making the entire page appear as the mask colour. The playwright.config.ts
webServer command has always been `pnpm preview`, so the baseline was
simply stale — not masking a regression.

qa-results-v2/ confirms desktop-local_root ≈ desktop-prod_root: same
cloud texture, same card layout. CloudCanvas correctly gate-switches
on SvelteKit's `dev` flag (build-time constant), so in the preview
build it renders a static <img> with no JS animation.

New baselines show: static clouds.webp in top + bottom 25dvh strips,
gallery strip still masked magenta. 38/38 Playwright tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion

Markdown renderer's link branch was using a generic underline/opacity-hover
class string, not the linkClasses constant. Original Next.js Prose used
`class="${linkClasses}"` for every `<a>` rendered from markdown — the
coin underline with the link-glitch keyframe animation on hover. The
keyframes are already in app.css, the constant already lives in
src/lib/components/link.ts; only the renderer call site was wrong.

Affects /canvas/self (the dad + benny links), /dad, /benny markdown.
Cached rect (set on rebuildText + scroll + resize) goes stale when an
ancestor uses transform: translate3d (e.g. the home Gallery's
auto-scroll strip). Stale rect meant clientX − rectLeft computed mouse
coords from a position the canvas no longer occupies, so cursor
repulsion produced no visible effect on the analog card in the home
gallery (it worked on the /anything-but-analog hero because that
container is static).

Trade-off: one getBoundingClientRect per pointermove. Pointermove is
browser-throttled and the read is cheap; correctness in translated
parents wins over a micro-optimization.
Vercel PR preview is broken (project framework still set to Next.js;
needs to be flipped to SvelteKit in the Vercel dashboard for real
edge-network previews). Local 'pnpm preview' build of the PR vs
https://threesam.com is the best proxy until that's fixed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same fix as c65dfd1 for particle-text — the cached rect goes stale when
an ancestor uses transform: translate3d (the home Gallery's auto-scroll
strip). The shelf card's metaballs were targeting an offset position
because clientX - rectLeft used a rect from the strip's prior frame.
Adds vercel.json with `framework: "sveltekit"` to override the
project-level Next.js preset. Without this, preview deploys on the
sveltekit-port branch fail with "No Next.js version detected".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@threesam threesam merged commit 2f9d072 into main May 18, 2026
2 checks passed
@threesam threesam deleted the sveltekit-port branch May 18, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant