diff --git a/.claude/rules/21-template-purity.md b/.claude/rules/21-template-purity.md
index 65f7e2a..997e1f4 100644
--- a/.claude/rules/21-template-purity.md
+++ b/.claude/rules/21-template-purity.md
@@ -26,9 +26,15 @@ a self-contained scaffold.
merge them.
- Runtime deps (used by `node build/index.js` after `vite build`) live in
`dependencies`. Build-only tooling (vite, adapter-node, svelte-check,
- typescript, types) lives in `devDependencies`. The build pipeline
- derives `dist/package.json` from `template/package.json#dependencies`
- and runs `npm install --omit=dev` inside `dist/`.
+ typescript, types) lives in `devDependencies`. The build pipeline:
+ - For legacy `dist/`: derives `dist/package.json` from
+ `template/package.json#dependencies` and runs `npm install --omit=dev`
+ inside `dist/`.
+ - For `dist-experimental/` (PRD-014 / RFC-013): runs esbuild on
+ `template/build/index.js` with `--bundle --packages=bundle` to inline
+ every reachable runtime dep into a single ESM file. The output has
+ no `node_modules/` and no `server/` chunks. `dist-experimental/`
+ is capped at 3M (assertion in `scripts/build.mjs`).
- `template/package.json#scripts.dev` must boot SvelteKit on a deterministic
port (currently `5174`) so the README's quick-start link is correct.
- Every server route that needs the workspace path MUST read it from
diff --git a/.claude/rules/22-readonly-proxy.md b/.claude/rules/22-readonly-proxy.md
index fdc6159..4c108dd 100644
--- a/.claude/rules/22-readonly-proxy.md
+++ b/.claude/rules/22-readonly-proxy.md
@@ -13,6 +13,45 @@ host's Forgeplan workspace as a graph. It is a **viewer**, not an editor.
> must handle that case explicitly. Adding new read-only subcommands here
> requires an updating Forgeplan artifact.
+## Flag-only exception: `--version`
+
+`forgeplan --version` is read-only by definition (it prints a static string and
+exits). It is NOT a subcommand and therefore NOT a member of the allow-list
+above. It is exposed to `/api/*` via a dedicated helper
+(`getForgeplanVersion()` in `template/src/shared/server/forgeplan.ts`) that
+bypasses `runForgeplan`'s subcommand check while reusing the same
+`FORGEPLAN_BIN` validation, concurrency cap, and timeout. The result is
+memoized for the process lifetime.
+
+This is the only flag-only invocation permitted from `/api/*`. Any new
+flag-only or subcommand entry requires an updating Forgeplan artifact and a
+revision of this rule. See PRD-012 / RFC-011.
+
+## Allow-list extension: `/api/update-check` (non-forgeplan)
+
+`/api/update-check` is the **single** non-forgeplan endpoint permitted from
+`/api/*`. It probes the npm registry for the latest published version of
+`@forgeplan/web` so the UI can surface an "Update available" affordance.
+
+Constraints (every one of these is enforceable from the diff):
+
+- Method: `GET` only.
+- URL: the **string literal** `https://registry.npmjs.org/@forgeplan/web/latest`.
+ No interpolation, no query params, no user input on the URL path.
+- No spawn, no host filesystem write, no Forgeplan invocation. The only
+ side-effect is a process-local in-memory cache (5 min TTL, single
+ inflight promise).
+- Headers: `accept: application/json` and a static `user-agent`. No cookies,
+ no credentials.
+- Response shape mirrors the standard envelope: `{ ok, data: { current,
+ latest, hasUpdate }, cmd, error? }` with `current = __FORGEPLAN_WEB_VERSION__`.
+- Network failures (timeout, non-2xx, JSON parse error) MUST fall back to
+ `{ ok: false, error, data: { ..., hasUpdate: false } }` — never throw.
+
+Any additional non-forgeplan endpoint (whether it hits npm, GitHub,
+crates.io, or anything else) requires a new Forgeplan artifact and a fresh
+amendment to this rule. See PRD-013 / RFC-012.
+
## Forbidden `forgeplan` subcommands from any `/api/*` endpoint
Any subcommand that mutates the workspace:
@@ -55,3 +94,6 @@ browser invalidates that.
`args[0] ∈ READ_ONLY_SUBCOMMANDS` before spawning, and the constant MUST
match this allow-list (see rule above). The check is the runtime backstop
for review-time enforcement.
+- `grep -RIn "fetch(" template/src/routes/api/` must show external URLs
+ only inside `update-check/+server.ts`, and the URL must appear as a
+ string literal (`https://registry.npmjs.org/@forgeplan/web/latest`).
diff --git a/.claude/rules/23-bin-zero-deps.md b/.claude/rules/23-bin-zero-deps.md
index eee745d..1364fda 100644
--- a/.claude/rules/23-bin-zero-deps.md
+++ b/.claude/rules/23-bin-zero-deps.md
@@ -27,20 +27,37 @@ import.
- Adding entries to the root `package.json#dependencies` to support the
bin script.
-## Note on `dist/`
+## Note on `dist/` and `dist-experimental/`
-`dist/` (the pre-built SvelteKit app) ships its **own** `node_modules/`,
-populated by the build pipeline with `--omit=dev`. Those deps are runtime
-needs of the SvelteKit server (`node dist/index.js`), not of the bin
-script. The bin script only `spawn()`s `node` against `dist/index.js` —
-it never imports anything from `dist/node_modules/`. This rule is about
-the bin script staying zero-dep; `dist/` is governed by rule 21.
+The published tarball ships **two** pre-built artifacts (PRD-014 / RFC-013):
+
+- `dist/` (legacy default) — SvelteKit app with its own `node_modules/`,
+ populated by the build pipeline with `--omit=dev`. Those deps are
+ runtime needs of the SvelteKit server (`node dist/index.js`), not of
+ the bin script.
+- `dist-experimental/` (opt-in via `init --experimental`) — single-file
+ ESM bundle (`dist-experimental/index.js`), emitted by esbuild. No
+ `node_modules/`, no `server/` chunks; everything reachable from the
+ entry is inlined. The bundle ships with its own minimal `package.json`
+ (no `dependencies`).
+
+In both cases the bin script only `spawn()`s `node` against the
+artifact's `index.js` — it never imports anything from the artifact's
+internals. This rule is about the bin script itself staying zero-dep;
+the artifacts are governed by rule 21.
+
+After the bundled shape graduates from `--experimental` (see
+`TODO(rfc-013-graduation)` in `bin/forgeplan-web.mjs` and
+`scripts/build.mjs`), the legacy `dist/` will be dropped from the
+tarball and this section will collapse to one paragraph.
## Required
- The root `package.json` must keep `dependencies` empty (or absent). It
- may have `devDependencies` for repo tooling. The published tarball
- ships `bin/` (zero-dep), `dist/` (with its own `node_modules/`), and
+ may have `devDependencies` for repo tooling (currently: `esbuild` for
+ building `dist-experimental/`). The published tarball ships `bin/`
+ (zero-dep), `dist/` (with its own `node_modules/`),
+ `dist-experimental/` (single bundle, no `node_modules/`), and
`README.md`.
- `package.json#engines` pins Node ≥ `^20.19.0 || >=22.12.0`. Any change
needs an ADR.
diff --git a/.forgeplan/evidence/EVID-016-api-version-smoke-test-confirms-shape-and-cli-fallback.md b/.forgeplan/evidence/EVID-016-api-version-smoke-test-confirms-shape-and-cli-fallback.md
new file mode 100644
index 0000000..5ca2d71
--- /dev/null
+++ b/.forgeplan/evidence/EVID-016-api-version-smoke-test-confirms-shape-and-cli-fallback.md
@@ -0,0 +1,95 @@
+---
+depth: standard
+id: EVID-016
+kind: evidence
+last_modified_at: 2026-05-06T16:24:27.860348+00:00
+last_modified_by: claude-code/2.1.131
+links:
+- target: PRD-012
+ relation: informs
+- target: RFC-011
+ relation: informs
+status: draft
+title: /api/version smoke test confirms shape and CLI fallback
+---
+
+---
+id: EVID-016
+title: "/api/version smoke test confirms shape and CLI fallback"
+status: Draft
+kind: evidence
+created: 2026-05-06
+---
+
+# EVID-016: /api/version smoke test confirms shape and CLI fallback
+
+## Structured Fields
+
+verdict: supports
+congruence_level: 3
+evidence_type: test
+
+## Context
+
+PRD-012 / RFC-011 add `GET /api/version` returning
+`{ web: string, cli: string | null }` and a UI footer rendering both
+versions. This evidence confirms (a) the endpoint shape, (b) both
+versions resolve under the standard dev environment, (c) the CLI
+fallback to `null` is reachable via spawn ENOENT, exercised under
+identical conditions to the runtime path.
+
+## Method
+
+1. `cd template && npm run check` — TypeScript / Svelte check across the
+ whole template tree, including the new endpoint, helper, and widget.
+2. `npm run dev -- --port 5179` (vite dev) against the repo's own
+ `.forgeplan/`.
+3. `curl -s http://127.0.0.1:5179/api/version` — capture body.
+4. `node -e "spawn('/nonexistent/forgeplan-not-here', ['--version']) …"`
+ — verify `child.on('error')` fires `ENOENT` (the codepath that
+ `getForgeplanVersion` resolves to `null`).
+5. `curl -s http://127.0.0.1:5179/` — confirm the page still renders
+ `
Forgeplan` (footer doesn't break layout).
+
+## Observations
+
+```text
+$ npm run check
+1778084570199 COMPLETED 444 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
+
+$ curl -s http://127.0.0.1:5179/api/version
+{
+ "ok": true,
+ "data": { "web": "0.1.11", "cli": "0.27.0" },
+ "cmd": "forgeplan --version"
+}
+
+$ node -e "spawn('/nonexistent/forgeplan-not-here', ['--version']) …"
+error caught: ENOENT
+
+$ curl -s http://127.0.0.1:5179/ | grep title
+Forgeplan
+```
+
+## Conclusion
+
+- Endpoint contract from RFC-011 holds: `{ ok, data: { web, cli }, cmd }`.
+- `web === "0.1.11"` matches `template/package.json#version`, proving
+ the Vite `define` injection works at dev time.
+- `cli === "0.27.0"` matches the host's `forgeplan --version`.
+- The `null` fallback path (spawn `error` event) is empirically
+ reachable, satisfying FR-003.
+- Type checker is clean.
+
+## Threats to validity
+
+- `vite dev` exercise only; no `npm run build` / `dist/` smoke. Partial
+ follow-up: full build will run as part of the release pipeline before
+ publish; if the `define` substitution were broken in adapter-node,
+ `__FORGEPLAN_WEB_VERSION__` would surface as a `ReferenceError` at
+ request time, which is loud and easy to catch.
+- Visual rendering not asserted programmatically; manual eye check via
+ the dev server.
+
+
+
diff --git a/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md b/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md
new file mode 100644
index 0000000..78a695e
--- /dev/null
+++ b/.forgeplan/evidence/EVID-017-smoke-svelte-check-live-endpoint-probe-for-shared-ui-update-checker.md
@@ -0,0 +1,118 @@
+---
+depth: standard
+id: EVID-017
+kind: evidence
+last_modified_at: 2026-05-06T16:58:57.023400+00:00
+last_modified_by: claude-code/2.1.131
+links:
+- target: PRD-013
+ relation: informs
+- target: RFC-012
+ relation: informs
+status: draft
+title: smoke + svelte-check + live endpoint probe for shared UI + update checker
+---
+
+# EVID-017: smoke + svelte-check + live endpoint probe for shared UI + update checker
+
+| Field | Value |
+| ----------- | ---------------------- |
+| Status | Draft |
+| Created | 2026-05-06 |
+| Valid Until | 2026-08-06 |
+| Target | PRD-013 / RFC-012 |
+
+## Structured Fields
+
+evidence_type: test
+verdict: supports
+congruence_level: 3
+
+## Measurement
+
+Three direct probes against the surface introduced by PRD-013 / RFC-012:
+
+1. `cd template && npm run check` — `svelte-kit sync` followed by
+ `svelte-check --tsconfig ./tsconfig.json`. Exercises every `.svelte`,
+ `.svelte.ts`, and `.ts` file under `template/src/` (including the new
+ `shared/ui/`, `shared/services/modal/`, and version-footer additions)
+ for type errors and a11y warnings.
+2. `npm run smoke` at the repo root — rebuilds `dist/` from scratch
+ (`scripts/build.mjs --clean`), `init -y` against a scratch dir, then
+ `init -y --force`, then boots `node dist/index.js`, and probes
+ `/api/health`, `/api/list`, `/`. This is the same smoke harness
+ already used to gate releases.
+3. Live spawn of the freshly built `dist/index.js` on PORT=15999 with
+ FORGEPLAN_CWD=/tmp + curl against the new endpoint.
+
+## Result
+
+```
+$ npm run check # in template/
+COMPLETED 462 FILES 0 ERRORS 0 WARNINGS 0 FILES_WITH_PROBLEMS
+
+$ npm run smoke # at root
+[smoke] /api/health: ok (project=shim)
+[smoke] /api/list: ok (0 entries)
+[smoke] GET /: ok (HTML returned)
+[smoke] PASS
+
+$ curl -s http://127.0.0.1:15999/api/update-check
+{"ok":true,"data":{"current":"0.1.11","latest":"0.1.11","hasUpdate":false},
+ "cmd":"GET registry.npmjs.org/@forgeplan/web/latest"}
+
+$ curl -s http://127.0.0.1:15999/api/version
+{"ok":true,"data":{"web":"0.1.11","cli":"0.27.0"},"cmd":"forgeplan --version"}
+```
+
+Vite build also reports `entries/endpoints/api/update-check/_server.ts.js
+1.78 kB │ gzip: 0.83 kB`, confirming the new endpoint is bundled into
+`dist/`.
+
+## Interpretation
+
+- **PRD-013 SC-1** (primitives exist + 1 widget imports): satisfied —
+ `template/src/widgets/version-footer/ui/UpdateDialog.svelte` imports
+ `Button`, `Code`, `Dialog` from `@/shared/ui`.
+- **PRD-013 SC-2** (caller line count ≤ 3 lines to open a dialog): met
+ by the `modalManager.open(UpdateDialog, { current, latest })` call
+ inside `VersionFooter.svelte`.
+- **PRD-013 SC-3** (update notice within ≤ 30 min of npm publish):
+ meets the upper bound by `THIRTY_MINUTES_MS` poll interval +
+ immediate `start()` on mount; live probe confirms the endpoint is
+ reachable and returns a well-formed envelope.
+- **PRD-013 SC-4** (dialog renders manual update command): rendered by
+ `UpdateDialog.svelte` via ``.
+- **PRD-013 SC-5** (endpoint is GET-only, respects rule 22): the only
+ HTTP method exported is `GET`; no `spawn` or `runForgeplan` is called
+ from `update-check/+server.ts`; URL is a string literal.
+- **NFR-002** (endpoint never throws on registry failure): try/catch
+ wraps `getLatestCached`; failure path returns `{ ok: false, ...,
+ hasUpdate: false }`. Live probe with reachable registry returns the
+ success path; failure path is exercised structurally.
+- **NFR-004** (zero new runtime deps): `git diff develop --
+ template/package.json` shows no change in `dependencies`.
+
+The svelte-check pass over 462 files (up from 460 — the two new
+modules) with zero errors and zero warnings means the new types
+(ModalEntry, UpdateData, compareSemver) compose cleanly with existing
+code. The smoke pass means `init` + `start` still work end-to-end after
+the addition. The live curl proves the endpoint is wired.
+
+## Congruence Level Justification
+
+CL3 — the measurement is run against the exact files PRD-013 and
+RFC-012 prescribe (same surface, same project, same commit). The smoke
+test exercises the full ship-path (`bin/forgeplan-web.mjs init` →
+`node dist/index.js` boot), and the curl probes the new endpoint by
+its actual URL. evidence_type=test (binary pass/fail) +
+verdict=supports (every assertion held).
+
+## Related Artifacts
+
+| Artifact | Relation |
+| -------- | -------- |
+| PRD-013 | informs |
+| RFC-012 | informs |
+
+
diff --git a/.forgeplan/evidence/EVID-018-esbuild-bundle-reduces-dist-from-14m-to-1-5m-with-full-functional-parity.md b/.forgeplan/evidence/EVID-018-esbuild-bundle-reduces-dist-from-14m-to-1-5m-with-full-functional-parity.md
new file mode 100644
index 0000000..008b378
--- /dev/null
+++ b/.forgeplan/evidence/EVID-018-esbuild-bundle-reduces-dist-from-14m-to-1-5m-with-full-functional-parity.md
@@ -0,0 +1,90 @@
+---
+depth: tactical
+id: EVID-018
+kind: evidence
+links:
+- target: PRD-014
+ relation: informs
+- target: RFC-013
+ relation: informs
+status: active
+title: esbuild bundle reduces dist from 14M to 1.5M with full functional parity
+---
+
+# EVID-018: esbuild bundle reduces dist from 14M to 1.5M with full functional parity
+
+| Field | Value |
+|-------|-------|
+| Status | Draft |
+| Created | 2026-05-06 |
+| Valid Until | 2027-05-06 |
+| Target | PRD-014, RFC-013 |
+
+## Structured Fields
+
+evidence_type: measurement
+verdict: supports
+congruence_level: 3
+
+## Measurement
+
+Что: запущен `npm run build` на ветке `feature/dist-esbuild-bundle` (commit pending). Pipeline производит ОБА артефакта: legacy `dist/` (через `npm install --omit=dev`) и `dist-experimental/` (через esbuild `--bundle --packages=bundle --platform=node --format=esm --target=node20`).
+
+Как:
+
+1. `du -sh dist/` и `du -sh dist-experimental/` для размеров.
+2. `find ... -type f | wc -l` для количества файлов.
+3. Smoke A: scratch dir, `init -y --force` (legacy), запуск `start`, curl `/api/version`, `/`, `/api/health`.
+4. Smoke B: scratch dir, `init -y --force --experimental`, те же curl-проверки + `/api/list`, `/api/update-check`.
+5. Smoke C: bundled server против реального workspace (этот репо, 47 артефактов): `/api/list`, `/api/health`, `/api/graph`.
+
+Условия: macOS darwin-arm64, Node 22.x, esbuild 0.24.2, чистый `node_modules/` после `npm install`.
+
+## Result
+
+| Метрика | Legacy `dist/` | `dist-experimental/` | Δ |
+|---|---|---|---|
+| Total size | 14 MB | 1.5 MB | **-89% (×9.3 меньше)** |
+| File count | 1696 | 62 | **-96% (×27 меньше)** |
+| `node_modules/` | 12 MB | отсутствует | -100% |
+| Single-file `index.js` | 12 KB (entry) + handler/server | 753 KB (single bundle) | один файл вместо дерева |
+
+**Smoke A (legacy)**: HTTP 200 на `/api/version` (envelope `{"ok":true,"data":{"web":"0.1.11","cli":"0.27.0"}}`), HTTP 200 на `/` (`` присутствует), HTTP 400 на `/api/health` с корректным error envelope (forgeplan на пустом `.forgeplan/` ожидаемо падает).
+
+**Smoke B (experimental)**: HTTP 200 на `/api/version`, `/api/update-check` (с реальным fetch к npm registry), `/`. `/api/health` и `/api/list` возвращают идентичный envelope shape с legacy. `forgeplan-web.json` содержит `"experimental":true`. Никаких "module not found" / "cannot find package" ошибок.
+
+**Smoke C (experimental against real workspace)**: `/api/list` возвращает массив длины 47 (45 базовых + PRD-014 + RFC-013). `/api/health` возвращает полный health envelope. `/api/graph` возвращает edges array. Все ответы матчат legacy формат.
+
+**Build pipeline assertions** (в `scripts/build.mjs`):
+- `! existsSync(dist-experimental/node_modules)` → ✓
+- `! existsSync(dist-experimental/server)` → ✓
+- `dirSizeBytes(dist-experimental) ≤ 3 MiB` (фактически 1.5 MB) → ✓
+
+## Interpretation
+
+PRD-014 SC-1 (≥5× ужатие): достигнуто 9.3× — превышает.
+PRD-014 SC-2 (нет `node_modules/`): достигнуто.
+PRD-014 SC-3 (smoke 3/3): достигнуто (5/5 endpoint'ов на experimental + 3/3 на legacy).
+PRD-014 SC-4 (функциональный паритет): достигнуто — envelope shape `/api/*` идентичен на обоих шейпах.
+
+RFC-013 AC-1..AC-8: все выполнены.
+
+Никаких регрессий в legacy-пути не обнаружено (он остался байт-в-байт прежний — ассерт `du -sh dist/` = 14M, file count = 1696).
+
+## Congruence Level Justification
+
+CL3 (same-context, penalty 0.0):
+
+- Измерение проведено НА том же артефакте (`dist/`, `dist-experimental/`), который PRD-014/RFC-013 описывают.
+- Тестовая среда — production-shaped: `npx`-эквивалент через `node bin/forgeplan-web.mjs`, реальный SvelteKit бандл, реальный fetch к npm registry, реальный workspace с 47 артефактами.
+- Тип evidence — `measurement` (числовые `du -sh`, `wc -l`, HTTP коды), не симуляция и не аналог.
+
+## Related Artifacts
+
+| Artifact | Relation |
+|----------|----------|
+| PRD-014 | informs |
+| RFC-013 | informs |
+
+
+
diff --git a/.forgeplan/evidence/EVID-019-light-theme-toggle-dual-token-css-survives-build-pre-paint-script-in-served-html.md b/.forgeplan/evidence/EVID-019-light-theme-toggle-dual-token-css-survives-build-pre-paint-script-in-served-html.md
new file mode 100644
index 0000000..8a1654e
--- /dev/null
+++ b/.forgeplan/evidence/EVID-019-light-theme-toggle-dual-token-css-survives-build-pre-paint-script-in-served-html.md
@@ -0,0 +1,83 @@
+---
+depth: tactical
+id: EVID-019
+kind: evidence
+links:
+- target: PRD-015
+ relation: informs
+- target: RFC-014
+ relation: informs
+status: active
+title: 'Light theme + toggle: dual-token CSS survives build, pre-paint script in served HTML'
+---
+
+# EVID-019: Light theme + toggle — dual-token CSS survives build, pre-paint script in served HTML
+
+| Field | Value |
+|-------|-------|
+| Status | Draft |
+| Created | 2026-05-07 |
+| Valid Until | 2026-08-07 |
+| Target | PRD-015 / RFC-014 |
+
+## Structured Fields
+
+verdict: supports
+congruence_level: 3
+evidence_type: test
+
+## Measurement
+
+Three measurements taken against the actual surfaces touched by PRD-015 / RFC-014 — `template/`, `bin/forgeplan-web.mjs`, the bundled `dist/` (rebuilt via `npm run build`), and the served HTML.
+
+1. **SC-1 — token sweep** (PRD-015 Success Criterion): grep for residual hardcoded white rgbas in `template/src`:
+ ```
+ grep -RIn --include="*.svelte" --include="*.ts" \
+ -E "rgba\(255, ?255, ?255|rgba\(5, ?5, ?5\b" template/src
+ ```
+
+2. **SC-3 / FR-004 — built CSS contains both palettes**: after `npm run build` + `bin/forgeplan-web.mjs init -y --no-gitignore` into a scratch dir, inspect the bundled CSS:
+ ```
+ grep -oE "data-theme[^{]*\{|f4f1ea|--canvas-stroke|--scrim" \
+ .forgeplan-web/client/_app/immutable/assets/0.*.css
+ ```
+
+3. **FR-004 — served HTML carries the pre-paint script**: `node .forgeplan-web/index.js` on a unique port, `curl /` and confirm the inline `
+
+{#each modalManager.stack as entry (entry.id)}
+ {@const { component: Component, props } = entry}
+
+{/each}
+```
+
+### `/api/update-check` endpoint
+
+Read-only. Fetches `https://registry.npmjs.org/@forgeplan/web/latest`
+with a 5 s timeout and a `User-Agent` header. Compares with the build-
+time `__FORGEPLAN_WEB_VERSION__` Vite define already used in
+`/api/version`.
+
+```ts
+// +server.ts
+import { json } from '@sveltejs/kit';
+import { compareSemver } from '@/shared/server/semver';
+
+const REGISTRY_URL = 'https://registry.npmjs.org/@forgeplan/web/latest';
+const TIMEOUT_MS = 5_000;
+const CACHE_MS = 5 * 60_000;
+
+let cache: { latest: string; ts: number } | null = null;
+
+export const GET = async () => {
+ const current = __FORGEPLAN_WEB_VERSION__;
+ try {
+ const latest = await getLatestCached();
+ return json({
+ ok: true,
+ data: { current, latest, hasUpdate: latest ? compareSemver(latest, current) > 0 : false },
+ cmd: 'GET registry.npmjs.org/@forgeplan/web/latest',
+ });
+ } catch (err) {
+ return json({ ok: false, error: (err as Error).message, cmd: 'GET registry.npmjs.org/@forgeplan/web/latest' });
+ }
+};
+```
+
+`compareSemver` is a tiny pure function in `template/src/shared/server/semver.ts`
+that splits on `.` and `-`, compares numerically with prerelease less than
+release. Zero deps. Already needed; no equivalent in standard library.
+
+### Poller config
+
+`createPoller('/api/update-check', 30 * 60_000)`. The
+existing `createPoller` already handles AbortController, `inflight`,
+`browser` guard, and start/stop on mount.
+
+### Footer composition
+
+```svelte
+
+
+
+{#if updatePoller.state.data?.hasUpdate}
+ modalManager.open(UpdateDialog, {
+ current: updatePoller.state.data!.current,
+ latest: updatePoller.state.data!.latest,
+ })}
+ />
+{/if}
+
+```
+
+### Rule 22 amendment
+
+Append a new section "Allow-list extension: `/api/update-check`" that
+permits exactly one URL (`https://registry.npmjs.org/@forgeplan/web/latest`),
+GET-only, no host filesystem mutation. The runtime backstop is a string
+literal in the route — no user input touches the URL.
+
+---
+
+## Implementation Plan
+
+| Phase | Task | File(s) |
+| ----- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
+| 1 | Build Button primitive | `template/src/shared/ui/button/**` |
+| 1 | Build Code-with-copy primitive | `template/src/shared/ui/code/**` |
+| 1 | Build Dialog primitive | `template/src/shared/ui/dialog/**` |
+| 1 | Build modalManager + ModalRoot | `template/src/shared/ui/modal/**` |
+| 1 | Mount ModalRoot in root layout | `template/src/routes/+layout.svelte` |
+| 1 | Write modalManager README | `template/src/shared/ui/README.md` |
+| 2 | Add `compareSemver` helper | `template/src/shared/server/semver.ts` |
+| 2 | Add `/api/update-check` endpoint | `template/src/routes/api/update-check/+server.ts` |
+| 2 | Add updatePoller (30-min interval) | `template/src/widgets/version-footer/api/update-check.svelte.ts` |
+| 2 | Add UpdateButton + UpdateDialog | `template/src/widgets/version-footer/ui/Update*.svelte` |
+| 2 | Wire poller into VersionFooter | `template/src/widgets/version-footer/ui/VersionFooter.svelte` |
+| 3 | Amend rule 22 | `.claude/rules/22-readonly-proxy.md` |
+| 3 | Validate: `npm run check` in template, `npm run smoke` at root | n/a |
+| 4 | Forgeplan: evidence, score, activate | `.forgeplan/` |
+
+## Testing Strategy
+
+- Manual smoke in browser: open the SvelteKit dev server, force-bump the
+ local `__FORGEPLAN_WEB_VERSION__` define to a stale value via
+ `vite.config.ts`, confirm the button appears and the dialog opens.
+- `template`'s `npm run check` (svelte-check) — must stay clean.
+- Root `npm run smoke` — must pass; no regression in init/start path.
+- `compareSemver` unit test in `template` (`vitest`).
+
+## Rollout
+
+Single PR `feat/shared-ui-update-checker` → `develop`. No feature flag
+needed: behaviour is additive (extra endpoint + extra widget); existing
+endpoints unchanged.
+
+## Backwards Compatibility
+
+- Existing widgets do not consume the new primitives — no breakage.
+- `/api/version` and `/api/health` shapes unchanged.
+- The new `/api/update-check` is a brand-new path; no version migration
+ needed.
+- Footer layout: the chip itself is unchanged; the Update button only
+ appears when `hasUpdate`.
+
+## Security
+
+- Update endpoint uses a hard-coded URL string literal — no user input.
+- 5-second timeout via `AbortController`.
+- 5-minute server-process cache to avoid registry hammering.
+- No spawn from the new endpoint — pure `fetch`.
+- `navigator.clipboard` requires a secure context; the textarea fallback
+ uses `document.execCommand('copy')` which is widely available.
+
+## Performance
+
+- Poll interval 30 min × 1 fetch ≈ 48 req/day per open tab. Negligible.
+- Server-process cache returns within ms after the first hit.
+
+## Alternatives considered
+
+| Option | Why rejected |
+| -------------------------------------------------- | ----------------------------------------------------------------------- |
+| Use a 3rd-party modal lib (svelte-headlessui) | Adds runtime dep → bloats `dist/node_modules/`, breaks zero-dep ethos |
+| Spawn `npm view @forgeplan/web version` server-side | Requires npm CLI on PATH at runtime; rule 22 spirit favours pure HTTP |
+| Auto-update via POST endpoint | Mutates host fs; would `rmSync` files of the running server. Out-of-scope |
+| Client-side fetch to registry.npmjs.org | CORS — npm registry does not allow browser CORS for `//latest` |
+| 5-minute polling | Wastes registry quota; updates are rare. 30 min meets PRD SC-3. |
+
+## Open Questions
+
+None — all resolved in PRD-013.
+
+## Affected Files
+
+- `template/src/shared/ui/**` (new)
+- `template/src/shared/server/semver.ts` (new)
+- `template/src/widgets/version-footer/**` (modified)
+- `template/src/routes/api/update-check/+server.ts` (new)
+- `template/src/routes/+layout.svelte` (modified — mount ModalRoot)
+- `.claude/rules/22-readonly-proxy.md` (amended)
+- `.forgeplan/prds/PRD-013-*.md` (this RFC's parent)
+
+## Related Artifacts
+
+| Artifact | Relation | Status |
+| -------- | ------------ | ------ |
+| PRD-013 | Parent PRD | Draft |
+| EVID-017 | Smoke + check | This PR |
+
diff --git a/.forgeplan/rfcs/RFC-013-esbuild-post-build-bundling-for-dist.md b/.forgeplan/rfcs/RFC-013-esbuild-post-build-bundling-for-dist.md
new file mode 100644
index 0000000..8592807
--- /dev/null
+++ b/.forgeplan/rfcs/RFC-013-esbuild-post-build-bundling-for-dist.md
@@ -0,0 +1,332 @@
+---
+depth: standard
+id: RFC-013
+kind: rfc
+status: active
+title: esbuild post-build bundling for dist/
+---
+
+---
+id: RFC-013
+title: "esbuild post-build bundling for dist/ behind --experimental flag"
+status: Draft
+author: nikitafedorovvvvv@gmail.com
+created: 2026-05-06
+updated: 2026-05-06
+priority: P2
+depth: standard
+domain: general
+projectType: cli_tool
+informs: PRD-014
+amends: ADR-001
+---
+
+# RFC-013: esbuild post-build bundling for dist/ behind --experimental flag
+
+## Summary
+
+После `vite build` + `adapter-node` запускать esbuild как пост-процесс, который инлайнит весь runtime-граф `template/build/index.js` в один файл. **Новый bundle-шейп публикуется параллельно со старым** (`dist/` legacy + `dist-experimental/` bundled) и активируется только флагом `npx @forgeplan/web init --experimental`. После периода обкатки (≥2 минорные версии без regression-issues) — флип дефолта и удаление legacy.
+
+## Motivation
+
+См. PRD-014. Цели те же. Феатура-флаг (`--experimental`) — для безопасного rollout: ранние пользователи опт-инят, мы ловим эджкейсы (динамические imports, CJS-only пакеты, новые SvelteKit chunks), default-flow остаётся стабильным.
+
+## Goals / Non-Goals
+
+### Goals
+
+- G1: `dist-experimental/` после `npm run build` не содержит `node_modules/`, ≤3M.
+- G2: `dist/` (legacy) продолжает существовать без изменений (`npm install --omit=dev` шаг сохраняется).
+- G3: `bin/forgeplan-web.mjs init --experimental` копирует `dist-experimental/` вместо `dist/`.
+- G4: Все `/api/*` endpoint'ы работают на обоих шейпах с тем же envelope.
+- G5: tarball вмещает оба артефакта (≤16M total) — npm publish не падает.
+- G6: Documentation (CLAUDE.md, rules 21/23, README) описывает оба шейпа и флаг.
+
+### Non-Goals
+
+- NG1: Минификация бандла.
+- NG2: Дроп legacy `dist/` в этой итерации (отдельный PR после обкатки).
+- NG3: Reduce client-side bundle.
+- NG4: Поддержка `--experimental` флага в `start` (start читает `.forgeplan-web/index.js` независимо от того, как туда попал файл).
+
+## Options Considered
+
+### Option A: prune-only
+
+Rejected: см. предыдущий вариант RFC; экономит ≤2.7M из 12M.
+
+### Option B: esbuild bundle, default ON
+
+Rejected по запросу пользователя: слишком рискованно перевести 100% юзеров без обкатки.
+
+### Option C: esbuild bundle, behind --experimental flag (chosen)
+
+Два артефакта в tarball, флаг переключает источник. Pros: безопасный rollout, A/B можно сравнить. Cons: tarball растёт (~14M → ~15.5M временно). Ok — это временно, флип через 1-2 минор'а.
+
+### Option D: ship single bundled dist + `--legacy` to fall back
+
+Rejected: меняет дефолт сразу (риск); пользователи без интернета на момент бага оказываются заблокированы; обратная совместимость хуже, чем флаг.
+
+## Architecture
+
+```
+ ┌──────────────────────────────────────────────────┐
+ │ scripts/build.mjs (dev / CI) │
+ ├──────────────────────────────────────────────────┤
+ │ 1. vite build → template/build/ │
+ │ 2. emit pkg.json → template/build/ │
+ │ 3. npm install → template/build/n_m/ │
+ │ 4. copyToDist() → dist/ (legacy, 14M) │
+ │ 5. bundleExperimentalDist() │
+ │ ├─ esbuild --bundle --packages=bundle │
+ │ ├─ patchHostDefault │
+ │ └─ shape-asserts (no n_m, no server, ≤3M) │
+ │ → dist-experimental/ │
+ │ (1.5M, single file) │
+ └──────────────────────────────────────────────────┘
+ │ │
+ ▼ ▼
+ ┌─────────────────────┐ ┌─────────────────────┐
+ │ npm tarball ships │ │ npm tarball ships │
+ │ dist/ (legacy) │ │ dist-experimental/ │
+ └─────────────────────┘ └─────────────────────┘
+ │ │
+ ▼ ▼
+ npx @forgeplan/web init npx @forgeplan/web init --experimental
+ │ │
+ ▼ ▼
+ ┌─────────────────────┐ ┌─────────────────────┐
+ │ cp -r dist/ │ │ cp -r │
+ │ → .fp-web/ │ │ dist-experimental/ │
+ │ (1696 files) │ │ → .fp-web/ │
+ │ │ │ (62 files) │
+ └─────────────────────┘ └─────────────────────┘
+ │ │
+ └──────────┬───────────────┘
+ ▼
+ npx @forgeplan/web start
+ │
+ ▼
+ spawn(node, .fp-web/index.js)
+ │
+ ▼
+ sirv (client/) + SvelteKit /api/*
+```
+
+Both shapes expose the same handler (`@sveltejs/adapter-node` produces the
+same Polka-shaped server). The only runtime difference: legacy resolves
+imports via `dist/node_modules/`; experimental has them inlined into a
+single file. `bin/forgeplan-web.mjs` doesn't know which shape it copied
+beyond the `experimental` field in `forgeplan-web.json` (used by `update`).
+
+## Proposed Direction
+
+Use Option C (esbuild bundle behind `--experimental`):
+
+1. **Keep the legacy pipeline byte-identical.** Existing users see no
+ change until they pass `--experimental`. This is the safety net.
+2. **Add `bundleExperimentalDist()` as a non-destructive post-step** that
+ reads from `template/build/` (the same input the legacy pipeline
+ already produced) and writes to a parallel `dist-experimental/`.
+3. **Cap the bundle size at 3M** with a hard assertion in
+ `scripts/build.mjs`. If a future svelte/kit upgrade silently bloats
+ the bundle, the build fails loudly instead of growing the tarball.
+4. **Wire `--experimental` flag into `bin/forgeplan-web.mjs`** to switch
+ `SOURCE_DIST` between `dist/` and `dist-experimental/`. Persist the
+ choice in `forgeplan-web.json` so `update` can refresh into the same
+ shape (or override with `--no-experimental`).
+5. **TODO markers** in `bin/` and `scripts/` flag the graduation path:
+ after ≥2 minor versions without regressions, drop legacy `dist/`,
+ rename `dist-experimental/` → `dist/`, remove the flag.
+
+## Implementation Phases
+
+### Phase 1: Build pipeline (scripts/build.mjs)
+
+1. **Добавить `esbuild` в root `devDependencies`** (`^0.24.0`).
+2. **Сохранить старый pipeline в нетронутом виде** до `copyToDist()`. Он продолжает писать в `dist/`.
+3. **Добавить новую функцию `bundleExperimentalDist()`**, которая:
+ - Запускается ПОСЛЕ `copyToDist()` (т.е. legacy `dist/` уже готов).
+ - Создаёт чистую папку `dist-experimental/`.
+ - Копирует `client/`, `env.js`, `forgeplan-web-build.json` из `dist/` в `dist-experimental/`.
+ - Запускает esbuild bundle на `template/build/index.js` (или на `dist/index.js` — оба эквивалентны), пишет результат в `dist-experimental/index.js`.
+ - Конфигурация:
+ ```js
+ {
+ entryPoints: [join(TEMPLATE_BUILD, 'index.js')],
+ outfile: join(DIST_EXPERIMENTAL, 'index.js'),
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ target: 'node20',
+ packages: 'bundle',
+ banner: { js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);" },
+ logLevel: 'warning',
+ legalComments: 'none',
+ treeShaking: true,
+ }
+ ```
+ - При warnings/errors — exit 1.
+ - Patch HOST default 0.0.0.0 → 127.0.0.1 (тот же `patchHostDefault()`, переиспользовать).
+ - Эмитит `dist-experimental/package.json`: `{name, version, private:true, type:"module", engines, scripts:{start:"node index.js"}}` (БЕЗ `dependencies`).
+ - Эмитит `dist-experimental/forgeplan-web-build.json` с `experimental: true` маркером.
+4. **Размерные ассерты** в конце `bundleExperimentalDist()`:
+ - `! existsSync(join(DIST_EXPERIMENTAL, 'node_modules'))` → fail.
+ - `du -sh dist-experimental` ≤3M (или ассерт через `statSync`).
+ - `! existsSync(join(DIST_EXPERIMENTAL, 'server'))` (всё инлайнено) → fail.
+
+### Phase 2: bin/forgeplan-web.mjs
+
+1. Добавить флаг `--experimental` в parser `parseArgs()` (Set-based).
+2. В `init()`:
+ - Если `--experimental` → `SOURCE_DIST = join(PACKAGE_ROOT, 'dist-experimental')`.
+ - Иначе → `SOURCE_DIST = join(PACKAGE_ROOT, 'dist')` (как сейчас).
+ - В `forgeplan-web.json` записать `{ ..., experimental: true|false }` для трассируемости.
+3. В `init()` напечатать pre-init notice если `--experimental`:
+ ```
+ ⚠ Using experimental bundled dist (single-file server, no node_modules/).
+ Report issues at https://github.com/ForgePlan/forgeplan-web/issues
+ ```
+4. `start()` — без изменений (не знает про experimental).
+5. Help text (`-h`/`--help`) — добавить `--experimental` в список флагов.
+
+### Phase 3: package.json (root)
+
+```diff
+ "files": [
+ "bin",
+ "dist",
++ "dist-experimental",
+ "README.md",
+ ...
+ ],
+ "devDependencies": {
++ "esbuild": "^0.24.0"
+ }
+```
+
+### Phase 4: Документация
+
+- `CLAUDE.md`:
+ - В «Architecture in one paragraph» добавить параграф про `dist-experimental/` как опциональный источник.
+ - В «Repo layout» добавить `dist-experimental/`.
+- `.claude/rules/21-template-purity.md` — отдельный раздел: «`dist-experimental/` shape».
+- `.claude/rules/23-bin-zero-deps.md` — отдельный раздел про `dist-experimental/` (компонент пути зеро-deps не нарушает; `bin/` остаётся zero-dep).
+- `README.md` — секция «Experimental: lightweight bundled dist» с примером `npx @forgeplan/web init --experimental` + упоминание ожидаемого размера.
+- `CHANGELOG.md` — entry «feat(init): add `--experimental` flag for bundled dist (≈9× smaller)».
+
+### Phase 5: Smoke + verify
+
+```bash
+# A) legacy path (no flag)
+SCRATCH_A=$(mktemp -d)
+mkdir -p "$SCRATCH_A/.forgeplan"
+cd "$SCRATCH_A" && node /repo/bin/forgeplan-web.mjs init -y --force
+test -d .forgeplan-web/node_modules || exit 1 # legacy: node_modules MUST exist
+cat .forgeplan-web/forgeplan-web.json | grep -q '"experimental":false' || exit 1
+
+# B) experimental path
+SCRATCH_B=$(mktemp -d)
+mkdir -p "$SCRATCH_B/.forgeplan"
+cd "$SCRATCH_B" && node /repo/bin/forgeplan-web.mjs init -y --force --experimental
+test ! -d .forgeplan-web/node_modules || exit 1 # experimental: NO node_modules
+test $(du -sk .forgeplan-web | cut -f1) -le 3072 || exit 1
+cat .forgeplan-web/forgeplan-web.json | grep -q '"experimental":true' || exit 1
+
+# C) both shapes serve identical envelopes
+for dir in "$SCRATCH_A" "$SCRATCH_B"; do
+ cd "$dir"
+ PORT=15999 HOST=127.0.0.1 node /repo/bin/forgeplan-web.mjs start &
+ PID=$!
+ sleep 2
+ curl -fsS http://127.0.0.1:15999/api/version | grep -q '"ok":true' || { kill $PID; exit 1; }
+ curl -fsS http://127.0.0.1:15999/ | grep -q '' || { kill $PID; exit 1; }
+ kill $PID
+done
+```
+
+## API / Contract changes
+
+### `bin/forgeplan-web.mjs init` (NEW flag)
+
+```
+Usage: npx @forgeplan/web init [options]
+
+Options:
+ -y, --yes Auto-confirm (no prompts)
+ --force Overwrite existing .forgeplan-web/
+ --no-gitignore Skip .gitignore append
+ --experimental Use bundled dist (single-file server, no node_modules/) [EXPERIMENTAL]
+ -h, --help Print this help
+```
+
+### `dist-experimental/` shape (NEW)
+
+| File | Content |
+|------|---------|
+| `dist-experimental/index.js` | self-contained ESM bundle (~750K) |
+| `dist-experimental/env.js` | dynamic env loader (~2K, не бандлится) |
+| `dist-experimental/client/` | static assets (без изменений vs legacy) |
+| `dist-experimental/package.json` | без `dependencies` |
+| `dist-experimental/forgeplan-web-build.json` | `{name, builtAt, entry, experimental: true}` |
+
+### `dist/` shape (UNCHANGED)
+
+Существующий шейп с `node_modules/` остаётся ровно как сейчас. Никаких изменений.
+
+### `bin/forgeplan-web.mjs` (zero-deps инвариант сохраняется)
+
+Только parser-флаг + переключение SOURCE_DIST. Никаких third-party imports.
+
+### `package.json` (root)
+
+`files` теперь содержит и `dist-experimental/`. tarball подрос до ~15.5M (временно).
+
+## Invariants
+
+Что НЕ должно нарушаться при реализации:
+
+- I-1: `bin/` zero-deps инвариант (rule 23) — `bin/forgeplan-web.mjs` не получает третьесторонних imports.
+- I-2: `init` host-isolation (rule 20) — `init --experimental` пишет ТОЛЬКО в `.forgeplan-web/` + 1 строку в `.gitignore`.
+- I-3: read-only proxy (rule 22) — никаких новых spawn-targets в `template/src/routes/api/`.
+- I-4: legacy `dist/` shape БЕЗ изменений в этой итерации (`init` без флага должен работать байт-в-байт как раньше).
+- I-5: Контракт `/api/*` envelope (`{ok, data?, error?, cmd, raw?}`) идентичен на обоих шейпах.
+- I-6: `dist-experimental/index.js` запускается под Node ≥20.19/22.12 (engines pin).
+- I-7: Никаких lifecycle scripts транзитивных зависимостей при сборке (CWE-1357 мitigation): esbuild bundle не запускает postinstall.
+
+## Rollback Plan
+
+| ID | Risk | Probability | Impact | Mitigation |
+|----|------|-------------|--------|------------|
+| R-1 | esbuild не инлайнит динамический `env.js` | High | Medium | Явно копировать `env.js` в build pipeline |
+| R-2 | esbuild ломается на новом svelte/kit upgrade | Low | Medium | Pin esbuild на minor; smoke в CI matrix |
+| R-3 | tarball ≥16M (npm warning threshold) | Medium | Low | Мониторим; если близко — отделить артефакты по dist tags |
+| R-4 | Пользователь put `--experimental` в production и сталкивается с regression | Medium | Medium | Notice в init output + README выделено как EXPERIMENTAL |
+| R-5 | rules 21/23 содержат описания только legacy `dist/` | High | Low | Обновить тексты правил вместе с PR |
+| R-6 | Удаление флага после флипа дефолта поломает users with `--experimental` в скриптах | Medium | Low | После флипа: флаг становится no-op (warning), удалить через ещё одну минорку |
+
+**Rollback план**:
+1. Если bundle ломается на конкретном пакете: добавить пакет в `external: [...]` esbuild config.
+2. Если глобально проблема: `git revert` PR-коммитов, `dist-experimental/` исчезает из tarball, флаг становится no-op (или error). Пользователи с `--experimental` получают «not available in this version» сообщение.
+
+## Acceptance criteria
+
+- AC-1: `npm run build` успешно создаёт И `dist/`, И `dist-experimental/`.
+- AC-2: `init` без флага копирует legacy → `.forgeplan-web/node_modules/` существует.
+- AC-3: `init --experimental` копирует bundled → `.forgeplan-web/node_modules/` отсутствует, размер ≤3M.
+- AC-4: оба шейпа отвечают `200` на `GET /` и `/api/version`.
+- AC-5: `forgeplan-web.json` содержит поле `experimental: bool`.
+- AC-6: `--help` упоминает `--experimental` с пометкой `[EXPERIMENTAL]`.
+- AC-7: README секция описывает флаг + ожидаемый размер.
+- AC-8: rules 21, 23 обновлены и валидны.
+
+## Open questions
+
+- OQ-1: Нужен ли `--no-experimental` для будущей супрессии после флипа дефолта? **Решение**: пока нет. Когда дефолт перевернётся, добавим `--legacy` симметрично.
+- OQ-2: Версия esbuild — pin на minor (`^0.24.x`) или range? **Решение**: caret-pin на minor.
+- OQ-3: Стоит ли логировать experimental usage статистикой? **Решение**: нет (privacy / нет телеметрии в проекте).
+
+---
+
+> **Next step**: реализовать Phase 1–4, прогнать AC-1..AC-8, собрать EvidencePack с CL3 measurement.
+
diff --git a/.forgeplan/rfcs/RFC-014-light-theme-architecture-data-theme-attribute-dual-token-css.md b/.forgeplan/rfcs/RFC-014-light-theme-architecture-data-theme-attribute-dual-token-css.md
new file mode 100644
index 0000000..c175083
--- /dev/null
+++ b/.forgeplan/rfcs/RFC-014-light-theme-architecture-data-theme-attribute-dual-token-css.md
@@ -0,0 +1,213 @@
+---
+depth: standard
+id: RFC-014
+kind: rfc
+links:
+- target: PRD-015
+ relation: refines
+prd: PRD-015
+status: active
+title: 'Light theme architecture: data-theme attribute + dual-token CSS'
+---
+
+# RFC-014: Light theme architecture — `data-theme` attribute + dual-token CSS
+
+## Summary
+
+Switch the existing single-palette CSS in `template/src/app/styles/app.css`
+to a dual-palette system keyed by a `data-theme="light|dark"` attribute on
+``. Drive that attribute from a small Svelte 5 store
+(`shared/lib/theme.svelte.ts`) that reads `localStorage` and the
+`prefers-color-scheme` media query, with a synchronous inline init script
+in `app.html` to avoid a flash on first paint.
+
+## Motivation
+
+Per PRD-015, the viewer is currently dark-only. Making it dual-theme is
+a self-contained UI change with a clear boundary: tokens in one CSS file
++ a handful of components that hardcode `rgba(255,255,255,*)` for
+SVG strokes need to read from CSS vars instead.
+
+## Goals
+
+- One source of truth for color: CSS variables on `:root[data-theme=…]`.
+- Zero new runtime npm deps.
+- No flash of incorrect theme on first paint.
+- Toggle UI affordance follows the existing `notify-toggle` button shape
+ in the header (consistency with current chrome).
+
+## Non-Goals
+
+- Per-component user customisation.
+- Sepia / OLED / high-contrast variants.
+- Refactoring `dist/` or `dist-experimental/` directly — both rebuild
+ from `template/`.
+
+## Options considered
+
+### Option A — `data-theme` attribute on `` + dual `:root[data-theme]` blocks (chosen)
+
+```css
+:root[data-theme='dark'] { --bg: #050505; --fg: #f5f5f5; ... }
+:root[data-theme='light'] { --bg: #f4f1ea; --fg: #1a1815; ... }
+```
+
+Toggle just flips the attribute; CSS does the rest. Works without JS for
+default dark; the inline pre-paint script picks the right value before
+hydration.
+
+Pros: minimal diff, no media-query duplication, JS-driven explicit choice
+overrides OS preference cleanly, easy to e2e-test by setting
+`document.documentElement.dataset.theme`.
+
+Cons: graph views currently hardcode white rgbas — must be tokenized.
+
+### Option B — `prefers-color-scheme` media query only
+
+Pros: zero JS for the OS-following case.
+
+Cons: cannot model an explicit user override (Light user, Dark OS) without
+JS that synthesises a media-query class anyway, and `@media` blocks balloon
+the diff because every `:root` rule needs a duplicate inside `@media`.
+
+### Option C — Tailwind / CSS-in-JS theme provider
+
+Pros: ergonomic.
+
+Cons: violates NFR-002 (no new runtime dep) and contradicts the project's
+hand-rolled CSS-token aesthetic.
+
+**Decision: Option A.**
+
+## Token mapping
+
+```
+Token Dark Light
+--bg #050505 #f4f1ea (cream canvas, matches forgeplan.dev)
+--bg-1 #0b0b0b #ffffff (raised cards, white)
+--bg-2 #141414 #f7f4ed
+--bg-3 #1a1a1a #ece8df
+--fg #f5f5f5 #1a1815 (near-black on cream)
+--fg-1 #e5e5e5 #2a2724
+--fg-2 #a3a3a3 #6b6760
+--fg-3 #737373 #8c887f
+--fg-4 #525252 #b5b1a8
+--accent #ff5a1f #ff5a1f (unchanged)
+--accent-soft #ff8a5b #ff7a3f
+--accent-dim rgba(255,90,31,.18) rgba(255,90,31,.12)
+--good #22c55e #15803d
+--bad #ef4444 #dc2626
+--line rgba(255,255,255,.06) rgba(0,0,0,.06)
+--line-2 rgba(255,255,255,.14) rgba(0,0,0,.12)
+--line-3 rgba(255,255,255,.28) rgba(0,0,0,.22)
+--dot-grid-color rgba(255,255,255,.10) rgba(0,0,0,.10)
+--canvas-overlay rgba(5,5,5,.85) rgba(244,241,234,.85)
+--canvas-stroke rgba(255,255,255,.45) rgba(0,0,0,.55) (NEW — for graph node strokes)
+--canvas-stroke-2 rgba(255,255,255,.32) rgba(0,0,0,.40) (NEW)
+--canvas-stroke-soft rgba(255,255,255,.16) rgba(0,0,0,.12) (NEW)
+--canvas-stroke-on-fill rgba(0,0,0,.4) rgba(255,255,255,.55) (NEW — opposite-of-canvas)
+--card-shadow rgba(0,0,0,.5) rgba(0,0,0,.08)
+```
+
+## Architecture
+
+The data flow is one-way:
+
+```
+app.html inline script ──reads──▶ localStorage 'forgeplan-web.theme'
+ │
+ └─writes─▶ ◀──CSS reads via :root[data-theme]
+ ▲
+ │ on toggle click
+ThemeStore.setMode() ───────────┘
+ ▲
+ │ Svelte 5 $state + $derived
+ │
+HealthBar segmented toggle ── [Auto | Light | Dark]
+```
+
+- **Single source of truth**: the `data-theme` attribute on ``.
+ Both the inline script (first paint) and the Svelte store (post-hydration)
+ write it; CSS reads it via `:root[data-theme=…]` selectors.
+- **`ThemeStore` (`shared/lib/theme.svelte.ts`)** holds `mode` and
+ `systemPref` as `$state`, `effective` as `$derived`. `start()`
+ attaches a `matchMedia('(prefers-color-scheme: light)')` listener so
+ Auto follows OS changes without reload.
+- **`HealthBar`** owns the toggle UI (segmented `[Auto | Light | Dark]`)
+ and calls `themeStore.setMode(...)` on click.
+- **CSS** is layered: `:root` (default → dark fallback) →
+ `:root[data-theme='dark']` (explicit) → `:root[data-theme='light']`
+ (override via attribute selector). Component-scoped CSS reads tokens
+ via `var(--…)` unmodified.
+- **SVG attribute substitution**: graph views use Svelte's `style:fill` /
+ `style:stroke` directives instead of bare `fill={...}` / `stroke={...}`
+ attributes so `var()` resolves through inline CSS. SVG XML attributes
+ do not parse `var()` — only inline-styled fill/stroke do.
+
+## Implementation
+
+### Phase 1 — tokens
+
+- Rewrite `template/src/app/styles/app.css` `:root` block as
+ `:root[data-theme='dark']` + `:root[data-theme='light']`.
+- Default fallback `:root` block points to the dark palette so CSS still
+ renders if no JS / no attribute (graceful).
+- Add new `--canvas-stroke*` and `--canvas-overlay` tokens.
+
+### Phase 2 — store + pre-paint
+
+- New file: `template/src/shared/lib/theme.svelte.ts` exports
+ `themeStore` (Svelte 5 `$state`-based) with
+ `mode: 'auto' | 'light' | 'dark'` and a derived `effective: 'light' | 'dark'`
+ that reads `matchMedia('(prefers-color-scheme: light)')` on Auto.
+- `app.html` gets an inline `
%sveltekit.head%
diff --git a/template/src/app/styles/app.css b/template/src/app/styles/app.css
index 2ea7a19..d03229c 100644
--- a/template/src/app/styles/app.css
+++ b/template/src/app/styles/app.css
@@ -1,6 +1,14 @@
-/* Forgeplan design system — tokens extracted from forgeplan.dev. */
-
-:root {
+/* Forgeplan design system — dual-theme tokens (PRD-015 / RFC-014).
+ *
+ * Theme is selected via `data-theme="dark"` (default) or `data-theme="light"`
+ * on , set by the inline pre-paint script in `app.html` and the
+ * Svelte theme store (`shared/lib/theme.svelte.ts`).
+ *
+ * Default :root falls back to dark so first-paint without JS still works.
+ */
+
+:root,
+:root[data-theme='dark'] {
color-scheme: dark;
/* Surfaces (very dark, near-pure black). */
@@ -33,6 +41,39 @@
--line-2: rgba(255, 255, 255, 0.14);
--line-3: rgba(255, 255, 255, 0.28);
+ /* Canvas-aware tokens (NEW for light theme — used by SVG graph views
+ * for strokes/labels that previously hardcoded rgba(255,255,255,*)). */
+ --canvas-stroke: rgba(255, 255, 255, 0.45);
+ --canvas-stroke-2: rgba(255, 255, 255, 0.32);
+ --canvas-stroke-soft: rgba(255, 255, 255, 0.16);
+ --canvas-stroke-faint: rgba(255, 255, 255, 0.04);
+ --canvas-label: rgba(229, 229, 229, 0.78);
+ --canvas-label-faded: rgba(229, 229, 229, 0.32);
+ --canvas-label-strong: #ffffff;
+ --canvas-stroke-on-fill: rgba(0, 0, 0, 0.4);
+ --canvas-overlay: rgba(5, 5, 5, 0.85);
+ --scrim: rgba(0, 0, 0, 0.6);
+ --shadow-card: 0 12px 40px rgba(0, 0, 0, 0.5);
+ --shadow-mini: 0 4px 16px rgba(0, 0, 0, 0.4);
+ --on-accent: #0b0b0b;
+
+ /* Edge palette (graph relations). */
+ --edge-default: rgba(229, 229, 229, 0.85);
+ --edge-soft: rgba(229, 229, 229, 0.55);
+ --edge-informs: rgba(229, 229, 229, 0.65);
+ --edge-refines: rgba(160, 192, 255, 0.65);
+ --edge-contains: rgba(255, 200, 120, 0.65);
+ --edge-supersedes: rgba(255, 160, 200, 0.65);
+
+ /* Node defaults. */
+ --node-border-neutral: rgba(255, 255, 255, 0.7);
+ --node-fg-neutral: #e5e5e5;
+
+ /* Dot grid pattern. */
+ --dot-grid-color: rgba(255, 255, 255, 0.1);
+ --dot-grid-size: 24px;
+ --dot-grid-radius: 0.9px;
+
/* Typography. */
--font-sans:
"Inter", "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
@@ -40,11 +81,74 @@
--font-mono:
"JetBrains Mono", "Geist Mono", "IBM Plex Mono", ui-monospace,
SFMono-Regular, Menlo, monospace;
+}
- /* Dot grid pattern. */
- --dot-grid-color: rgba(255, 255, 255, 0.1);
- --dot-grid-size: 24px;
- --dot-grid-radius: 0.9px;
+:root[data-theme='light'] {
+ color-scheme: light;
+
+ /* Surfaces — cream/beige canvas matching forgeplan.dev's marketing site. */
+ --bg: #f5f2ea;
+ --bg-1: #ffffff;
+ --bg-2: #faf6ee;
+ --bg-3: #efeae0;
+
+ /* Foreground — high-contrast primary text + airy decorative greys.
+ Pushed primary darker (PRD-015 user feedback: "контраста маловато")
+ and decorative greys lighter ("серый слишком темный в некоторых местах"). */
+ --fg: #0f0d0a;
+ --fg-1: #1f1c18;
+ --fg-2: #5d574e;
+ --fg-3: #a09b90;
+ --fg-4: #c4bfb5;
+
+ /* Brand accent — same orange, slightly more saturated dim for cream backdrop. */
+ --accent: #ff5a1f;
+ --accent-soft: #ff7a3f;
+ --accent-dim: rgba(255, 90, 31, 0.14);
+
+ /* Status semantics — darker shades for legibility on cream. */
+ --good: #15803d;
+ --good-dim: rgba(21, 128, 61, 0.14);
+ --warn: #ff5a1f;
+ --bad: #dc2626;
+ --info: var(--fg-2);
+
+ /* Lines — softer than before so cards/dividers don't feel heavy on cream. */
+ --line: rgba(0, 0, 0, 0.05);
+ --line-2: rgba(0, 0, 0, 0.09);
+ --line-3: rgba(0, 0, 0, 0.18);
+
+ /* Canvas-aware tokens — graph strokes lifted (more contrast) but
+ incidental decoration (dot grid, soft edges) softened. */
+ --canvas-stroke: rgba(0, 0, 0, 0.45);
+ --canvas-stroke-2: rgba(0, 0, 0, 0.30);
+ --canvas-stroke-soft: rgba(0, 0, 0, 0.10);
+ --canvas-stroke-faint: rgba(0, 0, 0, 0.03);
+ --canvas-label: rgba(31, 28, 24, 0.85);
+ --canvas-label-faded: rgba(31, 28, 24, 0.42);
+ --canvas-label-strong: #0f0d0a;
+ --canvas-stroke-on-fill: rgba(255, 255, 255, 0.7);
+ --canvas-overlay: rgba(245, 242, 234, 0.88);
+ --scrim: rgba(15, 12, 8, 0.42);
+ --shadow-card: 0 12px 40px rgba(20, 16, 10, 0.10);
+ --shadow-mini: 0 4px 16px rgba(20, 16, 10, 0.08);
+ --on-accent: #ffffff;
+
+ /* Edge palette tuned for cream — slightly stronger than before. */
+ --edge-default: rgba(31, 28, 24, 0.62);
+ --edge-soft: rgba(31, 28, 24, 0.36);
+ --edge-informs: rgba(31, 28, 24, 0.45);
+ --edge-refines: rgba(60, 90, 160, 0.55);
+ --edge-contains: rgba(170, 110, 30, 0.55);
+ --edge-supersedes: rgba(180, 60, 110, 0.55);
+
+ /* Node defaults — dark border on cream, but not pitch-black so it
+ reads as "ink" rather than "outline". */
+ --node-border-neutral: rgba(0, 0, 0, 0.62);
+ --node-fg-neutral: #0f0d0a;
+
+ /* Dot grid — softened so it sits under the graph rather than competes. */
+ --dot-grid-color: rgba(0, 0, 0, 0.07);
}
* {
@@ -64,6 +168,7 @@ body {
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
+ transition: background-color 160ms ease, color 160ms ease;
}
button {
diff --git a/template/src/entities/artifact/lib/theme.ts b/template/src/entities/artifact/lib/theme.ts
index 6a20959..5e8fd4c 100644
--- a/template/src/entities/artifact/lib/theme.ts
+++ b/template/src/entities/artifact/lib/theme.ts
@@ -3,14 +3,25 @@ import type { ArtifactKind, ArtifactStatus } from '../model/types';
/**
* Forgeplan kind palette — terminal/black-and-orange aesthetic.
*
- * Most kinds render as plain white-bordered rectangles; "accent" kinds
- * (Epic, Problem) render in brand orange; Evidence renders in green.
+ * All color values are returned as CSS `var(--token)` strings so the
+ * dual-theme system (PRD-015 / RFC-014) takes effect without re-rendering
+ * the graph. Call sites that hand the result to an SVG presentation
+ * attribute MUST use Svelte's `style:` directive (e.g. `style:stroke={...}`)
+ * — bare attributes like `stroke={...}` will not interpolate `var()`.
+ *
+ * Most kinds render with a neutral border; "accent" kinds (Epic, Problem)
+ * render in brand orange; Evidence renders in green.
*/
-const ACCENT = '#ff5a1f';
-const GOOD = '#22c55e';
-const NEUTRAL_FG = '#e5e5e5';
-const NEUTRAL_BORDER = 'rgba(255, 255, 255, 0.7)';
+const ACCENT = 'var(--accent)';
+const GOOD = 'var(--good)';
+const NEUTRAL_FG = 'var(--node-fg-neutral)';
+const NEUTRAL_BORDER = 'var(--node-border-neutral)';
+// Decorative kind-dot fill: softer than NEUTRAL_FG so the dots read as
+// "subtle" in both themes (white-on-black in dark, mid-grey on cream in
+// light). Using NEUTRAL_FG here renders as heavy black blobs in light
+// mode (PRD-015 user feedback: "вот тут какие-то черные круги").
+const NEUTRAL_DOT = 'var(--fg-3)';
export const ACCENT_KINDS = new Set(['epic', 'problem', 'evidence', 'evid']);
@@ -89,8 +100,8 @@ export const KIND_TAGLINES: Record =
export const STATUS_RING: Record = {
active: GOOD,
draft: ACCENT,
- superseded: '#525252',
- deprecated: '#3f3f3f',
+ superseded: 'var(--fg-4)',
+ deprecated: 'var(--fg-4)',
stale: ACCENT
};
@@ -106,7 +117,7 @@ export function kindIsAccent(kind: ArtifactKind | string): boolean {
* Border colour for a graph node, terminal-style.
* - Epic / Problem → brand orange
* - Evidence → green
- * - everything else → neutral white border
+ * - everything else → neutral (white in dark theme, black in light)
*/
export function kindBorder(kind: ArtifactKind | string): string {
const k = kind.toLowerCase();
@@ -127,27 +138,28 @@ export function kindLabelColor(kind: ArtifactKind | string): string {
}
/**
- * Legacy fill helper kept around for chips / dots that still want a
- * per-kind solid colour.
+ * Decorative kind dots (filter chips, insights rail). Neutral kinds use
+ * the softer NEUTRAL_DOT token; accent kinds keep their saturated brand
+ * colors so the legend stays scannable in both themes.
*/
export const KIND_COLORS: Record = {
- prd: NEUTRAL_FG,
- rfc: NEUTRAL_FG,
- adr: NEUTRAL_FG,
- spec: NEUTRAL_FG,
+ prd: NEUTRAL_DOT,
+ rfc: NEUTRAL_DOT,
+ adr: NEUTRAL_DOT,
+ spec: NEUTRAL_DOT,
epic: ACCENT,
evidence: GOOD,
evid: GOOD,
problem: ACCENT,
- solution: NEUTRAL_FG,
- note: NEUTRAL_FG,
- refresh: NEUTRAL_FG
+ solution: NEUTRAL_DOT,
+ note: NEUTRAL_DOT,
+ refresh: NEUTRAL_DOT
};
export function kindColor(kind: ArtifactKind | string): string {
- return KIND_COLORS[kind.toLowerCase()] ?? NEUTRAL_FG;
+ return KIND_COLORS[kind.toLowerCase()] ?? NEUTRAL_DOT;
}
export function statusRing(status: ArtifactStatus | string): string {
- return STATUS_RING[status.toLowerCase()] ?? '#3f3f3f';
+ return STATUS_RING[status.toLowerCase()] ?? 'var(--fg-4)';
}
diff --git a/template/src/pages/home/ui/HomePage.svelte b/template/src/pages/home/ui/HomePage.svelte
index 1f1bc87..eb88ed5 100644
--- a/template/src/pages/home/ui/HomePage.svelte
+++ b/template/src/pages/home/ui/HomePage.svelte
@@ -20,15 +20,29 @@
import { DependencyGraph } from '@/widgets/dependency-graph';
import { ArtifactPanel } from '@/widgets/artifact-panel';
import { InsightsRail } from '@/widgets/insights-rail';
- import { GRAPH_VIEWS, type GraphView, type InsightTab } from '@/shared/config';
+ import { Timeline, snapshotStore } from '@/widgets/timeline';
+ import { VersionFooter } from '@/widgets/version-footer';
+ import {
+ MosaicCanvas,
+ changeView,
+ leaves,
+ loadLayout,
+ saveLayout,
+ singletonLayout,
+ type Layout
+ } from '@/widgets/mosaic';
+ import { type GraphView, type InsightTab } from '@/shared/config';
import { loadSettings, saveSettings } from '../lib/settings';
let view = $state('force');
+ let layout = $state(singletonLayout('force'));
+ let layoutHydrated = $state(false);
let kindFilter = $state(new Set());
let statusFilter = $state(new Set());
let activeTab = $state('agents');
let selectedId = $state(null);
- let graphRef = $state<{ resetZoom: () => void } | undefined>();
+ type GraphRef = { resetZoom: () => void };
+ let graphRefs = $state>({});
let settingsHydrated = $state(false);
let notifyEnabled = $state(false);
let liveText = $state('');
@@ -48,15 +62,24 @@
let lastNotifyEnabled = false;
let liveSeq = 0;
- const nodes = $derived(listPoller.state.data ?? []);
- const edges = $derived(graphPoller.state.data?.edges ?? []);
+ const liveNodes = $derived(listPoller.state.data ?? []);
+ const liveEdges = $derived(graphPoller.state.data?.edges ?? []);
+ const snapshotting = $derived(
+ snapshotStore.mode === 'single' && snapshotStore.current !== null
+ );
+ const nodes = $derived(
+ snapshotting && snapshotStore.current
+ ? (snapshotStore.current.artifacts as typeof liveNodes)
+ : liveNodes
+ );
+ const edges = $derived(
+ snapshotting && snapshotStore.current
+ ? (snapshotStore.current.edges as typeof liveEdges)
+ : liveEdges
+ );
const scores = $derived(scorePoller.state.data ?? []);
const globalError = $derived(listPoller.state.error ?? graphPoller.state.error ?? null);
- function setView(next: GraphView) {
- view = next;
- }
-
function selectNode(detail: { id: string }) {
selectedId = detail.id;
}
@@ -69,8 +92,8 @@
selectedId = detail.id;
}
- function reset() {
- graphRef?.resetZoom();
+ function resetZoomFor(leafId: string) {
+ graphRefs[leafId]?.resetZoom();
}
$effect(() => {
@@ -81,6 +104,8 @@
activeTab = initial.activeTab;
notifyEnabled = initial.notify;
settingsHydrated = true;
+ layout = loadLayout(initial.view);
+ layoutHydrated = true;
listPoller.start();
graphPoller.start();
@@ -116,6 +141,13 @@
return () => clearTimeout(timer);
});
+ $effect(() => {
+ if (!layoutHydrated) return;
+ const snapshot = layout;
+ const timer = setTimeout(() => saveLayout(snapshot), 250);
+ return () => clearTimeout(timer);
+ });
+
$effect(() => {
const health = healthPoller.state.data;
if (!health) return;
@@ -238,41 +270,25 @@