port: Next.js → SvelteKit (Svelte 5 runes)#29
Merged
Merged
Conversation
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>
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@sveltejs/adapter-vercel(Node, Fluid Compute)./signal,/source,/resonance, all/case-studies/*,/anything-but-analog/raw/[slug], and the entire audio-reactive subsystem (UI was setting state nothing visual read aftercomponents/hero/*was orphaned).^or~); zero high/critical CVEs on prod deps.Spec:
docs/superpowers/specs/2026-05-17-sveltekit-migration-design.mdPlan:
docs/superpowers/plans/2026-05-17-sveltekit-migration.mdScope
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
pnpm test:smoke): 38/38 routes 200 + marker visible + no console errorspnpm test:visual): 38/38 within tolerance vs the Next baseline (mobile + desktop, canvas + iframes masked)pnpm lh): all categories ≥ Next baseline on every route (results indocs/lighthouse/)/api/countersGET + POST behave identically to currentLighthouse delta (Next baseline → SvelteKit)
//shelf/sounds/thoughts/dad/deana/benny/anything-but-analog/canvas/selfNote:
/canvas/selfperf dip (-4) is within noise — it runs a WebGL canvas that precludes a perfect score on either stack.Notable
Sketch.setup)/api/imgsharp-backed image proxy (replaces Next<Image>for /shelf book covers)$lib/canvas/gl-utils.ts,$lib/canvas/color.ts,$lib/server/ttl-cache.ts,$lib/markdown.tsMerge 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