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 @@
{nodes.length} ARTIFACTS · {edges.length} EDGES -
-
- {#each GRAPH_VIEWS as v (v.id)} - - {/each} -
- -
-
-
- {GRAPH_VIEWS.find((v) => v.id === view)?.hint ?? ''}
- selectNode(detail)} - /> + + {#snippet leafSnippet(paneView: GraphView, leafId: string)} + selectNode(detail)} + /> + {/snippet} +
+
selectNode(detail)} /> {#if selectedId} @@ -297,6 +313,7 @@ {/if} + diff --git a/template/src/shared/ui/button/index.ts b/template/src/shared/ui/button/index.ts new file mode 100644 index 0000000..ae34e39 --- /dev/null +++ b/template/src/shared/ui/button/index.ts @@ -0,0 +1 @@ +export { default as Button } from './Button.svelte'; diff --git a/template/src/shared/ui/code/Code.svelte b/template/src/shared/ui/code/Code.svelte new file mode 100644 index 0000000..b46064f --- /dev/null +++ b/template/src/shared/ui/code/Code.svelte @@ -0,0 +1,165 @@ + + +{#if inline} + + {code} + + +{:else} +
+
{code}
+ +
+{/if} + + diff --git a/template/src/shared/ui/code/index.ts b/template/src/shared/ui/code/index.ts new file mode 100644 index 0000000..3da303c --- /dev/null +++ b/template/src/shared/ui/code/index.ts @@ -0,0 +1 @@ +export { default as Code } from './Code.svelte'; diff --git a/template/src/shared/ui/dialog/Dialog.svelte b/template/src/shared/ui/dialog/Dialog.svelte new file mode 100644 index 0000000..794e2bf --- /dev/null +++ b/template/src/shared/ui/dialog/Dialog.svelte @@ -0,0 +1,181 @@ + + + + +
e.stopPropagation()} + > + {#if title || showClose} +
+

{title ?? ''}

+ {#if showClose} + + {/if} +
+ {/if} +
+ {#if body} + {@render body()} + {:else if children} + {@render children()} + {/if} +
+ {#if footer} +
+ {@render footer()} +
+ {/if} +
+
+ + diff --git a/template/src/shared/ui/dialog/index.ts b/template/src/shared/ui/dialog/index.ts new file mode 100644 index 0000000..17f5a2a --- /dev/null +++ b/template/src/shared/ui/dialog/index.ts @@ -0,0 +1 @@ +export { default as Dialog } from './Dialog.svelte'; diff --git a/template/src/shared/ui/index.ts b/template/src/shared/ui/index.ts new file mode 100644 index 0000000..dc4e050 --- /dev/null +++ b/template/src/shared/ui/index.ts @@ -0,0 +1,4 @@ +export { Button } from './button'; +export { Code } from './code'; +export { Dialog } from './dialog'; +export { ModalRoot } from './modal'; diff --git a/template/src/shared/ui/modal/ModalRoot.svelte b/template/src/shared/ui/modal/ModalRoot.svelte new file mode 100644 index 0000000..19d7f57 --- /dev/null +++ b/template/src/shared/ui/modal/ModalRoot.svelte @@ -0,0 +1,8 @@ + + +{#each modalManager.stack as entry (entry.id)} + {@const { component: ModalComponent, props, id } = entry} + +{/each} diff --git a/template/src/shared/ui/modal/index.ts b/template/src/shared/ui/modal/index.ts new file mode 100644 index 0000000..56db4c8 --- /dev/null +++ b/template/src/shared/ui/modal/index.ts @@ -0,0 +1 @@ +export { default as ModalRoot } from './ModalRoot.svelte'; diff --git a/template/src/widgets/dependency-graph/lib/relation.ts b/template/src/widgets/dependency-graph/lib/relation.ts index 0cefd1e..58a6212 100644 --- a/template/src/widgets/dependency-graph/lib/relation.ts +++ b/template/src/widgets/dependency-graph/lib/relation.ts @@ -1,3 +1,7 @@ +// All colors return as CSS var() so the dual-theme system (PRD-015 / +// RFC-014) applies without re-rendering. Call sites passing the result +// to an SVG presentation attribute MUST use `style:` directives. + export function relationClass(relation: string): string { const r = relation?.toLowerCase() ?? ""; if (r === "informs") return "edge informs"; @@ -7,18 +11,17 @@ export function relationClass(relation: string): string { export function relationFill(relation: string): string { const r = relation?.toLowerCase() ?? ""; - if (r === "informs") return "rgba(229, 229, 229, 0.65)"; + if (r === "informs") return "var(--edge-informs)"; if (r === "risks" || r === "risk") return "var(--accent)"; - return "rgba(229, 229, 229, 0.85)"; + return "var(--edge-default)"; } export function relationStroke(relation: string): string { const r = relation?.toLowerCase() ?? ""; - if (r === "informs") return "rgba(229, 229, 229, 0.55)"; + if (r === "informs") return "var(--edge-soft)"; if (r === "risks" || r === "risk") return "var(--accent)"; - if (r === "refines") return "rgba(160, 192, 255, 0.65)"; - if (r === "belongs-to" || r === "contains") - return "rgba(255, 200, 120, 0.65)"; - if (r === "supersedes") return "rgba(255, 160, 200, 0.65)"; - return "rgba(229, 229, 229, 0.55)"; + if (r === "refines") return "var(--edge-refines)"; + if (r === "belongs-to" || r === "contains") return "var(--edge-contains)"; + if (r === "supersedes") return "var(--edge-supersedes)"; + return "var(--edge-soft)"; } diff --git a/template/src/widgets/dependency-graph/ui/ForceView.svelte b/template/src/widgets/dependency-graph/ui/ForceView.svelte index 44c611d..a7297c6 100644 --- a/template/src/widgets/dependency-graph/ui/ForceView.svelte +++ b/template/src/widgets/dependency-graph/ui/ForceView.svelte @@ -585,7 +585,7 @@ > - + @@ -629,14 +629,14 @@ class="box" width={node.w} height={node.h} - stroke={kindBorder(node.kind)} + style:stroke={kindBorder(node.kind)} /> {node.id} @@ -652,7 +652,7 @@ cx={node.w + 8} cy={node.h / 2} r="3.2" - fill={statusRing(node.status)} + style:fill={statusRing(node.status)} /> {#if reff > 0} {/if} @@ -681,13 +681,13 @@ cursor: grabbing; } .edge { - stroke: rgba(255, 255, 255, 0.45); + stroke: var(--canvas-stroke); stroke-width: 1; fill: none; transition: stroke 180ms ease-out, stroke-width 180ms ease-out, opacity 180ms ease-out; } .edge.informs { - stroke: rgba(255, 255, 255, 0.32); + stroke: var(--canvas-stroke-2); stroke-dasharray: 4 4; } .edge.risk { diff --git a/template/src/widgets/dependency-graph/ui/LanesView.svelte b/template/src/widgets/dependency-graph/ui/LanesView.svelte index 6842c0d..cfffcd3 100644 --- a/template/src/widgets/dependency-graph/ui/LanesView.svelte +++ b/template/src/widgets/dependency-graph/ui/LanesView.svelte @@ -319,7 +319,7 @@ - + @@ -369,8 +369,8 @@ tabindex="0" aria-label={`${node.id}: ${node.title}`} > - - + + {node.id} {#if node.id === selectedId} @@ -382,7 +382,7 @@ ry="3" /> {/if} - + {#if (scoreById.get(node.id) ?? 0) > 0} {/if} @@ -408,7 +408,7 @@ } .graph:active { cursor: grabbing; } .lane { - fill: rgba(255, 255, 255, 0.015); + fill: var(--canvas-stroke-faint); stroke: var(--line); stroke-width: 1; } @@ -427,12 +427,12 @@ } .lane-count { fill: var(--fg-3); } .edge { - stroke: rgba(255, 255, 255, 0.45); + stroke: var(--canvas-stroke); stroke-width: 1; fill: none; transition: stroke 180ms ease-out, stroke-width 180ms ease-out, opacity 180ms ease-out; } - .edge.informs { stroke: rgba(255, 255, 255, 0.32); stroke-dasharray: 4 4; } + .edge.informs { stroke: var(--canvas-stroke-2); stroke-dasharray: 4 4; } .edge.risk { stroke: var(--accent); stroke-dasharray: 3 3; } .node { cursor: pointer; diff --git a/template/src/widgets/dependency-graph/ui/MatrixView.svelte b/template/src/widgets/dependency-graph/ui/MatrixView.svelte index dec9d50..95ce781 100644 --- a/template/src/widgets/dependency-graph/ui/MatrixView.svelte +++ b/template/src/widgets/dependency-graph/ui/MatrixView.svelte @@ -231,10 +231,10 @@ tabindex="0" aria-label={`row ${n.id}`} > - + {n.id} - + - + {n.id} @@ -276,7 +276,7 @@ y={HEADER + c.row * CELL + 2} width={CELL - 4} height={CELL - 4} - fill={relationFill(c.relation)} + style:fill={relationFill(c.relation)} onclick={(e) => { e.stopPropagation(); selectId(c.to); }} onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && selectId(c.to)} role="button" @@ -317,7 +317,7 @@ stroke-width: 0.5; } .diag { - stroke: rgba(255, 255, 255, 0.04); + stroke: var(--canvas-stroke-faint); stroke-width: 1; } .row-header, .col-header { @@ -354,11 +354,18 @@ cursor: pointer; transition: filter 120ms, fill 180ms ease-out, opacity 180ms ease-out; } + /* `!important` here is load-bearing: cell `fill` is set inline via + Svelte's `style:fill={relationFill(...)}` directive (so CSS vars + resolve in SVG), which beats normal CSS rules. To make hover / + focus / row-col / edge-active flip the cell color to accent in + both themes, the rule has to win against the inline style. */ .cell:hover { + fill: var(--accent) !important; filter: drop-shadow(0 0 4px var(--accent)); outline: none; } .cell:focus-visible { + fill: var(--accent) !important; stroke: var(--accent); stroke-width: 1.5; filter: drop-shadow(0 0 4px var(--accent)); @@ -366,6 +373,7 @@ } .cell.is-row, .cell.is-col { + fill: var(--accent) !important; stroke: var(--accent); stroke-width: 1; } @@ -374,7 +382,7 @@ pointer-events: none; } .edge-active { - fill: var(--accent); + fill: var(--accent) !important; opacity: 1; } .edge-dim { diff --git a/template/src/widgets/dependency-graph/ui/Minimap.svelte b/template/src/widgets/dependency-graph/ui/Minimap.svelte index 07f3dc8..a9d3208 100644 --- a/template/src/widgets/dependency-graph/ui/Minimap.svelte +++ b/template/src/widgets/dependency-graph/ui/Minimap.svelte @@ -116,7 +116,7 @@ background: var(--bg-1); border: 1px solid var(--line-2); border-radius: 2px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-mini); cursor: pointer; z-index: 5; overflow: hidden; diff --git a/template/src/widgets/dependency-graph/ui/RadialView.svelte b/template/src/widgets/dependency-graph/ui/RadialView.svelte index dd2d524..54b4432 100644 --- a/template/src/widgets/dependency-graph/ui/RadialView.svelte +++ b/template/src/widgets/dependency-graph/ui/RadialView.svelte @@ -437,7 +437,7 @@ - + @@ -482,8 +482,8 @@ tabindex="0" aria-label={`${node.id}: ${node.title}`} > - - + + {node.id} {#if node.id === selectedId} @@ -495,7 +495,7 @@ ry="3" /> {/if} - + {#if (scoreById.get(node.id) ?? 0) > 0} {/if} @@ -522,14 +522,14 @@ .graph:active { cursor: grabbing; } .ring { fill: none; - stroke: rgba(255, 255, 255, 0.16); + stroke: var(--canvas-stroke-soft); stroke-width: 1; stroke-dasharray: 3 5; } .cluster-toggle { cursor: pointer; } .cluster-toggle circle { fill: var(--bg-1); - stroke: rgba(255, 255, 255, 0.45); + stroke: var(--canvas-stroke); stroke-width: 1; transition: stroke-width 120ms, stroke 120ms; } @@ -543,16 +543,16 @@ .cluster-toggle .toggle-glyph { font-family: var(--font-mono); font-size: 14px; - fill: rgba(255, 255, 255, 0.85); + fill: var(--canvas-label); pointer-events: none; } .edge { - stroke: rgba(255, 255, 255, 0.45); + stroke: var(--canvas-stroke); stroke-width: 1; fill: none; transition: stroke 180ms ease-out, stroke-width 180ms ease-out, opacity 180ms ease-out; } - .edge.informs { stroke: rgba(255, 255, 255, 0.32); stroke-dasharray: 4 4; } + .edge.informs { stroke: var(--canvas-stroke-2); stroke-dasharray: 4 4; } .edge.risk { stroke: var(--accent); stroke-dasharray: 3 3; } .node { cursor: pointer; diff --git a/template/src/widgets/dependency-graph/ui/SankeyView.svelte b/template/src/widgets/dependency-graph/ui/SankeyView.svelte index a877a72..4f8ca7b 100644 --- a/template/src/widgets/dependency-graph/ui/SankeyView.svelte +++ b/template/src/widgets/dependency-graph/ui/SankeyView.svelte @@ -295,7 +295,7 @@ @@ -330,7 +330,7 @@ y={y0} width={x1 - x0} height={Math.max(2, y1 - y0)} - fill={kindBorder(n.kind)} + style:fill={kindBorder(n.kind)} /> {/each} @@ -372,7 +372,7 @@ pointer-events: none; } .tier-divider { - stroke: rgba(255, 255, 255, 0.05); + stroke: var(--canvas-stroke-faint); stroke-width: 1; stroke-dasharray: 2 6; pointer-events: none; @@ -407,7 +407,7 @@ font-size: 11px; letter-spacing: 0.02em; pointer-events: none; - fill: rgba(229, 229, 229, 0.78); + fill: var(--canvas-label); transition: fill 120ms; } .status-dot { pointer-events: none; opacity: 0.85; } @@ -426,7 +426,7 @@ .node:hover .label, .node:focus-visible .label, .node.hovered .label { - fill: #ffffff; + fill: var(--canvas-label-strong); } /* Selected: same accent stroke as hover but persistent; text switches to accent so user can find the active node visually diff --git a/template/src/widgets/dependency-graph/ui/SunburstView.svelte b/template/src/widgets/dependency-graph/ui/SunburstView.svelte index 8d51079..fb8aa82 100644 --- a/template/src/widgets/dependency-graph/ui/SunburstView.svelte +++ b/template/src/widgets/dependency-graph/ui/SunburstView.svelte @@ -283,7 +283,7 @@ {#if shouldShowLabel(d)} - + - + - + - + {node.id} @@ -446,7 +446,7 @@ cx={node.w + 8} cy={node.h / 2} r="3.2" - fill={statusRing(node.status)} + style:fill={statusRing(node.status)} /> {#if (scoreById.get(node.id) ?? 0) > 0} {/if} @@ -475,13 +475,13 @@ cursor: grabbing; } .edge { - stroke: rgba(255, 255, 255, 0.55); + stroke: var(--canvas-stroke); stroke-width: 1.2; fill: none; transition: stroke 180ms ease-out, stroke-width 180ms ease-out, opacity 180ms ease-out; } .edge.informs { - stroke: rgba(255, 255, 255, 0.4); + stroke: var(--canvas-stroke-2); stroke-dasharray: 4 4; } .edge.risk { diff --git a/template/src/widgets/health-bar/ui/HealthBar.svelte b/template/src/widgets/health-bar/ui/HealthBar.svelte index c4e5d4b..26a0c54 100644 --- a/template/src/widgets/health-bar/ui/HealthBar.svelte +++ b/template/src/widgets/health-bar/ui/HealthBar.svelte @@ -5,6 +5,7 @@ notificationsSupported, requestPermission } from '@/entities/health/lib/notify.svelte'; + import { themeStore, type ThemeMode } from '@/shared/lib'; let { notify = $bindable(false), liveText = '' } = $props<{ notify?: boolean; @@ -16,6 +17,17 @@ permission = notificationPermission(); }); + $effect(() => { + themeStore.start(); + return () => themeStore.stop(); + }); + + const THEME_OPTIONS: ReadonlyArray<{ id: ThemeMode; label: string; title: string }> = [ + { id: 'auto', label: 'Auto', title: 'Follow operating system theme' }, + { id: 'light', label: 'Light', title: 'Force light theme' }, + { id: 'dark', label: 'Dark', title: 'Force dark theme' } + ]; + const health = $derived(healthPoller.state.data); const lastUpdated = $derived( healthPoller.state.lastFetched @@ -76,6 +88,21 @@ {/if}
+
+ {#each THEME_OPTIONS as opt (opt.id)} + + {/each} +
{#if notificationsSupported()} + {/if} + {#if canAdd} + + {/if} + {#if canClose} + + {/if} + +
+ {@render children()} +
+
+ + diff --git a/template/src/widgets/mosaic/ui/Splitter.svelte b/template/src/widgets/mosaic/ui/Splitter.svelte new file mode 100644 index 0000000..b270861 --- /dev/null +++ b/template/src/widgets/mosaic/ui/Splitter.svelte @@ -0,0 +1,106 @@ + + + + + + + diff --git a/template/src/widgets/timeline/index.ts b/template/src/widgets/timeline/index.ts new file mode 100644 index 0000000..70340f5 --- /dev/null +++ b/template/src/widgets/timeline/index.ts @@ -0,0 +1,22 @@ +export { default as Timeline } from "./ui/Timeline.svelte"; +export { + loadSnapshotAt, + setActiveAt, + setComparePair, + setNow, + snapshotStore, + toggleCollapsed, + type SnapshotMode, + type SnapshotState, +} from "./lib/snapshot-state.svelte"; +export { + eventsToDomain, + eventsToTicks, + snapToNearestEvent, + stepEvent, + timestampToX, + xToTimestamp, + type AxisDomain, + type TickPosition, + type TimelineEvent, +} from "./lib/event-axis"; diff --git a/template/src/widgets/timeline/lib/event-axis.test.ts b/template/src/widgets/timeline/lib/event-axis.test.ts new file mode 100644 index 0000000..ac340a7 --- /dev/null +++ b/template/src/widgets/timeline/lib/event-axis.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + eventsToDomain, + eventsToTicks, + snapToNearestEvent, + stepEvent, + timestampToX, + xToTimestamp, + type TimelineEvent, +} from "./event-axis"; + +const sampleEvents: TimelineEvent[] = [ + { at: "2026-01-15T10:00:00.000Z", kind: "created", artifactId: "PRD-001" }, + { at: "2026-02-01T12:00:00.000Z", kind: "activated", artifactId: "PRD-001" }, + { at: "2026-03-10T08:30:00.000Z", kind: "scored", artifactId: "PRD-002" }, + { at: "2026-04-05T14:00:00.000Z", kind: "superseded", artifactId: "PRD-001" }, +]; + +describe("eventsToDomain", () => { + it("returns null for empty events", () => { + expect(eventsToDomain([])).toBeNull(); + }); + + it("computes min/max ms across events", () => { + const d = eventsToDomain(sampleEvents); + expect(d).not.toBeNull(); + expect(d!.startMs).toBe(Date.parse("2026-01-15T10:00:00.000Z")); + expect(d!.endMs).toBe(Date.parse("2026-04-05T14:00:00.000Z")); + }); + + it("pads single-event domain by ±1h", () => { + const d = eventsToDomain([sampleEvents[0]!]); + expect(d).not.toBeNull(); + expect(d!.endMs - d!.startMs).toBe(7_200_000); + }); + + it("ignores invalid timestamps without crashing", () => { + const d = eventsToDomain([ + { at: "not-a-date", kind: "created", artifactId: "X" }, + sampleEvents[0]!, + ]); + expect(d).not.toBeNull(); + expect(d!.startMs).toBe(Date.parse(sampleEvents[0]!.at) - 3_600_000); + }); +}); + +describe("timestampToX / xToTimestamp", () => { + const domain = eventsToDomain(sampleEvents)!; + + it("maps domain start to x=0", () => { + expect(timestampToX(domain.startMs, domain, 1000)).toBe(0); + }); + + it("maps domain end to x=widthPx", () => { + expect(timestampToX(domain.endMs, domain, 1000)).toBe(1000); + }); + + it("clamps out-of-range timestamps", () => { + expect(timestampToX(domain.startMs - 1_000_000, domain, 1000)).toBe(0); + expect(timestampToX(domain.endMs + 1_000_000, domain, 1000)).toBe(1000); + }); + + it("returns 0 for non-positive widthPx", () => { + expect(timestampToX(domain.startMs, domain, 0)).toBe(0); + expect(timestampToX(domain.startMs, domain, -10)).toBe(0); + }); + + it("xToTimestamp inverts timestampToX", () => { + const targetMs = Date.parse("2026-02-15T00:00:00.000Z"); + const x = timestampToX(targetMs, domain, 800); + const back = xToTimestamp(x, domain, 800); + expect(Math.abs(back - targetMs)).toBeLessThan(1000); + }); + + it("xToTimestamp clamps x to [0, widthPx]", () => { + expect(xToTimestamp(-50, domain, 1000)).toBe(domain.startMs); + expect(xToTimestamp(2000, domain, 1000)).toBe(domain.endMs); + }); +}); + +describe("eventsToTicks", () => { + it("produces one tick per valid event", () => { + const domain = eventsToDomain(sampleEvents)!; + const ticks = eventsToTicks(sampleEvents, domain, 1000); + expect(ticks).toHaveLength(4); + expect(ticks[0]!.x).toBe(0); + expect(ticks[3]!.x).toBe(1000); + }); + + it("filters invalid timestamps", () => { + const domain = eventsToDomain(sampleEvents)!; + const withInvalid: TimelineEvent[] = [ + ...sampleEvents, + { at: "garbage", kind: "created", artifactId: "BAD" }, + ]; + const ticks = eventsToTicks(withInvalid, domain, 500); + expect(ticks).toHaveLength(4); + }); +}); + +describe("snapToNearestEvent", () => { + const domain = eventsToDomain(sampleEvents)!; + + it("returns null for empty events", () => { + expect(snapToNearestEvent(100, [], domain, 1000)).toBeNull(); + }); + + it("snaps to nearest event by pixel distance", () => { + const snapped = snapToNearestEvent(0, sampleEvents, domain, 1000); + expect(snapped?.artifactId).toBe("PRD-001"); + expect(snapped?.kind).toBe("created"); + }); + + it("snaps to last event at far right", () => { + const snapped = snapToNearestEvent(1000, sampleEvents, domain, 1000); + expect(snapped?.kind).toBe("superseded"); + }); +}); + +describe("stepEvent", () => { + it("starts at first event when current is null and direction is next", () => { + const e = stepEvent(null, sampleEvents, "next"); + expect(e?.artifactId).toBe("PRD-001"); + expect(e?.kind).toBe("created"); + }); + + it("starts at last event when current is null and direction is prev", () => { + const e = stepEvent(null, sampleEvents, "prev"); + expect(e?.kind).toBe("superseded"); + }); + + it("advances to the next strictly later event", () => { + const e = stepEvent("2026-01-15T10:00:00.000Z", sampleEvents, "next"); + expect(e?.at).toBe("2026-02-01T12:00:00.000Z"); + }); + + it("steps back to the previous strictly earlier event", () => { + const e = stepEvent("2026-04-05T14:00:00.000Z", sampleEvents, "prev"); + expect(e?.at).toBe("2026-03-10T08:30:00.000Z"); + }); + + it("returns the last event when stepping past the end", () => { + const e = stepEvent("2026-04-05T14:00:00.000Z", sampleEvents, "next"); + expect(e?.kind).toBe("superseded"); + }); + + it("returns the first event when stepping before the start", () => { + const e = stepEvent("2026-01-15T10:00:00.000Z", sampleEvents, "prev"); + expect(e?.kind).toBe("created"); + }); +}); diff --git a/template/src/widgets/timeline/lib/event-axis.ts b/template/src/widgets/timeline/lib/event-axis.ts new file mode 100644 index 0000000..b8b475f --- /dev/null +++ b/template/src/widgets/timeline/lib/event-axis.ts @@ -0,0 +1,130 @@ +export interface TimelineEvent { + at: string; + kind: "created" | "activated" | "superseded" | "scored" | "linked"; + artifactId: string; +} + +export interface TickPosition { + x: number; + at: string; + kind: TimelineEvent["kind"]; + artifactId: string; +} + +export interface AxisDomain { + startMs: number; + endMs: number; +} + +export function eventsToDomain( + events: ReadonlyArray, +): AxisDomain | null { + if (events.length === 0) return null; + let startMs = Number.POSITIVE_INFINITY; + let endMs = Number.NEGATIVE_INFINITY; + for (const e of events) { + const t = Date.parse(e.at); + if (Number.isNaN(t)) continue; + if (t < startMs) startMs = t; + if (t > endMs) endMs = t; + } + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null; + if (startMs === endMs) { + // FIXME(single-event-domain): a one-event timeline collapses to a zero- + // width axis. Pad ±1h so the scrubber has somewhere to land. + return { startMs: startMs - 3_600_000, endMs: endMs + 3_600_000 }; + } + return { startMs, endMs }; +} + +export function timestampToX( + ms: number, + domain: AxisDomain, + widthPx: number, +): number { + if (widthPx <= 0) return 0; + const range = domain.endMs - domain.startMs; + if (range <= 0) return 0; + const ratio = (ms - domain.startMs) / range; + return Math.max(0, Math.min(widthPx, ratio * widthPx)); +} + +export function xToTimestamp( + x: number, + domain: AxisDomain, + widthPx: number, +): number { + if (widthPx <= 0) return domain.startMs; + const clampedX = Math.max(0, Math.min(widthPx, x)); + const range = domain.endMs - domain.startMs; + return domain.startMs + (clampedX / widthPx) * range; +} + +export function eventsToTicks( + events: ReadonlyArray, + domain: AxisDomain, + widthPx: number, +): TickPosition[] { + const out: TickPosition[] = []; + for (const e of events) { + const ms = Date.parse(e.at); + if (Number.isNaN(ms)) continue; + out.push({ + x: timestampToX(ms, domain, widthPx), + at: e.at, + kind: e.kind, + artifactId: e.artifactId, + }); + } + return out; +} + +export function snapToNearestEvent( + x: number, + events: ReadonlyArray, + domain: AxisDomain, + widthPx: number, +): TimelineEvent | null { + if (events.length === 0) return null; + let bestEvent: TimelineEvent | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const e of events) { + const ms = Date.parse(e.at); + if (Number.isNaN(ms)) continue; + const ex = timestampToX(ms, domain, widthPx); + const d = Math.abs(ex - x); + if (d < bestDistance) { + bestDistance = d; + bestEvent = e; + } + } + return bestEvent; +} + +export function stepEvent( + current: string | null, + events: ReadonlyArray, + direction: "next" | "prev", +): TimelineEvent | null { + if (events.length === 0) return null; + const sorted = [...events].sort( + (a, b) => Date.parse(a.at) - Date.parse(b.at), + ); + if (current === null) { + return direction === "next" ? sorted[0]! : sorted[sorted.length - 1]!; + } + const currentMs = Date.parse(current); + if (Number.isNaN(currentMs)) return sorted[0]!; + if (direction === "next") { + for (const e of sorted) { + if (Date.parse(e.at) > currentMs) return e; + } + return sorted[sorted.length - 1]!; + } + let last: TimelineEvent | null = null; + for (const e of sorted) { + if (Date.parse(e.at) >= currentMs) break; + last = e; + } + return last ?? sorted[0]!; +} diff --git a/template/src/widgets/timeline/lib/snapshot-state.svelte.ts b/template/src/widgets/timeline/lib/snapshot-state.svelte.ts new file mode 100644 index 0000000..e02dfb3 --- /dev/null +++ b/template/src/widgets/timeline/lib/snapshot-state.svelte.ts @@ -0,0 +1,103 @@ +import type { SnapshotData } from "@/shared/server"; + +export type SnapshotMode = "now" | "single" | "compare"; + +export interface SnapshotState { + mode: SnapshotMode; + activeAt: string | null; + t1: string | null; + t2: string | null; + collapsed: boolean; + loading: boolean; + error: string | null; + current: SnapshotData | null; +} + +const COLLAPSE_KEY = "forgeplan-web.timeline.collapsed"; + +function readCollapsed(): boolean { + if (typeof localStorage === "undefined") return false; + try { + return localStorage.getItem(COLLAPSE_KEY) === "1"; + } catch { + return false; + } +} + +function writeCollapsed(v: boolean): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(COLLAPSE_KEY, v ? "1" : "0"); + } catch { + // FIXME(localStorage-quota): silently ignored — surface in ops log later. + } +} + +export const snapshotStore = $state({ + mode: "now", + activeAt: null, + t1: null, + t2: null, + collapsed: readCollapsed(), + loading: false, + error: null, + current: null, +}); + +export function setNow(): void { + snapshotStore.mode = "now"; + snapshotStore.activeAt = null; + snapshotStore.t1 = null; + snapshotStore.t2 = null; + snapshotStore.error = null; + snapshotStore.current = null; +} + +export function setActiveAt(at: string): void { + snapshotStore.mode = "single"; + snapshotStore.activeAt = at; + snapshotStore.error = null; +} + +export function setComparePair(t1: string, t2: string): void { + snapshotStore.mode = "compare"; + snapshotStore.t1 = t1; + snapshotStore.t2 = t2; + snapshotStore.error = null; +} + +export function toggleCollapsed(): void { + snapshotStore.collapsed = !snapshotStore.collapsed; + writeCollapsed(snapshotStore.collapsed); +} + +interface SnapshotResponse { + ok: boolean; + at: string; + sha?: string; + snapshot?: SnapshotData; + fromCache?: "memory" | "disk" | null; + error?: string; +} + +export async function loadSnapshotAt(at: string): Promise { + snapshotStore.loading = true; + snapshotStore.error = null; + try { + const url = `/api/snapshot?at=${encodeURIComponent(at)}`; + const res = await fetch(url); + const body = (await res.json()) as SnapshotResponse; + if (!res.ok || !body.ok || !body.snapshot) { + snapshotStore.error = body.error ?? `HTTP ${res.status}`; + snapshotStore.current = null; + return; + } + setActiveAt(at); + snapshotStore.current = body.snapshot; + } catch (err) { + snapshotStore.error = (err as Error).message; + snapshotStore.current = null; + } finally { + snapshotStore.loading = false; + } +} diff --git a/template/src/widgets/timeline/ui/Timeline.svelte b/template/src/widgets/timeline/ui/Timeline.svelte new file mode 100644 index 0000000..ce34d59 --- /dev/null +++ b/template/src/widgets/timeline/ui/Timeline.svelte @@ -0,0 +1,336 @@ + + + + + diff --git a/template/src/widgets/version-footer/api/update-check.svelte.ts b/template/src/widgets/version-footer/api/update-check.svelte.ts new file mode 100644 index 0000000..475b72d --- /dev/null +++ b/template/src/widgets/version-footer/api/update-check.svelte.ts @@ -0,0 +1,15 @@ +import { createPoller, type Poller } from '@/shared/api'; + +export interface UpdateData { + current: string; + latest: string | null; + hasUpdate: boolean; +} + +// PRD-013 SC-3 / FR-008: poll registry once at app mount, then every 30 min. +const THIRTY_MINUTES_MS = 30 * 60_000; + +export const updatePoller: Poller = createPoller( + '/api/update-check', + THIRTY_MINUTES_MS, +); diff --git a/template/src/widgets/version-footer/index.ts b/template/src/widgets/version-footer/index.ts new file mode 100644 index 0000000..9978d2f --- /dev/null +++ b/template/src/widgets/version-footer/index.ts @@ -0,0 +1 @@ +export { default as VersionFooter } from './ui/VersionFooter.svelte'; diff --git a/template/src/widgets/version-footer/ui/UpdateButton.svelte b/template/src/widgets/version-footer/ui/UpdateButton.svelte new file mode 100644 index 0000000..49f41b2 --- /dev/null +++ b/template/src/widgets/version-footer/ui/UpdateButton.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/template/src/widgets/version-footer/ui/UpdateDialog.svelte b/template/src/widgets/version-footer/ui/UpdateDialog.svelte new file mode 100644 index 0000000..cdc4bd4 --- /dev/null +++ b/template/src/widgets/version-footer/ui/UpdateDialog.svelte @@ -0,0 +1,147 @@ + + + + {#snippet body()} +
+
+ current + v{current} +
+ +
+ latest + v{latest} +
+
+ +
+

Manual update

+
    +
  1. Stop the running server (Ctrl+C in its terminal).
  2. +
  3. Run the command below in the directory where you initialized @forgeplan/web.
  4. +
  5. Restart the server: npx @forgeplan/web start.
  6. +
  7. Reload this page.
  8. +
+ +

+ -y skips npx's install-confirmation prompt; + @latest forces npx to fetch the newest tarball + instead of running a stale cached copy. +

+
+ {/snippet} + + {#snippet footer()} + + {/snippet} +
+ + diff --git a/template/src/widgets/version-footer/ui/VersionFooter.svelte b/template/src/widgets/version-footer/ui/VersionFooter.svelte new file mode 100644 index 0000000..6e265c1 --- /dev/null +++ b/template/src/widgets/version-footer/ui/VersionFooter.svelte @@ -0,0 +1,95 @@ + + +{#if update?.hasUpdate && update.latest} + +{/if} + +{#if loaded} + + web v{web ?? '?'} + + cli {cli ? `v${cli}` : '?'} + +{/if} + + diff --git a/template/vite.config.ts b/template/vite.config.ts index 4767bd1..ce993b2 100644 --- a/template/vite.config.ts +++ b/template/vite.config.ts @@ -1,10 +1,24 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); +// PRD-012 / RFC-011: bake @forgeplan/web's package version at build time so the +// UI footer can render it without runtime I/O. Read from template/package.json +// (the only authoritative version source for the published app). +const TEMPLATE_PKG_VERSION = (() => { + try { + const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')); + return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + // FIXME(build): template/package.json unreadable — falling back to 0.0.0 + return '0.0.0'; + } +})(); + export default defineConfig(({ command, mode }) => { // TODO(dev-only): `npm run dev:playground` (vite --mode playground) // points at the repo-local playground/ workspace. Plain `npm run dev` @@ -17,7 +31,19 @@ export default defineConfig(({ command, mode }) => { return { plugins: [sveltekit()], + define: { + __FORGEPLAN_WEB_VERSION__: JSON.stringify(TEMPLATE_PKG_VERSION) + }, + // FIXME(prd-015-css-minify): lightningcss tree-shakes the + // `:root[data-theme='light']` block + the new `--canvas-*` tokens + // because they're only referenced from component-scoped CSS in + // separate chunks. Force esbuild for CSS minify until lightningcss + // gains cross-chunk custom-property awareness. + css: { + transformer: 'postcss' + }, build: { + cssMinify: 'esbuild', sourcemap: false }, server: {