diff --git a/CLAUDE.md b/CLAUDE.md
index 03da412..20c5bf2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,6 +15,8 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
- `npm run lint` — type-check only (`tsc --noEmit`)
- `npm test` / `npm run test:watch` — vitest
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
+- `npm run jd2 -- --archives
--dry-run` — generate JDownloader `.crawljob`
+ files for every profile on disk (see `scripts/jd2-sync.ts`)
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
@@ -71,14 +73,21 @@ Restoring an archive **rehydrates URLs from `path`**: server archives rebuild HT
Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue is **serial on purpose** — decoding several 50MP+ images at once OOMs the tab. `requestThumbnail` must keep a stable identity (it reads cache state through a ref), or every completed thumbnail re-runs the effect in all mounted thumbnails.
-### URL state (`src/App.tsx`)
+### URL state (`src/App.tsx`, `src/lib/routing.ts`)
-App state syncs to `?a=` / `?t=` / `?p=`. Two rules, both learned from real bugs:
+Paths mirror Instagram: `//`, `//reels/`, `//p//`. The old `?a=&t=&p=` form is still parsed for existing links but never written. Reserved prefixes (`api`, `archives`, `assets`…) can't be mistaken for a profile name.
-- The initial query string is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
+A post URL carries no tab, as on Instagram — the tab is re-derived from the post's `source`, so a reel link lands on the Reels tab and pages through reels. Sidecar posts keep directory-scoped ids internally but expose only the shortcode.
+
+Three rules, all learned from real bugs:
+
+- The initial route is captured into a ref on first render; the URL is rewritten from state as soon as anything loads, so reading `window.location` later sees the rewrite, not the user's link.
- URL writing is gated on `hasInitialLoaded`, otherwise it erases the deep link before the loader consumes it.
+- Deep-link resolution waits on the archive fetch having *settled* (`archivesFetched`), not on `isServerMode`, which is still false while the request is in flight.
-Deep-link resolution waits on the archive fetch having *settled*, not on `isServerMode` (which is still false while in flight).
+### Mobile feed (`src/components/PostFeed.tsx`)
+
+Below `md`, opening a post renders a scrolling feed page rather than the modal (`useIsMobile` decides). Only a window of posts is mounted; it grows both ways, and prepending corrects `scrollTop` in a `useLayoutEffect` so content doesn't jump. Only the post crossing the viewport centre plays its video and drives the URL. Desktop keeps `PostModal`; both share `MediaCarousel`.
### Backend (`server.ts`)
diff --git a/package.json b/package.json
index cbc67de..6d81a34 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"clean": "rm -rf dist",
"lint": "tsc --noEmit",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "jd2": "tsx scripts/jd2-sync.ts"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",