From 0c8a7b9974895a5a27e9d60080c07a0a1022473a Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 9 Sep 2026 08:50:14 -0400 Subject: [PATCH] =?UTF-8?q?chore(agent):=20phase=2090-91=20roadmap=20from?= =?UTF-8?q?=20TODO.md=20=E2=80=94=20upload=20without=20scan=20+=20admin=20?= =?UTF-8?q?theme=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phases/todo/90_upload_no_scan/00_phase.md | 36 ++++++++++++++ .../01_upload_defers_scan.md | 36 ++++++++++++++ .../02_upload_ui_and_copy.md | 41 ++++++++++++++++ .../03_e2e_upload_then_sync.md | 26 ++++++++++ .../todo/91_admin_theme_tab/00_phase.md | 47 +++++++++++++++++++ .../01_ui_settings_store.md | 45 ++++++++++++++++++ .../02_inline_theme_injection.md | 37 +++++++++++++++ .../03_retire_css_file_theming.md | 34 ++++++++++++++ .../91_admin_theme_tab/04_theme_tab_shell.md | 38 +++++++++++++++ .../91_admin_theme_tab/05_theme_editor.md | 33 +++++++++++++ .../91_admin_theme_tab/06_e2e_theme_tab.md | 28 +++++++++++ 11 files changed, 401 insertions(+) create mode 100644 .agents/phases/todo/90_upload_no_scan/00_phase.md create mode 100644 .agents/phases/todo/90_upload_no_scan/01_upload_defers_scan.md create mode 100644 .agents/phases/todo/90_upload_no_scan/02_upload_ui_and_copy.md create mode 100644 .agents/phases/todo/90_upload_no_scan/03_e2e_upload_then_sync.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/00_phase.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/01_ui_settings_store.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/02_inline_theme_injection.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/03_retire_css_file_theming.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/04_theme_tab_shell.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/05_theme_editor.md create mode 100644 .agents/phases/todo/91_admin_theme_tab/06_e2e_theme_tab.md diff --git a/.agents/phases/todo/90_upload_no_scan/00_phase.md b/.agents/phases/todo/90_upload_no_scan/00_phase.md new file mode 100644 index 0000000..9602455 --- /dev/null +++ b/.agents/phases/todo/90_upload_no_scan/00_phase.md @@ -0,0 +1,36 @@ +# Phase 90 — Upload stops scanning: the RAG page's Sync button owns the scan + +**Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list. Update the button text as well to read "Upload" rather than "Upload and scan"" +**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-09). +**Context:** `POST /api/git-sources/upload` (`app/api/git_sources.py::upload_archive`) does three inline gates (1. name/format 422, 2. one-at-a-time 409, 3. 1 MiB-chunk stream into a dotfile temp with the `upload_max_mb` 413 cap), returns 202 + `UploadAccepted`, then runs `_run_upload` in the background: 4. unpack (traversal/symlink/corrupt/over-cap → `failed`), 5. atomic swap-in (same-name re-upload replaces in place), 6. row upsert by `path` (`kind='local'`, `added_at` preserved, the row's saved `ignore_paths` captured — phase 89), 7. fail-fast `check_models`, 8. `import_sources(…, prune=True, progress=, ignore_by_root=…)` + change-gated `regenerate_overview`, 9. INFO log line, 10. `success` with the `UploadOut` count fields in `detail`. `GET /api/git-sources/upload/status` mirrors the phase-32 sync-status key set (`state`, `started_at`, `finished_at`, `detail`, `error`, `current_file`, `files_done`, `files_total`) and is what `frontend/assets/git-sources.js` polls (2 s poll, `startUploadPolling`; boot re-attach `initUploadStatus`). The Sources view (`#view-git-sources`, static form in `frontend/index.html`, `#archive-upload-btn` labeled "Upload & scan") and the RAG view (`#view-rag`) where the admin-only `#sync-btn` ("Sync sources") lives; `app/api/sync.py::_run_sync` already mirrors every `git_sources` row, including `kind='local'` (the stored directory, re-verified, missing dir → `local source missing: `), honoring each row's `ignore_paths` (phase 89) — so a registered upload row is imported on the next sync with **no sync change**. The phase-89 per-row "Ignore paths" editor (`#ignore-editor-dialog` in `git-sources.js`) is the UI the deferral exists for: upload → row visible → edit ignore list → sync. + +## Objective +Uploading a source archive stores it, unpacks it, and registers the source row — and **nothing else**: no model check, no import, no overview regeneration. The scan is left to the "Sync sources" button on the RAG page, giving the owner time to edit the new source's ignore list first. The upload button reads "Upload" (was "Upload & scan") and all surrounding copy says the upload unpacks only and the sync scans. + +## Dependencies +- `89_source_ignore_paths` (complete) — the per-row ignore-list editor is the workflow this phase unlocks; the upload still captures the row's saved `ignore_paths` on re-upload. + +## Design (shared by all tasks — the executor reads this, not the chat) + +- **What the background run keeps (A1, owner-locked 2026-09-09).** Steps 1–6 unchanged: the three inline gates, the streaming receive, unpack (with all its sanitized `failed` states), the zero-entry `failed`, the atomic swap-in, the row upsert (existing row left as-is — `added_at` preserved, `ignore_paths` preserved — new row inserted with `kind='local'`). The scan = steps 7–8 (and their success detail) leave the upload entirely. +- **Status contract (A2, owner-locked 2026-09-09).** Same key set as today. Terminal `success` now carries `detail = {"message": "uploaded"}` (no count fields), `current_file = null`, `files_done = files_total = 0`. `failed` states unchanged in shape and wording (unpack/zero-entry/swap/row). `current_file`/`files_done`/`files_total` stay `null`/`0`/`0` for the whole background run (unpack has no file-level progress hook) — the UI's in-flight label loses its "(n/m)" file count for uploads (sync keeps its live file label; phase 64). +- **Sync unchanged (A4, owner-locked 2026-09-09).** `_run_sync` already imports `kind='local'` rows with prune + ignore lists; task 03 proves the full loop in E2E. No change to `app/api/sync.py` in this phase. +- **Copy (A3, owner-locked 2026-09-09).** Button label exactly **Upload** (static label, `restoreUploadButton`, and any relabel sites). The success moment keeps the phase-64 toast ("Successfully uploaded — ") but the settled result line points at the RAG page: "Uploaded — press **Sync sources** to import it." `#git-sources-hint` and the table caption are re-worded: uploads unpack and register only; the Sync button scans. + +## Tasks +1. `01_upload_defers_scan.md` — the background run stops after the row upsert; success status is a no-count "uploaded" payload; unit tests. +2. `02_upload_ui_and_copy.md` — "Upload" button, in-flight/result/toast copy, `#git-sources-hint` + caption; affected existing suites updated in place. +3. `03_e2e_upload_then_sync.md` — the story's dedicated Playwright E2E: upload → nothing indexed → edit ignore list → Sync sources on the RAG page → docs land, ignores honored. + +## Testing & Quality +- Unit/integration: the upload pipeline's new terminal states (row registered, KB untouched, no model call); the status payload shape; the sync-imports-a-fresh-upload-row path already covered by the sync suites (no change) — plus every affected existing test updated, none deleted without replacement. +- Coverage: **>90%** on new/modified code (`uv run pytest --cov=app --cov-report=term-missing`). +- This phase's Playwright E2E suite: `tests/e2e/test_upload_no_scan.py`, run in isolation (`uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov`). +- Affected existing E2E suites (`test_archive_upload_sources.py`, `test_sync_upload_progress.py`) updated in place and green. + +## Completion Criteria +- [ ] Uploading an archive on the Sources page settles with the "ready for sync" result and indexes **zero** documents; the source row is present with its ignore editor. +- [ ] The upload button reads "Upload"; no "Upload and scan" copy remains anywhere (`rg "Upload &" frontend/` → nothing). +- [ ] "Sync sources" on the RAG page imports the uploaded source (respecting its edited ignore list) — proven by the isolated E2E. +- [ ] Full test suite green, `app/` coverage >90%, `uv run ruff check . && uv run pyright` clean. +- [ ] One atomic Conventional Commits commit for the phase (`--no-gpg-sign`), `.agents/` phase files moved to `complete/` by the pipeline. diff --git a/.agents/phases/todo/90_upload_no_scan/01_upload_defers_scan.md b/.agents/phases/todo/90_upload_no_scan/01_upload_defers_scan.md new file mode 100644 index 0000000..683747a --- /dev/null +++ b/.agents/phases/todo/90_upload_no_scan/01_upload_defers_scan.md @@ -0,0 +1,36 @@ +# Task 01 — The upload's background run stops after the row upsert (no scan) + +**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list." +**Story:** n/a (TODO-derived). + +## Objective +`_run_upload` keeps the gates, streaming, unpack, swap-in, and row upsert — and drops the model check, the import, and the overview regeneration. The upload's job ends with the source row registered and the folder on disk; the RAG page's Sync button performs the scan. + +## Work +1. `app/api/git_sources.py` — `_run_upload` (the post-202 background pipeline): + - Keep steps 4–6 byte-for-byte in behavior: unpack (`unpack_archive` + zero-entry `failed`), atomic `swap_in`, the short-lived-session row upsert by `path` (existing row left as-is — `added_at` + `ignore_paths` preserved; new row `kind='local'`, `IntegrityError` backstop message unchanged). + - **Delete step 7** (the fail-fast `check_models` — its `ModelUnavailableError → failed` arm goes with it; model availability is the sync's concern) and **step 8** (`import_sources(…, prune=True, progress=…, ignore_by_root=…)` + the change-gated `regenerate_overview` call). + - Step 9: replace the scan INFO line with one line in the same PLAN §9 house shape for the upload leg: e.g. `upload: finished name= bytes= total_ms= state=` (unpack+register only — no file counts). + - Step 10: `success` state with `detail = {"message": "uploaded"}`, `current_file = None`, `files_done = files_total = 0` (A2 — the key set is unchanged; the UI composes the user copy). + - Rewrite the docstrings that describe the pipeline (`upload_archive`'s numbered docstring — renumber steps 4–6 + the new terminal; `_run_upload`'s docstring — drop the models/import failure modes, keep the unpack/zero-entry/swap/row ones; the module docstring if it summarizes the upload flow) — house style: the docstrings are the contract. + - Imports: drop what is now unused (`check_models` / `ModelUnavailableError`, `import_sources`, `regenerate_overview`, the `UploadOut`-related names if the schema goes — see 2.) so `ruff` stays clean. +2. `app/schemas.py` — if `UploadOut` (or the upload success-detail model) exists, remove it or reduce it to the new `{"message": "uploaded"}` shape — whichever keeps the schema surface honest; `UploadAccepted` (the 202 body) is unchanged. +3. `tests/unit/test_git_sources.py` — rework the upload pipeline tests to the new contract: + - success: the folder swapped in, the row upserted (new + same-name re-upload — `added_at` preserved, `ignore_paths` preserved), **no documents created, no chunks, no model calls** (assert via the fake LLM's call log / document count), status `success` with `detail == {"message": "uploaded"}` and `current_file is None`, `files_done == files_total == 0`. + - failure states unchanged: bad unpack, zero entries, swap failure, row `IntegrityError` — same sanitized `failed` messages; the `_upload_in_progress` flag released in `finally`. + - gates unchanged: format 422 (naming the accepted set), one-at-a-time 409, size 413. + - remove any assertion that the upload imports/prunes/embeds. + +- ASSUMPTION: (A1, owner-locked 2026-09-09) unpacking stays part of the upload — the folder must exist on disk for sync to walk it; only the model check + import are deferred. +- ASSUMPTION: (A2, owner-locked 2026-09-09) the `upload/status` key set is unchanged; the success `detail` carries `{"message": "uploaded"}` instead of import counts. +- ASSUMPTION: (A4, owner-locked 2026-09-09) no change to `app/api/sync.py` — it already imports `kind='local'` rows (prune + `ignore_paths`); the E2E (task 03) proves the loop. + +## Testing & Quality +- Unit/integration: the behaviors listed in Work item 3; the existing upload-status and sync suites stay green untouched. +- Coverage: **>90%** on this task's new/modified code. + +## Completion Criteria +- [ ] `uv run pytest tests/unit/test_git_sources.py -v --no-cov` green; no test asserts an upload-driven import. +- [ ] `rg "import_sources|check_models|regenerate_overview" app/api/git_sources.py` → no matches. +- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean. +- [ ] no behavior change in completed work (sync, CLI import, ignore paths all untouched). diff --git a/.agents/phases/todo/90_upload_no_scan/02_upload_ui_and_copy.md b/.agents/phases/todo/90_upload_no_scan/02_upload_ui_and_copy.md new file mode 100644 index 0000000..54f911a --- /dev/null +++ b/.agents/phases/todo/90_upload_no_scan/02_upload_ui_and_copy.md @@ -0,0 +1,41 @@ +# Task 02 — The Sources view says "Upload" and unpacks-only copy + +**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Update the button text as well to read "Upload" rather than "Upload and scan"" (plus the timing clause — "doesn't give the user time to edit the ignore list" — which the copy now names: edit the ignore list, then Sync). +**Story:** n/a (TODO-derived). + +## Objective +The upload affordance and every piece of surrounding copy match the new contract: the button reads **Upload**, the in-flight state covers unpacking only (no "(n/m)" file count), the settled result points the owner at the RAG page's Sync button, and the Sources-page hint/caption no longer claim uploads scan. Affected existing test suites are updated in place. + +## Work +1. `frontend/index.html`: + - `#archive-upload-btn` (in `#archive-upload-form`, `#view-git-sources`): label "Upload & scan" → **Upload** (A3 — exactly "Upload"). + - The phase-49/64 comment block above the form: re-point the contract summary — 202 at safe-on-disk, toast, processing state covers UNPACK only, success line points at Sync sources. + - `#git-sources-hint` (`role="note"`): reword the upload sentence — "Uploads unpack and register the source only — re-uploading the same filename replaces that source in place (no new folder, no duplicate row). Press **Sync sources** on the RAG page to scan it; edit the source's ignore paths first if you want files excluded." The removal/total-removal sentences stay as-is. + - The `#git-sources-table` caption: "…and uploaded archives (unpacked under the upload directory)" → drop any scan implication (unpacked + registered; the Sync button imports them). +2. `frontend/assets/git-sources.js` (the upload block, phase 64 section): + - `restoreUploadButton()`: `"Upload & scan"` → `"Upload"`. + - `fmtUploadResult(detail)`: the sync-style count shape is gone — replace with the new success copy: read `detail.message` (task 01's `{"message": "uploaded"}`) and render `Uploaded — press Sync sources to import it.` (the name from the accepted upload; keep the function's single-role: the `role=status` result line text). + - Poll tick (running branch): the label is now bare "Processing…" for the whole background run (unpack has no file-level progress — no "(n/m)", no file in the title); keep the 2 s cadence, the `uploadPollTimer` ownership, and the terminal branches (success → result line + announce + `loadSources`, no second toast; failed → sanitized banner + `loadSources`, file selection kept; idle → defensive restore). + - Toast (`showUploadToast` + its call site): unchanged wording ("Successfully uploaded — ") — it marks the 202, which still means "safely on disk". + - `initUploadStatus` boot re-attach: a running run re-enters the (now bare) processing state; a terminal `success` re-renders the new result line. + - Module header docstring (the phase 64/65 upload paragraph at the top of the file): update to the unpack-only contract, citing phase 90. + - The success announcement ("Archive uploaded: …" line) → reword to name the next step ("Archive uploaded — press Sync sources to import it."). +3. Affected existing suites — update in place (assert the NEW contract; do not delete coverage without a replacement): + - `tests/e2e/test_archive_upload_sources.py` — the upload leg: button text "Upload", 202 toast, processing without "(n/m)", settled "ready for sync" line, **no indexed documents after upload** (assert the RAG table is unchanged / stats 0 for the source), the source row present with its "Ignore paths" control; the gates (422/409/413) and re-upload-in-place assertions keep their intent against the new flow. + - `tests/e2e/test_sync_upload_progress.py` — the upload no longer shows live import progress: rework the upload legs to the unpack-only processing state; the SYNC progress assertions (live "Syncing… (n/m)" label on the RAG page) stay green — add a leg where the sync that follows an upload DOES show its live file label and lands the counts. + - `tests/unit/test_frontend_sync_upload.py` — the JS contract assertions it makes (labels, result-line shape, poll ownership) updated to the task-01/02 contract. +4. Copy sweep: `rg -n "scan" frontend/ | rg -iv "scanner"` — every remaining "scan" mention in the Sources/upload context must be re-pointed at the RAG page's Sync button (the RAG page's own copy already says it). + +- ASSUMPTION: (A3, owner-locked 2026-09-09) the button label is exactly "Upload" (the current "Upload & scan" and the TODO's "Upload and scan" both become "Upload"). +- ASSUMPTION: the "Successfully uploaded — " 202 toast wording is kept (phase-64 owner-locked A2 copy) — only the settled result line changes. + +## Testing & Quality +- Unit/integration: task 3's suite updates green; `uv run pytest tests/unit/test_frontend_sync_upload.py tests/e2e -v --no-cov` for the touched files. +- Coverage: **>90%** on this task's new/modified code (frontend JS is exercised by the E2E suites; keep any Python-side coverage untouched). + +## Completion Criteria +- [ ] `rg -n "Upload & scan|Upload and scan" frontend/ app/ tests/` → no matches. +- [ ] The upload flow in a running app: button "Upload" → 202 toast → bare "Processing…" → "Uploaded — press Sync sources to import it." with zero new documents indexed. +- [ ] `tests/e2e/test_archive_upload_sources.py` and `tests/e2e/test_sync_upload_progress.py` green (updated in place). +- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean. +- [ ] no behavior change in completed work (sync progress UI, gate behavior, re-upload in place all preserved). diff --git a/.agents/phases/todo/90_upload_no_scan/03_e2e_upload_then_sync.md b/.agents/phases/todo/90_upload_no_scan/03_e2e_upload_then_sync.md new file mode 100644 index 0000000..9f6f755 --- /dev/null +++ b/.agents/phases/todo/90_upload_no_scan/03_e2e_upload_then_sync.md @@ -0,0 +1,26 @@ +# Task 03 — Story E2E: upload → (no scan) → edit ignores → Sync sources + +**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list." +**Story:** n/a (TODO-derived — one story, one phase, one isolated E2E file: `tests/e2e/test_upload_no_scan.py`). + +## Objective +The phase's dedicated Playwright suite proves the whole deferred-scan loop end-to-end against the real pipeline: an upload indexes nothing; the owner edits the new source's ignore list; the RAG page's "Sync sources" button performs the scan and honors the ignores. + +## Work +1. `tests/e2e/test_upload_no_scan.py` (NEW — module header in the house style, story gate + isolated-run command + the fixtures it borrows): + - **Fixtures** — reuse the patterns from `tests/e2e/test_archive_upload_sources.py` (do NOT import that module; mirror its local helpers): per-module app env (`BOR_UPLOAD_DIR` → scratch dir, `BOR_GIT_SOURCES` forced empty, mock LLM, `db_ready`, per-test `_clean` truncate), `_build_targz` in-test archive, `_admin_git_sources_page` login helper, `_docs` reader. The archive: `e2e-upload-no-scan.tar.gz` → `alpha.md`, `beta.md`, `notes/skipme.md` (markdown sentinels `ALPHA-…` / `BETA-…` / `SKIPME-…`). No `slow_llm` proxy needed on the upload leg (the upload makes zero LLM calls now); the sync leg is short (2 files + 1 ignored) — poll the RAG page's sync status/`#sync-result` with a generous timeout instead of racing a timer. + - **Test 1 — upload does not scan:** admin Sources page → assert the button reads exactly **Upload**; upload the archive → 202 "Successfully uploaded — e2e-upload-no-scan" toast → settled result line "Uploaded e2e-upload-no-scan — press Sync sources to import it." → assert **no documents** indexed from the source (the RAG page's table is empty of them, `_docs` shows none, the source's folder exists on the host under `BOR_UPLOAD_DIR` with all three files). + - **Test 2 — ignore list, then Sync scans:** (fresh state via the autouse clean) upload the archive → success line → open the row's phase-89 "Ignore paths" editor → add `notes` → save (row shows the "1 ignored" count tag) → navigate to the RAG page (`/sources.html`) → click "Sync sources" (`#sync-btn`) → wait for the sync to settle (`#sync-result` announces the counts, button restored) → assert the RAG table lists `alpha.md` and `beta.md` for the source and **not** `notes/skipme.md`. + - **Test 3 — re-upload replaces without a scan:** (fresh state) upload the archive, success, then upload a v2 archive (same basename, `beta.md` modified, `gamma.md` added, `alpha.md` dropped) → success again with exactly one source row (in-place replace, phase-49 contract) and still **zero** documents indexed from it. +2. `tests/e2e/conftest.py` / shared helpers — touch only if a genuinely shared helper is missing (the plan above uses per-module local helpers, so expect no changes). + +- ASSUMPTION: (A4, owner-locked 2026-09-09) the sync backend already imports `kind='local'` rows with prune + `ignore_paths` — this suite is the proof; if it fails, the defect is fixed here or in `app/api/sync.py` as a minimal, documented correction (flag it in the task report). + +## Testing & Quality +- E2E in isolation (DB up: `podman compose up -d db`): `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` — green. +- Coverage: this task adds no `app/` code — the phase coverage gate (>90%) is satisfied by tasks 01/02. + +## Completion Criteria +- [ ] `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` green (3 tests). +- [ ] The suite proves: zero docs after upload; ignore edit before sync is honored by the sync; re-upload stays in-place and scan-free. +- [ ] no behavior change in completed work. diff --git a/.agents/phases/todo/91_admin_theme_tab/00_phase.md b/.agents/phases/todo/91_admin_theme_tab/00_phase.md new file mode 100644 index 0000000..51f6d4e --- /dev/null +++ b/.agents/phases/todo/91_admin_theme_tab/00_phase.md @@ -0,0 +1,47 @@ +# Phase 91 — Admin Theme tab: pickers + fields, pre-paint theme, CSS-file theming retired + +**Source:** `TODO.md` L4 — "Custom theming isn't really working. The page loads red first and then the theme "pops" into view, replacing words and colors in an obvious way. Remove the custom css file theming. Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers. Theme should load immediately, not pop in after the page load." +**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-09). +**Context:** Today's theming: `BOR_THEME=.css` (bare-filename validated at boot in `app/config.py::_theme_bare_css_filename`, fail-loud) → served by `GET /api/config` → `frontend/assets/brand.js` step 7 inserts `` **after the boot fetch settles** — so the page paints with the built-in red-first palette, then the theme's `:root` overrides swap in: the pop-in the owner saw. A theme file is one `:root` block overriding the **8 identity variables** (`--bg`, `--surface`, `--ink`, `--ink-soft`, `--line`, `--brand`, `--brand-soft`, `--brand-ink` — built-ins in `frontend/assets/styles.css`; authoring guide + the 5 contrast pairs in `frontend/assets/themes/README.md`; the semantic families `--accent-*`/`--ok-*`/`--err-*` are deliberately NOT identity). The env vars also carry 3 strings the same boot fetch applies via `brand.js`: `BOR_APP_NAME` (name passes incl. the TreeWalker prose replace), `BOR_INPUT_PLACEHOLDER`, `BOR_FOOTER_TEXT` (empty = template default = byte-identical contract). `Containerfile` line 27 ships `assets/themes` into the image. **Admin-tab pattern** (Tuning/Tokens, phases 76/79): a hidden `