diff --git a/package.json b/package.json
index e4beac44..adaab72a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homepage",
- "version": "1.51.0",
+ "version": "1.52.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/scripts/codemod-app-stores-to-state.ts b/scripts/codemod-app-stores-to-state.ts
new file mode 100644
index 00000000..ee09e8f6
--- /dev/null
+++ b/scripts/codemod-app-stores-to-state.ts
@@ -0,0 +1,84 @@
+/**
+ * Migrate `$app/stores` (deprecated) to `$app/state` (rune-based).
+ *
+ * For each .svelte file:
+ * - Rewrite `from '$app/stores'` → `from '$app/state'`
+ * - For each named import, drop the `$` prefix from auto-subscriptions:
+ * `$page.url.pathname` → `page.url.pathname`
+ * `$navigating` → `navigating`
+ * `$updated` → `updated`
+ * Aliased imports (`page as appPage`) are tracked, so `$appPage` becomes `appPage`.
+ *
+ * Skips:
+ * - Non-.svelte files (server-only code uses getRequestEvent instead).
+ * - Files importing other things from $app/stores that don't have a state equivalent
+ * (none observed in this repo).
+ *
+ * Run: pnpm exec vite-node scripts/codemod-app-stores-to-state.ts [--dry]
+ */
+
+import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
+import { join, extname } from 'node:path';
+
+const SRC = 'src';
+const DRY = process.argv.includes('--dry');
+
+const STORES_IMPORT_RE =
+ /import\s*\{([^}]+)\}\s*from\s*['"]\$app\/stores['"]\s*;?/;
+
+function walk(dir: string, out: string[] = []): string[] {
+ for (const name of readdirSync(dir)) {
+ const p = join(dir, name);
+ const s = statSync(p);
+ if (s.isDirectory()) walk(p, out);
+ else if (extname(p) === '.svelte') out.push(p);
+ }
+ return out;
+}
+
+function parseImports(inner: string): Array<{ orig: string; local: string }> {
+ return inner
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((spec) => {
+ const m = spec.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
+ if (!m) return null;
+ return { orig: m[1], local: m[2] ?? m[1] };
+ })
+ .filter((x): x is { orig: string; local: string } => x !== null);
+}
+
+function rewriteFile(src: string): { code: string; changed: boolean } {
+ const m = STORES_IMPORT_RE.exec(src);
+ if (!m) return { code: src, changed: false };
+
+ const imports = parseImports(m[1]);
+ if (imports.length === 0) return { code: src, changed: false };
+
+ // Replace the import path; preserve the same import shape.
+ let out = src.replace(STORES_IMPORT_RE, (full) =>
+ full.replace(/['"]\$app\/stores['"]/, "'$app/state'")
+ );
+
+ // Drop `$` prefix from each local name where it appears as a store
+ // auto-subscription (i.e. $name followed by a non-word boundary).
+ for (const { local } of imports) {
+ const re = new RegExp(`\\$${local}\\b`, 'g');
+ out = out.replace(re, local);
+ }
+
+ return { code: out, changed: out !== src };
+}
+
+const files = walk(SRC);
+let changed = 0;
+for (const f of files) {
+ const orig = readFileSync(f, 'utf8');
+ const { code, changed: didChange } = rewriteFile(orig);
+ if (!didChange) continue;
+ if (!DRY) writeFileSync(f, code);
+ changed++;
+ console.log(` ${f}`);
+}
+console.log(`\n${DRY ? '[dry] ' : ''}${changed} files migrated`);
diff --git a/src/lib/components/FavoriteButton.svelte b/src/lib/components/FavoriteButton.svelte
index 0f9eace2..bc2792a7 100644
--- a/src/lib/components/FavoriteButton.svelte
+++ b/src/lib/components/FavoriteButton.svelte
@@ -1,12 +1,12 @@
diff --git a/src/lib/components/fitness/ExercisePicker.svelte b/src/lib/components/fitness/ExercisePicker.svelte
index b29a9bb4..628e7b6f 100644
--- a/src/lib/components/fitness/ExercisePicker.svelte
+++ b/src/lib/components/fitness/ExercisePicker.svelte
@@ -9,10 +9,10 @@
import PersonStanding from '@lucide/svelte/icons/person-standing';
import Shapes from '@lucide/svelte/icons/shapes';
import Weight from '@lucide/svelte/icons/weight';
- import { page } from '$app/stores';
+ import { page } from '$app/state';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
- const lang = $derived(detectFitnessLang($page.url.pathname));
+ const lang = $derived(detectFitnessLang(page.url.pathname));
const isEn = $derived(lang === 'en');
/**
diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte
index 55f2808e..52f0feb8 100644
--- a/src/lib/components/fitness/FoodSearch.svelte
+++ b/src/lib/components/fitness/FoodSearch.svelte
@@ -1,6 +1,6 @@
diff --git a/src/routes/[cospendRoot=cospendRoot]/+layout.svelte b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte
index 2059344a..f12bb87b 100644
--- a/src/routes/[cospendRoot=cospendRoot]/+layout.svelte
+++ b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte
@@ -1,6 +1,6 @@
diff --git a/src/routes/[recipeLang=recipeLang]/+layout.svelte b/src/routes/[recipeLang=recipeLang]/+layout.svelte
index b2d0625b..42eeaafa 100644
--- a/src/routes/[recipeLang=recipeLang]/+layout.svelte
+++ b/src/routes/[recipeLang=recipeLang]/+layout.svelte
@@ -1,7 +1,7 @@
diff --git a/src/routes/fitness/+layout.svelte b/src/routes/fitness/+layout.svelte
index 5996dbf3..e4339d7b 100644
--- a/src/routes/fitness/+layout.svelte
+++ b/src/routes/fitness/+layout.svelte
@@ -1,6 +1,6 @@