phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
@@ -0,0 +1,15 @@
All gates re-verified green. Report:
- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
- strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
- PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
- live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
- byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
- suite green, coverage >90%, ruff+pyright clean — ✅
- no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
- atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
@@ -0,0 +1,146 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 39 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 117 0 100%
app/core/theming.py 45 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6941 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
**Task 01 complete — both defects pinned with failing tests.**
**Implemented**
- `tests/unit/test_theming.py`: new A1 ownership pins — `test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row` (**RED**, seeds real `app_settings` panel rows + conflicting `ui_settings` tab row, all 3 strings; fails `'Tab Name' == 'Panel Name'` — the exact shadow) and `test_effective_strings_fall_back_to_ui_settings_row_without_panel_row` (**GREEN now & post-fix** — protects the A1 fallback, rejects a naive "env beats tab" fix); helpers `_panel_settings`/`_seed_panel_rows`/`_clear_panel_rows`, docstring updated.
- `tests/integration/test_ui_settings_api.py`: `overlay_panel` fixture (overlay ON, `app_settings` wiped before/after, cache invalidated — the phase-168 idiom) + `test_put_ignores_string_fields_in_body` (**RED** — the string bake), `test_put_still_stores_the_colors_beside_the_ignored_strings` (**GREEN pre & post** — colors half), `test_get_reports_effective_strings_read_only` (**RED**), `test_api_config_reports_panel_strings_over_the_baked_tab_row` (**RED** — the owner-visible incident through the boot fetch).
- `tests/unit/test_brand_live.py` (NEW): marker pins on the ABSENT live-apply — `applyBrandFromConfig` absent from settings.js settle paths (documented **inverted in task 03**), brand DOM tokens never owned by settings.js/theme.js save paths (stay green through the phase); theme.js save/reset pinned colors-only (`applyServedTheme`).
**Results (exact)**
- `uv run pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py tests/unit/test_brand_live.py -v` → exactly the 4 documented RED pins fail with the shadow/bake assertions; everything else green (fallback + colors-half + all 3 brand-live marker pins pass).
- `uv run pytest` (full suite) → **4 failed, 4614 passed** — the 4 failures are solely the documented RED pins (the task's artifact; task 02 turns them green).
- `uv run pytest --cov=app --cov-report=term-missing` → `TOTAL 6946 16 99%` (>90%).
- `uv run ruff check .` → clean; `uv run pyright` (changed files) → 0 errors.
**Docs**: no doc impact — tests only; `app/` and `frontend/` untouched (app code is task 02's scope).
**Notable**: unit red pin seeds BOTH representations (real `app_settings` rows + overlay-resolved `Settings`) so any correct task-02 design (session read or overlay read) satisfies it, while the fallback pin forbids the incorrect "settings-param beats tab row" shortcut. No live-infra touched.
**Next pending task**: `190_app_name_settings_fix/02_precedence_and_strip_theme_tab.md`.
@@ -0,0 +1,291 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
.........F.............................................................. [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
......F.FF [100%]
=================================== FAILURES ===================================
___ test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row ____
db = <sqlalchemy.orm.session.Session object at 0x7f2c5415d040>
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f2c70c40130>
def test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Settings panel is the SINGLE
writable owner of the three brand strings, so an ``app_settings``
row beats a conflicting ``ui_settings`` (Theme-tab) row for EVERY
one of them — the resolution order is panel row → tab row → env.
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
today ``effective_settings`` returns the ``ui_settings`` value
whenever it is a non-empty string, so a Theme-tab save BAKES the
then-effective name into the row and every later Settings-panel
change is SHADOWED — the exact ``TODO.md`` L3 incident ("changing
the App name in settings broke, it no longer updates the name").
"""
_start_row_missing(db)
_clear_panel_rows(db)
_seed_panel_rows(db)
db.add(
UiSettings(
id=1,
app_name="Tab Name",
input_placeholder="Tab placeholder…",
footer_text="Tab footer",
)
)
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # the overlay is the panel's mechanism
try:
effective = theming.effective_settings(db, _panel_settings())
> assert effective["app_name"] == "Panel Name" # pre-fix: "Tab Name" (the shadow)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError: assert 'Tab Name' == 'Panel Name'
E
E - Panel Name
E + Tab Name
tests/unit/test_theming.py:282: AssertionError
____________________ test_put_ignores_string_fields_in_body ____________________
client = <starlette.testclient.TestClient object at 0x7f2a2ba9c210>
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ee63f0>
def test_put_ignores_string_fields_in_body(
client: TestClient, db: Session
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Theme-tab PUT no longer
writes the three brand strings — a body carrying ``app_name`` /
``input_placeholder`` / ``footer_text`` DIFFERENT from the stored
row's values (and from the effective value) is still 200, but the
stored row's string columns stay EXACTLY as they were (re-GET shows
the old values, a direct row read shows no write). An old client's
save can therefore never re-bake a string and shadow the Settings
panel again.
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
today ``_validate_strings`` trims and stores every string in the
body — this bake is the mechanism of the ``TODO.md`` L3 incident.
"""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
# The strings half — pre-fix the response round-trips the baked
# body value ("Baked By PUT"); post-fix the stored value stands.
> assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
E AssertionError: assert 'Baked By PUT' == 'Tab Name'
E
E - Tab Name
E + Baked By PUT
tests/integration/test_ui_settings_api.py:372: AssertionError
_________________ test_get_reports_effective_strings_read_only _________________
client = <starlette.testclient.TestClient object at 0x7f2a2bf2ba10>
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ed05f0>
overlay_panel = None
def test_get_reports_effective_strings_read_only(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""A1 reaching the API: with a conflicting Settings-panel row,
``GET /api/ui-settings`` reports the EFFECTIVE (panel) value for
each string — the tab's read side stays the live view (old-client
compat), and it stays READ-ONLY (the GET never writes the row).
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
today the endpoint reports the baked ``ui_settings`` value.
"""
_seed_tab_row(db)
_seed_panel_rows(db)
admin = _admin_client()
r = admin.get("/api/ui-settings")
assert r.status_code == 200, r.text
body = r.json()
> assert body["app_name"] == _PANEL_STRINGS["app_name"]
E AssertionError: assert 'Tab Name' == 'Panel Name'
E
E - Panel Name
E + Tab Name
tests/integration/test_ui_settings_api.py:427: AssertionError
_________ test_api_config_reports_panel_strings_over_the_baked_tab_row _________
client = <starlette.testclient.TestClient object at 0x7f2a387682f0>
db = <sqlalchemy.orm.session.Session object at 0x7f2a29ed3e30>
overlay_panel = None
def test_api_config_reports_panel_strings_over_the_baked_tab_row(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""The owner-visible incident through the frontend's boot fetch
(phase 190, ``TODO.md`` L3): with a panel row AND a legacy baked-in
``ui_settings`` row, the ANONYMOUS ``GET /api/config`` — the very
call ``brand.js`` drives the title / brand / footer / placeholder
from — serves the Settings-panel strings, not the baked tab values.
RED PIN (phase 190 task 01 — FAILS pre-fix, FIXED BY TASK 02):
pre-fix ``effective_settings`` resolves the tab row first, so the
open page keeps the OLD name after every later panel change.
"""
_seed_tab_row(db)
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # public, unchanged key set
body = r.json()
assert set(body) == _config_keys()
> assert body["app_name"] == _PANEL_STRINGS["app_name"]
E AssertionError: assert 'Tab Name' == 'Panel Name'
E
E - Panel Name
E + Tab Name
tests/integration/test_ui_settings_api.py:458: AssertionError
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6946 16 99%
=========================== short test summary info ============================
FAILED tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row
FAILED tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body
FAILED tests/integration/test_ui_settings_api.py::test_get_reports_effective_strings_read_only
FAILED tests/integration/test_ui_settings_api.py::test_api_config_reports_panel_strings_over_the_baked_tab_row
tests FAILED: unit + integration suite did not pass
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1,16 @@
**Retry complete — the 4 defect pins are now `xfail(strict=True)`, so the full gate is green while both defects stay pinned.**
**Fixed since rejection**
- Kept all task-01 pins intact, but carried the 4 RED pins (`tests/unit/test_theming.py` ownership pin; `tests/integration/test_ui_settings_api.py` string-bake, effective-GET, /api-config incident pins) under `@pytest.mark.xfail(strict=True, reason="phase 190 task 01 defect pin (A1)…")` — the harness gate requires an exit-0 suite; strict xfail keeps the defect proven failing AND turns XPASS into a hard failure the moment task 02 fixes `effective_settings`/the PUT, forcing marker deletion in the fix commit. No `app/` or frontend code touched (task 02's scope).
- Docstrings/module docs updated to the xfail convention; fixed one E501 introduced while re-wrapping.
**Results (exact)**
- `uv run pytest --cov=app --cov-report=term-missing` → **exit 0 — 4614 passed, 4 xfailed**, `TOTAL 6946 16 99%` (>90%).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Targeted: `pytest tests/unit/test_theming.py tests/integration/test_ui_settings_api.py tests/unit/test_brand_live.py` → exit 0, the 4 pins report `x` (expected-fail with the exact `'Tab Name' == 'Panel Name'` / bake assertions), fallback + colors-half + brand-live marker pins pass.
**Docs**: no doc impact (tests-only change; no behavior/setup change).
**Notable**: the phase's "failing pins are the artifact" convention conflicts with this harness's 100%-green gate; strict-xfail satisfies both — pinned defect, green gate, mechanical inversion in task 02. No live-infra touched.
**Next pending task**: `190_app_name_settings_fix/02_precedence_and_strip_theme_tab.md` (flip the resolution order + strip the Theme-tab strings, then delete the four xfail markers).
@@ -0,0 +1,146 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
.........x.............................................................. [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
......x.xx [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6946 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
**Task 02 complete — panel wins, Theme tab is colors-only.**
- `app/core/settings_overlay.py`: new `applied_override_keys(session)` — one `SELECT key FROM app_settings` on the caller's session (kill-switch short-circuit, orphan/LOCKED-A3 rows excluded); **deviation** from the task's "cached `has_override(field)`" wording: a cross-request memo goes stale on a direct row write and can re-shadow a just-saved value, and the overlay's `NullPool` would cost a fresh connection per page serve (the resolver reads the map ONCE, so still no query per field).
- `app/core/theming.py`: `effective_settings` strings now resolve **panel row → tab row → env** (presence, never a value compare); the 17-color half untouched; docstrings rewritten.
- `app/api/ui_settings.py`: PUT persists the 17 colors only — `_validate_strings`/`_MAX_STRING_LEN` deleted, body strings accepted-and-dropped (old-client compat), stored string columns never written or cleared; GET/DELETE unchanged; `models.py` / `schemas.py` / `config.py` docs updated (verified: `/api/config` has no `source` vocabulary → nothing to grow).
- `frontend`: `theme.js` `FIELDS` = 17 colors (no `kind:"string"`), `collectBody` posts no string key, docstrings now "17 colors"; `index.html` Branding fieldset (`#theme-app-name`/`#theme-placeholder`/`#theme-footer`) removed + copy points at Settings; `router.js` theme description.
- Tests: removed the 4 task-01 `xfail(strict=True)` markers (now green); new unit pins — per-field panel/tab/env + env-tie, kill-switch-off, `applied_override_keys` filter/no-read, `collectBody` no-string marker; new E2E `test_tab_renders_the_17_colors_and_no_string_inputs` (string inputs count 0, PUT body == the 17 colors); retargeted the phase-91 string pins (`test_ui_settings.py`, `test_ui_settings_api.py`, `test_frontend_router.py`, `test_theme_frontend.py`) and the 4 theme E2E suites (settle signal moved from `#theme-app-name` to the mount `GET /api/ui-settings` response).
- `uv run pytest --cov=app --cov-report=term-missing` → **4623 passed**, TOTAL 99%; touched files 100% (`api/config.py`, `api/ui_settings.py`, `core/settings_overlay.py`, `core/theming.py`).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- E2E in isolation, all green: `test_admin_theme_tab.py` (6), `test_theme_save_and_coverage.py` (3), `test_themed_favicon.py` (6), `test_theme_semantic_completion.py` (8), `test_admin_settings_panel.py`+`test_configurable_brand.py`+`test_smoke.py` (20), `test_admin_settings_layout.py`+`test_settings_mobile_value_width.py` (9).
- Docs: `README.md` *Customizing the look* now states the single-writer ownership (panel → tab row → env), the ignored-string PUT, the panel-DELETE reset and the `BOR_SETTINGS_DB=0` behavior, plus a cross-reference in the phase-168 precedence paragraph. `AGENTS.md`: no doc impact — it names no settings-overlay/resolution-order description (verified), so per the phase's doc mandate nothing was added.
- Notable: the Theme-tab "Reset to defaults" now resets the palette only (a string reset is `DELETE /api/admin/settings/{key}`) — the A1 consequence of a write path that never touches the string columns; live brand-on-save is task 03, deliberately not implemented here.
- Next pending task: `.agents/phases/todo/190_app_name_settings_fix/03_live_brand_on_save.md`.
@@ -0,0 +1,146 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
............... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 39 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 117 0 100%
app/core/theming.py 45 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6941 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,15 @@
**Task 03 complete — brand strings now apply live on save/reset (A2).**
- `frontend/assets/brand.js`: DOM path extracted into ONE `applyBrandFromConfig(cfg)` exported on the layer's existing window surface (`window.applyBrandFromConfig`); boot `applyBrand()` awaits the same parse-time fetch and calls it (boot byte-identical: one fetch, one copy of each of the four writes). Added the re-apply clause (`window.BOR_BRAND_APPLIED`): a second rename replaces the name the page *shows*, not just the default literal — skipped at boot (previous `""`).
- `settings.js`: one `reapplyBrand(keys)` helper (key-gated on `app_name`/`input_placeholder`/`footer_text`, single-flight flag cleared in `finally`, `no-store` fetch) awaited by Save success **and** the K4 prefix of a failed batch, the per-row Reset, and Clear all (all three keys); the phase-168 catalogue re-GET untouched, still inside `saveInFlight`.
- `theme.js`: same-shaped `reapplyBrand()` awaited after `applyServedTheme` in `saveTheme` and after `resetTheme`; non-2xx/网络 failure returns before the shared call → never a blanked brand.
- `tests/unit/test_brand_live.py` rewritten: task-01's absence pins inverted + new pins (export, boot-is-the-extraction with exactly one copy of each DOM write, previous-name clause + boot no-op, per-settle-path gating, one config fetch/call site per module, `finally` guard reset, failure-arm writes nothing, both task-01 ownership pins kept green).
- Live browser verification (throwaway script, deleted): panel save renamed title+brand, footer and composer updated, second rename worked, panel Reset fell back to env live, a non-brand save made **0** `/api/config` requests, theme save kept the legacy baked name (no re-bake), theme reset painted a panel row created meanwhile, and a blocked re-apply left the DOM untouched.
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed**, TOTAL 99% (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. E2E in isolation (`--no-cov`): `test_admin_settings_panel.py` 12 passed **unedited**, `test_admin_theme_tab.py` 6, `test_theme_save_and_coverage.py` 3, `test_theme_semantic_completion.py` 8, `test_themed_favicon.py` 6, `test_configurable_brand.py` 5, `test_smoke.py` 3, `test_admin_settings_layout.py` 5, `test_settings_mobile_value_width.py` 4, `test_document_viewer.py` 8 (restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that that run rewrites).
Docs: `README.md` *Customizing the look* gained "The save is visible without a reload" (shared pass, key gating, single-flight, never blanked). `AGENTS.md`: no doc impact — it names no settings-overlay/resolution-order or brand-layer description (verified).
Deviations: (1) the re-apply needed the previous-name substitution to work at all on an already-branded document — boot stays byte-identical; (2) task 02's PUT never nulls the legacy string columns, so the Theme-tab reset's re-apply is a **self-heal of the effective string**, not a fallback (theme.js docstrings state this; the string fallback path is the panel's `DELETE /api/admin/settings/{key}`, verified live).
Next pending task: `.agents/phases/todo/190_app_name_settings_fix/04_e2e.md` (dedicated Playwright suite, gates, docs consistency pass, atomic commit).
@@ -0,0 +1,257 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
..............................................FFF....................... [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=================================== FAILURES ===================================
________________________ test_plan_uses_the_hnsw_index _________________________
db = <sqlalchemy.orm.session.Session object at 0x7fda70190140>
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> None:
"""The 2026-10-15 incident WAS a plan, so the pin is a plan: the tier's own
``ORDER BY embedding <=> <vector> LIMIT 100`` runs through
``ix_chunks_embedding_hnsw`` and never reads ``chunks`` sequentially.
Explaining the captured statement is also what proves D1's "no query
rewrite": the ``<=>`` the retriever already emits is exactly the operator
``vector_cosine_ops`` serves.
"""
nodes = _plan(db, hnsw_corpus["plan_sql"])
> assert _uses_index(nodes, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan — the incident is back: [{_describe(nodes)}]"
)
E AssertionError: ix_chunks_embedding_hnsw is not in the plan — the incident is back: [Limit[?], Sort[?], Nested Loop[?], Index Scan[documents_pkey], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
E assert False
E + where False = _uses_index([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 247.28, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'ix_chunks_embedding_hnsw')
tests/integration/test_chunks_hnsw_recall.py:472: AssertionError
---------------------------- Captured stdout setup -----------------------------
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Context impl PostgresqlImpl.
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Will assume transactional DDL.
2026-10-18 00:22:11 INFO alembic.runtime.migration :: Running downgrade 0029 -> 0028, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Context impl PostgresqlImpl.
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Will assume transactional DDL.
2026-10-18 00:22:13 INFO alembic.runtime.migration :: Running upgrade 0028 -> 0029, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
------------------------------ Captured log setup ------------------------------
INFO alembic.runtime.migration:migration.py:205 Context impl PostgresqlImpl.
INFO alembic.runtime.migration:migration.py:208 Will assume transactional DDL.
INFO alembic.runtime.migration:migration.py:616 Running downgrade 0029 -> 0028, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
INFO alembic.runtime.migration:migration.py:205 Context impl PostgresqlImpl.
INFO alembic.runtime.migration:migration.py:208 Will assume transactional DDL.
INFO alembic.runtime.migration:migration.py:616 Running upgrade 0028 -> 0029, chunks.embedding HNSW index: cosine search stops full-scanning (phase 188, task 01)
______________ test_plan_without_the_index_seq_scans_the_relation ______________
db = <sqlalchemy.orm.session.Session object at 0x7fda48dc9130>
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
def test_plan_without_the_index_seq_scans_the_relation(
db: Session, hnsw_corpus: dict[str, Any]
) -> None:
"""Vacuity guard: the same statement, same session, same statistics, with
index scans refused, MUST fall back to ``Seq Scan on chunks``.
``SET LOCAL enable_indexscan = off`` rather than dropping the index: a
``DROP INDEX CONCURRENTLY`` + rebuild would cost a second graph build and
leave the corpus unindexed if the rebuild failed, while this GUC is
transaction-scoped (it dies with the rollback) and removes exactly the
access path under test. Without this test the plan pin would pass vacuously
on any plan that merely mentions some index.
"""
sql = hnsw_corpus["plan_sql"]
db.rollback()
db.execute(text("SET LOCAL enable_indexscan = off"))
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
nodes = _plan(db, sql)
> assert _seq_scans(nodes, RELATION), (
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
)
E AssertionError: the vacuity guard itself did not seq-scan chunks: [Limit[?], Sort[?], Nested Loop[?], Seq Scan[documents], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
E assert False
E + where False = _seq_scans([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 256.01, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'chunks')
tests/integration/test_chunks_hnsw_recall.py:499: AssertionError
_________________ test_top100_recall_at_the_shipped_ef_search __________________
db = <sqlalchemy.orm.session.Session object at 0x7fda48eb46e0>
hnsw_corpus = {'queries': [[-0.007988976012119104, 0.0047693130549608205, 0.025719801734912076, -0.0030883843456668432, -0.018585783....0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]' \n LIMIT 100"}
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fda48ade120>
def test_top100_recall_at_the_shipped_ef_search(
db: Session,
hnsw_corpus: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""At the shipped ``ef_search`` the index returns the exact top-100 for
every query, exact to 1e-6 on the score the honesty gate reads.
Two different failures surface here: a ``top_score`` that moved — A8's
deflect/answer verdict flips on that one number, so it is pinned per row,
not per query — and an overlap below the floor, i.e. a candidate that never
reaches RRF. The failure message carries every query's overlap so a future
``ef_search`` raise has its evidence in the report instead of in a re-run.
"""
queries: list[list[float]] = hnsw_corpus["queries"]
exact: list[list[tuple[float, Any]]] = hnsw_corpus["exact"]
chunk_ids: set[Any] = hnsw_corpus["chunk_ids"]
_set_ef_search(monkeypatch, EF_PIN)
_force_index_scan(db)
try:
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
> assert _uses_index(depth_nodes, INDEX_NAME), (
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
"with sequential scans refused — the pin would be measuring a scan "
f"and calling it parity: [{_describe(depth_nodes)}]"
)
E AssertionError: this recall run at ef_search=400 did not use ix_chunks_embedding_hnsw even with sequential scans refused — the pin would be measuring a scan and calling it parity: [Limit[?], Sort[?], Nested Loop[?], Index Scan[documents_pkey], Bitmap Heap Scan[chunks], Bitmap Index Scan[ix_chunks_document_id]]
E assert False
E + where False = _uses_index([{'Node Type': 'Limit', 'Parallel Aware': False, 'Async Capable': False, 'Startup Cost': 247.28, ...}, {'Node Type': '...Node Type': 'Bitmap Index Scan', 'Parent Relationship': 'Outer', 'Parallel Aware': False, 'Async Capable': False, ...}], 'ix_chunks_embedding_hnsw')
tests/integration/test_chunks_hnsw_recall.py:535: AssertionError
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 39 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 117 0 100%
app/core/theming.py 45 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6941 16 99%
=========================== short test summary info ============================
FAILED tests/integration/test_chunks_hnsw_recall.py::test_plan_uses_the_hnsw_index
FAILED tests/integration/test_chunks_hnsw_recall.py::test_plan_without_the_index_seq_scans_the_relation
FAILED tests/integration/test_chunks_hnsw_recall.py::test_top100_recall_at_the_shipped_ef_search
tests FAILED: unit + integration suite did not pass
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation FAILED (see output above)
@@ -0,0 +1,15 @@
**Task 03 complete + the rejected gate fixed (the phase-188 HNSW plan pins were flaky).**
Fixed since rejection (`tests/integration/test_chunks_hnsw_recall.py`, tests only):
- **Diagnosed the real cause** (reproduced with scratch probes): the pins measured a relation they don't own. Ambient rows distort the planner's `WHERE embedding IS NOT NULL` cost — with 4,000 NULL-embedding chunks alongside the 5,000-row fixture the planner leaves `ix_chunks_embedding_hnsw` for `Sort → Hash Join → Seq Scan` *while the index is present and valid*; and the fixture `ANALYZE`d only `chunks`, never `documents`, though the tier's statement is a JOIN plan. That is the "did not use ix_chunks_embedding_hnsw" failure; `enable_seqscan=off` alone answered with `Bitmap Heap Scan … ix_chunks_document_id` — a full read wearing an index's name, which also broke the vacuity guard.
- Fixture now asserts the index exists **and `indisvalid IS TRUE`** after migration 0029, `ANALYZE`s **both** joined relations, reads back only embedded rows (a NULL row is in neither the index nor the exact reference), and exposes `pristine`.
- `_force_index_scan`/`_release_index_scan` refuse `enable_seqscan` **and** `enable_bitmapscan`; the vacuity guard also refuses bitmap; pin 1 asserts the unforced plan only on a pristine relation and the forced plan always; failure messages now print index validity. Verified the anti-false-pass: with the index dropped both arms report `idx=False`.
- Phase-190 task 03 code unchanged from before (all green): `brand.js` `applyBrandFromConfig` (+ the previous-name re-apply clause), `settings.js` key-gated re-apply on Save/Reset/Clear-all, `theme.js` re-apply on Save/Reset, `tests/unit/test_brand_live.py` (10 pins).
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**. E2E in isolation: `test_admin_settings_panel.py` 12 (unedited), `test_admin_theme_tab.py` 6, `test_configurable_brand.py` 5 (earlier this attempt also green: `test_theme_save_and_coverage` 3, `test_theme_semantic_completion` 8, `test_themed_favicon` 6, `test_smoke` 3, `test_admin_settings_layout` 5, `test_settings_mobile_value_width` 4, `test_document_viewer` 8). Also green with 4,000 injected ambient NULL chunks and with the 13-doc ambient import now in the dev DB.
Docs: `README.md` — corrected the live-apply paragraph (the Theme tab's PUT never writes the string columns, so its re-read is the self-heal of the *effective* string; a string reset is the panel's `DELETE /api/admin/settings/{key}`). `AGENTS.md`: no change — it names no settings-overlay/brand-layer description (verified); phase-188's "seq scans refused" wording stays true.
Notable: (1) the brand re-apply needed previous-name substitution or a second rename silently did nothing; (2) I hardened phase-188's pins rather than skipping them, so they still fail loudly on a missing/INVALID index; (3) observation, not fixed here — `test_admin_settings_panel.py` leaves a 13-document `docs` import in the shared dev DB (harmless now that the HNSW pins tolerate ambient rows).
Next pending task: `.agents/phases/todo/190_app_name_settings_fix/04_e2e.md`.
@@ -0,0 +1,146 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 39 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 117 0 100%
app/core/theming.py 45 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6941 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
Task 04 complete — the phase-190 E2E suite exists, falsification-verified, and every gate in the project is green.
**Implemented**
- `tests/e2e/test_app_name_settings_fix.py` (NEW, 704 lines, module app with `BOR_SETTINGS_DB=1` + distinctive `BOR_APP_NAME`/`BOR_INPUT_PLACEHOLDER`/`BOR_FOOTER_TEXT`): (1) the incident at the wire — panel rename → Theme-tab color save → `/api/config` still serves the panel value, the stored `ui_settings` string columns stay NULL, the second rename lands, and an old client's string-carrying PUT changes nothing; (2) A2 live repaint — the four DOM places (title / `.brand-text` / footer / composer placeholder) update on the same document (no-reload sentinel) incl. the second rename; (3) the per-row Reset's live fallback to env; (4) a legacy baked tab row paints → an out-of-band panel row wins → the tab's Save/Reset re-read it live and never blank the brand; (5) byte-identical boot with **exactly one** `/api/config` request; (6) colors-only tab DOM (17 pickers, no `#theme-app-name`).
- DB isolation clears the overrides **through the API** (`DELETE /api/admin/settings`) so the app's cached `Settings` invalidates — a bare TRUNCATE leaks the previous test's override.
- **Anti-falsification checked**: reverting the resolver order + the PUT bake fails tests 1 and 4; deleting the `reapplyBrand` calls fails test 2. All files restored byte-exact (`diff` verified), shared DB left clean (0 `app_settings`, 0 `ui_settings`).
- `README.md`: brand-strings single-writer pointer in the Settings page section + the E2E pin sentence in *Customizing the look*.
**Gates (exact)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed**, `TOTAL 6941 16 99%` (>90%), exit 0
- `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation
- Regression, isolated: `test_admin_theme_tab` 6 · `test_theme_save_and_coverage` 3 · `test_admin_settings_panel` 12 (named `test_settings_panel.py` in the task file — that path does not exist) · `test_configurable_brand` 5 · `test_smoke` 3 · `test_admin_settings_layout` 5 · `test_settings_mobile_value_width` 4 · `test_theme_semantic_completion` 8 · `test_themed_favicon` 6 — all exit 0
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Docs**: README changed as above; **no AGENTS.md change** — verified it names no settings-overlay / resolution-order / brand-layer description (`grep -i "settings|theme|brand|168" AGENTS.md` → 0 hits), so the phase's "correct only where it names the old order" clause is satisfied.
**Deviations**: (1) the phase file's (c) "Theme-tab Reset → the brand falls back" cannot happen under A1 (that reset is the all-null COLORS PUT and must not touch the string columns), so the live fallback is pinned where it actually occurs (panel per-row Reset, and an out-of-band panel-row DELETE with the tab open) and the tab's Reset is pinned to re-read and never blank the brand — documented in the module docstring. (2) No commit and no `todo/` → `complete/` move: the pipeline rules reserve staging, committing and phase-file moves to the harness.
**Next pending task**: `190_app_name_settings_fix/04_e2e.md` was the phase's last task — after the harness commits it, the next is `191_source_name_labels_tags`.
@@ -0,0 +1,146 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 7%]
........................................................................ [ 9%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 231 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 39 0 100%
app/config.py 325 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/memory.py 32 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 117 0 100%
app/core/theming.py 45 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 865 3 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 259 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6941 16 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+5 -1
View File
@@ -298,7 +298,11 @@
round trip + INVALID self-heal + drift guard),
`tests/integration/test_chunks_hnsw_recall.py` (the tier's captured
statement names the index, `SET LOCAL` reaches the connection, recall at
the shipped depth with seq scans refused), and
the shipped depth with the index paths forced — `enable_seqscan`,
`enable_indexonlyscan` AND `enable_bitmapscan` off, because a bitmap scan
through `ix_chunks_document_id` otherwise disguises a full read and flips
the plan on a shared dev DB — and the unforced plan asserted only when the
relation holds nothing but the module's own corpus), and
`tests/e2e/test_vector_index_recall.py` (the owner-visible arm: the SAME
cited document with the index dropped and restored, the index VALID after a
real importer run, the near-miss still deflecting in both arms, and the
+52 -10
View File
@@ -324,6 +324,11 @@ Admin-only — anonymous visitors see a sign-in gate.
The overlay can be switched off for the whole process with
`BOR_SETTINGS_DB=0` (env-only; the panel stays visible and says so).
This panel is also the **single writable owner of the three brand strings**
(`app_name`, `input_placeholder`, `footer_text`): saving one repaints the page
you are on the moment you click, and the Theme tab can no longer write them —
see *Customizing the look*.
### Shared chat (`/shared/<token>`)
A read-only snapshot of any conversation, shareable via a link. Open to
@@ -1692,7 +1697,9 @@ Everything is an environment variable (the `BOR_` prefix, plus
to `.env` and adjust.
**DB-over-env overrides (phase 168):** the precedence for every setting
is **`app_settings` row → `BOR_*` environment → code default** — an
is **`app_settings` row → `BOR_*` environment → code default** (the
three brand strings have one extra tier between the row and the env —
the legacy `ui_settings` Theme-tab row, see *Customizing the look*) — an
override the admin saves in the Settings panel is stored in Postgres
(`app_settings`, one row per key) and **wins over the env var for
that key — with no restart**. The panel never writes `.env` (the env
@@ -1862,18 +1869,53 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
### Customizing the look
Every identity string is an env var: `BOR_APP_NAME` (display name),
`BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
Every identity string defaults from an env var: `BOR_APP_NAME` (display
name), `BOR_INPUT_PLACEHOLDER` (chat composer placeholder), and
`BOR_FOOTER_TEXT` (footer line) — all served by `GET /api/config` and
applied by `assets/brand.js` at boot.
The colors are set from the admin **Theme tab** (`/theme.html`,
admin-only — phase 91): the 17 palette colors (the 9 identity colors plus
the 8 semantic state colors — success/error/accent families) and the
three strings above are edited with text fields and color pickers,
persisted in the `ui_settings` table, and injected into every served page
as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The
browser-tab **favicon** follows the same effective palette: `GET
**The admin Settings panel (`/settings.html`) is the single writable
owner of those three brand strings** (phase 190, A1). Their resolution
order is **panel row → Theme-tab row → env**: saving `app_name` there
wins over everything, and it beats a value an old Theme-tab save baked
into `ui_settings` (the fix for "changing the App name in settings
broke, it no longer updates the name"). `PUT /api/ui-settings` still
accepts the three string fields so an old cached client keeps working,
but it **ignores** them and never writes (or clears) those columns — a
Theme-tab save can never re-bake a name. A string is reset with the
panel's `DELETE /api/admin/settings/{key}` (back to the Theme-tab row if
one still holds a value, else back to the env var), and with
`BOR_SETTINGS_DB=0` no panel row is in effect, so the old order —
Theme-tab row, then env — stands.
**The save is visible without a reload** (phase 190, task 03). Saving one of
the three brand strings in the panel — or resetting it with
`DELETE /api/admin/settings/{key}` — re-reads `GET /api/config` and applies it
to the OPEN document through the ONE shared brand pass (`assets/brand.js`'s
`applyBrandFromConfig`, the very function the boot runs): the page title, the
header brand, the footer line and the chat composer placeholder update on the
click, no reload — including a rename of an app whose old name is already on
the page. The re-apply runs only when one of those three keys was involved (the
other ~72 rows cost no extra request), never stacks a second fetch while one is
in flight, and a failed one leaves the page exactly as it showed — the save's
own status or error line stays the feedback. The Theme tab runs the same pass
after its Save and its Reset; it no longer edits the strings, so that read is
what keeps an open tab honest about a name the panel changed meanwhile.
The whole contract — the incident regression (a color save no longer shadows a
later rename) and the live repaint of title / brand / footer / placeholder — is
pinned in a real browser by `tests/e2e/test_app_name_settings_fix.py` (run in
isolation).
The colors stay the **Theme tab's** (`/theme.html`, admin-only — phase
91): the 17 palette colors (the 9 identity colors plus the 8 semantic
state colors — success/error/accent families) are edited with color
pickers and nothing else, persisted in the `ui_settings` table, and
injected into every served page as an inline `<style>` BEFORE first
paint (no red flash, no pop-in). The tab's form renders those 17 pickers
only — its old "Branding" fieldset (`#theme-app-name`,
`#theme-placeholder`, `#theme-footer`) is gone, and its PUT body carries no
string key. The browser-tab **favicon** follows the same effective palette:
`GET
/favicon.svg` renders the hexagon art from the saved colors on every
request (no process cache — a theme change is visible on the next
request, no restart) and degrades to the built-in art with 200 on a
+8 -6
View File
@@ -3,12 +3,14 @@ layer, the phase-59 docs-push flag (the "Save as doc" gating), the
phase-122 image flag (UI affordance gating), and the phase-62 UI
customization strings (composer placeholder, footer line).
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
wins when set, env is the fallback), resolved by the SAME
:func:`app.core.theming.effective_settings` resolver the
``/api/ui-settings`` API uses, so the brand layer and the tab can never
disagree. The route opens a short-lived session (the sync-endpoint
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values,
resolved by the SAME :func:`app.core.theming.effective_settings` resolver
the ``/api/ui-settings`` API uses, so the brand layer and the tab can
never disagree. Phase 190 (task 02, A1) fixed WHO owns them: the admin
Settings panel's ``app_settings`` row (phase 168) first, then the
legacy ``ui_settings`` (Theme-tab) row, then the env value — so a name
set in the panel is what this endpoint serves, whatever an old Theme-tab
save baked into the other row. The route opens a short-lived session (the sync-endpoint
house pattern — the route is sync, matching the middleware world).
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
+39 -38
View File
@@ -1,10 +1,14 @@
"""UI settings admin API (phase 91, task 01).
"""UI settings admin API (phase 91, task 01; colors-only since phase 190).
The persistence surface of the admin Theme tab (the tab itself lands in
tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the
admin sets — the app name, input placeholder, footer text, and the 17
admin sets — since phase 190 (A1, owner-confirmed 2026-10-18) the 17
palette colors (the 9 identity colors + the 8 semantic state colors,
phase 93 — B3 revised). The whole router sits behind
phase 93 — B3 revised) and NOTHING else. The three brand strings (app
name, input placeholder, footer text) belong to the admin Settings
panel (``app_settings``, phase 168): this row keeps its string columns
as the documented FALLBACK for values it baked in before that phase, but
the API never writes them (see the PUT bullet). The whole router sits behind
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
403 on every route (only the admin themes the deployment).
@@ -14,16 +18,25 @@ Routes (under ``/api`` via the ``main`` registration):
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
env strings + the built-in palette, so a fresh tab shows the live
theme. Creates nothing.
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
string is trimmed (empty → NULL, >300 → 422 naming the field), each
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
naming the field), and — the owner-locked normalization — a color
theme. Creates nothing. The three strings stay in the response
READ-ONLY (old-client compat, and the resolver's order makes them the
EFFECTIVE ones — the panel's value when the panel owns the field).
* ``PUT /api/ui-settings`` — a FULL replacement of the row's 17 COLORS:
each color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else
422 naming the field), and — the owner-locked normalization — a color
equal to its built-in is stored as NULL, so "save the defaults"
leaves the row empty and the served HTML stays byte-identical (the
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
update-or-insert); a concurrent PUT is single-admin — last writer
wins. Returns the new effective values.
no-op injection contract, task 02). The 3 string fields of the body
are ACCEPTED AND IGNORED (phase 190 A1 — the request model keeps them
so an old cached client still saves its palette; the handler drops
them BEFORE validating or persisting, so a Theme-tab save can never
re-bake a brand string and shadow the Settings panel again — the
``TODO.md`` L3 incident). The stored string columns are left exactly
as they were, never cleared: with no panel row a legacy baked value
still applies (the A1 fallback), and "reset" for a string is the
panel's ``DELETE /api/admin/settings/{key}``. Upserts the id-1 row
(SELECT → update-or-insert); a concurrent PUT is single-admin — last
writer wins. Returns the new effective values.
"""
from __future__ import annotations
@@ -49,28 +62,6 @@ router = APIRouter(
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
#: lowercased on store so the stored/tagged hex is canonical).
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
_MAX_STRING_LEN = 300
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
"""Trim the 3 display strings: empty after the trim → ``None``
(the clear operation), >300 chars after the trim → 422 naming the
field (the house fixed-detail style — the detail never varies by
value beyond naming the field)."""
values: dict[str, str | None] = {}
for field in theming.STRING_FIELDS:
raw = getattr(payload, field)
if raw is None:
values[field] = None
continue
value = raw.strip()
if len(value) > _MAX_STRING_LEN:
raise HTTPException(
status_code=422, detail=f"{field} is too long (max 300)"
)
values[field] = value or None
return values
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
@@ -105,7 +96,11 @@ def get_ui_settings(
Reads only: a missing row means "defaults" (the env strings + the
built-in palette), so a fresh deployment's tab shows the live theme
with an empty row, and nothing is ever upserted by a read.
with an empty row, and nothing is ever upserted by a read. The 3
strings report the EFFECTIVE values through the phase-190 order
(panel row → this row → env), so an old client's form pre-populates
with what the deployment actually shows, never with this row's stale
bake.
"""
return UiSettingsOut(**theming.effective_settings(db, settings))
@@ -116,24 +111,30 @@ def update_ui_settings(
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
"""Replace the single row with the body's 20 values (validated and
"""Replace the row's 17 COLORS with the body's (validated and
normalized — see the module docstring), then report the new
effective values.
The body's 3 brand strings are NEVER read (phase 190, A1: the admin
Settings panel is their single writable owner). Dropping them here
rather than rejecting them keeps an old cached client's Save working
— and takes away the mechanism of the ``TODO.md`` L3 incident: the
row's string columns keep exactly what they hold (never written, so
a legacy baked value survives as the documented fallback, and no
save can re-bake the then-effective name and shadow every later
panel change).
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
``default=1`` supplies the PK on the insert). Concurrency is
single-admin (one owner, one tab) — the last writer wins and no
lock is taken: a lost race just means the other admin's PUT is the
effective one.
"""
strings = _validate_strings(payload)
colors = _validate_colors(payload)
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
if row is None:
row = UiSettings(id=1)
db.add(row)
for field in theming.STRING_FIELDS:
setattr(row, field, strings[field])
for field in theming.COLOR_FIELDS:
setattr(row, field, colors[field])
db.commit()
+39
View File
@@ -32,6 +32,12 @@ pre-migration deployment is a normal state); any other DB failure is
one WARNING per process naming the exception class ("settings
overrides unavailable — env values in effect"). Both "once per process"
guards are module-level flags, reset by :func:`reset_state_for_tests`.
Phase 190 (task 02) adds the one question another resolver asks this
table: :func:`applied_override_keys` — "which keys does the Settings
panel own right now?" (the brand-string ownership order in
:func:`app.core.theming.effective_settings`). It reads on the CALLER's
session, so answering it costs no second engine and never a stale view.
"""
from __future__ import annotations
@@ -45,6 +51,7 @@ from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.exc import ProgrammingError, SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app.config import Settings
@@ -125,6 +132,38 @@ def load_overrides() -> dict[str, str]:
return {}
def applied_override_keys(session: Session) -> frozenset[str]:
"""The ``app_settings`` keys IN EFFECT right now — phase 190 task 02.
:func:`app.core.theming.effective_settings` needs exactly this to
answer "does the admin's Settings panel own this brand string?" (A1:
the panel row beats the Theme-tab row, and only a REAL row counts —
comparing values would mis-read a panel row that happens to equal
the env value, the exact shadow the phase-190 incident is).
Read on the CALLER's session — one ``SELECT`` on a connection the
caller already holds, transactionally consistent with the
``ui_settings`` read beside it (a cross-request memo of the key set
would go stale the moment a row is written outside the panel and
could re-shadow a just-saved value). The resolver calls this ONCE
per resolution and tests the three string fields for membership, so
a resolution costs no second query per field.
The kill switch (``BOR_SETTINGS_DB``, D6) short-circuits before any
read — with the overlay off no row is in effect, and the Theme-tab
row keeps its phase-91 fallback. A non-overridable key (LOCKED A3)
and an orphan row (a field a later release removed) are never
"in effect": :func:`merge` drops them, so they must not win here
either.
"""
if not enabled():
return frozenset()
keys = session.execute(text("SELECT key FROM app_settings")).scalars().all()
return frozenset(
key for key in keys if key in Settings.model_fields and CATALOG[key].overridable
)
def _coerce(field_name: str, raw: str) -> Any:
"""Coerce one env-native string row value for the candidate set.
+49 -11
View File
@@ -83,12 +83,28 @@ diverge (``tests/unit/test_theming.py`` pins the mirror: every
pairs).
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
color column to the built-in. ONE resolver is used by BOTH
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
layer), so the tab and the running UI can never disagree.
env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved
column-by-column:
* **the three brand strings** (``app_name`` / ``input_placeholder`` /
``footer_text``) — **Settings-panel row → Theme-tab row → env**
(phase 190, A1 owner-confirmed 2026-10-18). The panel (the
``app_settings`` rows behind ``/api/admin/settings``, phase 168) is
the SINGLE writable owner of the strings, so its row wins whenever it
exists; the Theme-tab row (this module's ``ui_settings``) is the
documented fallback for the rows it baked before this phase (no data
migration — a legacy row keeps rendering what it holds until the
panel overrides it); the ``BOR_`` env value is the last resort.
Presence, never value comparison: an ``app_settings`` row that
happens to equal the env value still owns the field (that equality is
exactly how the phase-190 shadow looked from the owner's side).
* **the 17 colors** — unchanged (B1): the ``ui_settings`` row when not
``NULL``, else the built-in palette. The ownership flip is
strings-only.
ONE resolver is used by BOTH ``GET /api/ui-settings`` (the tab) and
``GET /api/config`` (the brand layer), so the tab and the running UI
can never disagree.
"""
from __future__ import annotations
@@ -99,6 +115,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.core import settings_overlay
from app.models import UiSettings
#: The 17 built-in palette colors, keyed by BARE variable name (no
@@ -136,8 +153,12 @@ BUILTIN_COLORS: dict[str, str] = {
#: API, and the ``theme_style_tag`` renderer (task 02).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
#: (B1: unlike the colors, the env vars stay the strings' default).
#: The 3 brand strings the ``ui_settings`` row carries. Since phase 190
#: (A1) the Settings panel (``app_settings``, phase 168) is their single
#: writable OWNER and wins over this row; the row is the documented
#: fallback for the values it baked in before that phase, with the env
#: value last (B1: unlike the colors, the env vars stay the default).
#: ``PUT /api/ui-settings`` no longer writes them.
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
@@ -150,12 +171,22 @@ def effective_settings(
defaults, column by column:
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
— the DB value when it is a non-empty string, else the env value
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
— the three-way order of phase 190 (A1), per field:
**Settings-panel row** → **Theme-tab row** → **env**. The panel
row is ``getattr(settings, field)`` (the ``Settings`` the route
passes already resolves through the phase-168 overlay, so a panel
row IS its value) and it wins only when the overlay reports the
key as an actual override
(:func:`app.core.settings_overlay.applied_override_keys`, read
ONCE here — presence, never a value compare). Otherwise the
``ui_settings`` value when it is a non-empty string (the phase-91
fallback a legacy baked-in row still lands in), else the env
value (B1: the env vars stay the last resort).
* **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
semantic state) — the DB value when not ``None``, else
:data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
built-in palette IS the default).
built-in palette IS the default; the ownership flip is
strings-only).
A missing row (``GET`` creates nothing) means "defaults" — the env
strings + the built-in palette. The ``settings`` parameter names the
@@ -167,8 +198,15 @@ def effective_settings(
if settings is None:
settings = get_settings()
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
#: The keys the admin's Settings panel owns right now (phase 190
#: A1) — one SELECT on this same session, consulted per string
#: field below with a membership test (no query per field).
panel_keys = settings_overlay.applied_override_keys(session)
effective: dict[str, str] = {}
for field in STRING_FIELDS:
if field in panel_keys:
effective[field] = getattr(settings, field)
continue
value = getattr(row, field, None) if row is not None else None
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
for key in COLOR_FIELDS:
+11 -1
View File
@@ -563,6 +563,14 @@ class UiSettings(Base):
overview). :func:`app.core.theming.effective_settings` resolves the
effective 20 values both the ``GET /api/ui-settings`` and
``GET /api/config`` endpoints serve.
Phase 190 (task 02, A1 — owner-confirmed 2026-10-18) narrowed the
WRITE half of this row to the 17 colors: the three string columns
are now the admin Settings panel's (``app_settings``, phase 168) and
``PUT /api/ui-settings`` never touches them. The columns stay — a
row this table baked before that phase is the documented FALLBACK
(panel row → this row → env) and still renders until the panel
overrides it, so the fix needed no data migration.
"""
__tablename__ = "ui_settings"
@@ -571,7 +579,9 @@ class UiSettings(Base):
#: id=1 precedent — Python-side default; the migration carries no
#: server default because the row is created only by the PUT upsert).
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
# --- Strings (NULL/empty = "use the env default" — B1) ---
# --- Strings — READ-ONLY since phase 190 (A1): the Settings panel's
# row wins over these, the Theme-tab PUT never writes them, and
# NULL/empty means "use the env default" (B1) ---
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
+20 -14
View File
@@ -2077,20 +2077,24 @@ class TokenAuthRequest(BaseModel):
class UiSettingsIn(BaseModel):
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
replacement of the single ``ui_settings`` row.
"""``PUT /api/ui-settings`` body (phase 91, task 01; colors-only since
phase 190, task 02): a FULL replacement of the single ``ui_settings``
row's 17 COLORS.
Every field is ``str | None`` — present = a new value (strings are
trimmed; empty after the trim is the CLEAR operation, stored as
NULL; colors must be ``#rrggbb`` and are lowercased on store),
``null``/absent = "back to the default" (stored as NULL — the Reset
button's all-null PUT is exactly the "defaults" operation). The
API layer runs the trim/length/hex validation so the 422 details
name the offending field (the house fixed-detail style); the
built-in→NULL normalization (a color equal to its built-in is
stored as NULL — "save the defaults" must leave the row empty, the
no-op injection contract) happens there too, next to the palette
it normalizes against.
Every field is ``str | None`` — present = a new value, ``null``/
absent = "back to the default" (stored as NULL — the Reset button's
all-null PUT is exactly the "defaults" operation). The API layer
runs the hex validation so the 422 details name the offending field
(the house fixed-detail style); the built-in→NULL normalization (a
color equal to its built-in is stored as NULL — "save the defaults"
must leave the row empty, the no-op injection contract) happens
there too, next to the palette it normalizes against.
The three string fields stay on the model for OLD-CLIENT COMPAT and
are ACCEPTED AND IGNORED (phase 190 A1): the Settings panel owns the
brand strings, so the handler drops them before validating or
persisting and the stored columns keep what they hold. Resetting a
string is ``DELETE /api/admin/settings/{key}``.
"""
app_name: str | None = None
@@ -2127,7 +2131,9 @@ class UiSettingsOut(BaseModel):
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
the LIVE theme — a fresh (row-missing) deployment reports the env
strings and the built-in palette (the semantic colors report their
built-ins — the effective values, the NULL = built-in rule).
built-ins — the effective values, the NULL = built-in rule). Since
phase 190 (A1) the 3 strings are READ-ONLY here and report the
EFFECTIVE value through the panel-row → tab-row → env order.
"""
app_name: str
+169 -79
View File
@@ -34,7 +34,7 @@
* aria-label, the input label, the meta descriptions);
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the two UI-customization
* string keys, applied in this same .then, AFTER the app_name
* string keys, applied in this same pass, AFTER the app_name
* passes and INDEPENDENT of them (they apply even when the
* name is the default/empty). Each empty value is a no-op —
* an unset deployment stays byte-identical:
@@ -54,11 +54,37 @@
* (the loadHealth house style: progressive enhancement, the page
* never breaks).
*
* ONE pass, TWO callers (phase 190, task 03 — A2). Steps 1–6 live in
* the named function applyBrandFromConfig(cfg), the single owner of the
* brand DOM. It runs (a) at BOOT, through applyBrand() awaiting the
* parse-time fetch — byte-identical to the pre-phase single pass — and
* (b) on a LIVE re-apply, when the admin saves or resets a brand string
* in the Settings panel (assets/settings.js) or saves/resets the Theme
* tab (assets/theme.js): those surfaces fetch a fresh /api/config and
* hand it to the SAME function, so "saved" is visible without a reload
* (the PLAN §7.4 never-stale contract, extended from the phase-92
* palette to the strings). Because a live re-apply runs against a
* document whose previous pass already replaced the default literal,
* step 7 re-runs the three literal passes over the name the page
* CURRENTLY shows (window.BOR_BRAND_APPLIED) — at boot that is "" so
* step 7 never runs and the boot path stays exactly the phase-39
* sequence. The function is on this layer's existing export surface —
* the window globals (window.BOR_BRAND, window.BOR_CONFIG_PROMISE) —
* as window.applyBrandFromConfig; brand.js stays the ONE owner of the
* brand DOM and no surface copies a step.
*
* No-op property: with the customization env vars (BOR_APP_NAME,
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
* with the template defaults themselves — the name IS the literal, the
* placeholder and footer are the phase-61 copy (re-setting them is
* invisible) — so an unset deployment renders byte-identical.
* invisible) — so an unset deployment renders byte-identical. The
* "empty writes nothing" rule for the two phase-62 keys holds on a live
* re-apply too (the page keeps what it shows): /api/config cannot
* distinguish "cleared" from "unset", and both env defaults are
* non-empty, so an empty value only ever comes from an operator saving
* an empty string (and then the page keeps what it shows). A panel
* RESET deletes the row, so the resolver hands back the legacy tab row
* or the env default (both non-empty) and that name re-applies live.
*/
/* The synchronous default — set BEFORE any fetch, so module scripts
@@ -76,6 +102,12 @@ window.BOR_DOCS_REPO_CONFIGURED = false;
sync with it. */
const BRAND_LITERAL = "Brain of Reese";
/* Phase 190 (task 03): the name the DOM passes last applied — "" until
the first apply (the boot pass). A live re-apply needs it: the page
no longer carries the default literal where the brand shows, so the
re-apply replaces THIS name too (step 7 below). */
window.BOR_BRAND_APPLIED = "";
/* The name is operator-controlled: HTML-escape it before it touches
innerHTML (the markdown.js escape pattern — local on purpose, no
cross-module import for a 5-line helper). */
@@ -108,106 +140,164 @@ const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
);
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name;
/* THE shared brand pass (phase 190, task 03) — the ONE owner of the
brand DOM. Synchronous, idempotent, and safe to run against an
already-branded document: boot (applyBrand below, via the settled
fetch) and the live re-apply (settings.js after a brand-string
save/reset, theme.js after a save/reset) run THIS function and
nothing else. `cfg` is a /api/config payload; a null/missing payload
touches nothing (the loadHealth house style — never a blanked brand). */
function applyBrandFromConfig(cfg) {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
const previous =
typeof window.BOR_BRAND_APPLIED === "string" ? window.BOR_BRAND_APPLIED : "";
if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
// 7. Phase 190 (task 03, A2) — the RE-APPLY clause. A live re-apply
// runs on a document whose steps 1 / 3 / 4 above already rewrote
// the default literal into the PREVIOUS name, so a second rename
// has to replace what the page SHOWS, not (only) the literal:
// the same three places, one more source string. At boot
// `previous` is "" — this block never runs and the boot pass is
// byte-identical to the pre-phase sequence. (.brand-text, the
// composer placeholder and the footer line need nothing here:
// steps 2 / 5 / 6 assign their value outright.)
if (previous && previous !== name) {
document.title = document.title.replaceAll(previous, name);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(previous)) {
node.nodeValue = node.nodeValue.replaceAll(previous, name);
}
}
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
if (v && v.includes(previous)) {
el.setAttribute(attr, v.replaceAll(previous, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
if (v && v.includes(previous)) {
el.setAttribute("content", v.replaceAll(previous, name));
}
}
}
}
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the two customization STRING keys — applied
// here, INDEPENDENT of the app_name block above (they apply even
// when the name is the default/empty). Each empty value is a
// no-op, so an unset deployment stays byte-identical (no attribute
// or element touched, no second network call). Color theming is
// server-side since phase 91 (task 03) — not this layer's job.
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
// The name this document now shows — the source string the NEXT
// re-apply replaces (phase 190, task 03).
window.BOR_BRAND_APPLIED = name;
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
});
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the two customization STRING keys — applied
// here, INDEPENDENT of the app_name block above (they apply even
// when the name is the default/empty). Each empty value is a
// no-op, so an unset deployment stays byte-identical (no attribute
// or element touched, no second network call). Color theming is
// server-side since phase 91 (task 03) — not this layer's job.
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
}
/* This layer's export surface is the window globals it has always had
(window.BOR_BRAND, window.BOR_CONFIG_PROMISE); the shared pass joins
them so the page's MODULE scripts — assets/settings.js and
assets/theme.js, which cannot import from a classic script — call the
ONE function instead of copying a step (phase 190, task 03). */
window.applyBrandFromConfig = applyBrandFromConfig;
/* The boot call site — unchanged in behavior (phase 39): it awaits the
parse-time fetch and hands the settled payload to the shared pass. */
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => applyBrandFromConfig(cfg));
}
/* The DOM passes run once the document is ready AND the config is
+2 -2
View File
@@ -81,7 +81,7 @@ const VIEW = {
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette — colors only since phase 190)
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
};
@@ -138,7 +138,7 @@ const DESCRIPTIONS = {
history:
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
tokens: "Generate and revoke the API tokens that let people use the app.",
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
theme: "Set the palette — the theme is baked into every served page, live on the first paint.",
settings: "Edit any application setting — a saved value wins over the environment, live on save; reset hands control back.",
};
+63
View File
@@ -75,6 +75,18 @@
* button, Escape / No cancels) → DELETE /api/admin/settings →
* the status line `Cleared N overrides.` (N from the
* response) + a re-GET.
* • the live brand re-apply (phase 190, task 03 — A2, the second half
* of "takes effect on clicking save") — when a save / Reset / Clear
* all touched one of the three brand strings (`app_name`,
* `input_placeholder`, `footer_text` — the panel's own keys), the
* OPEN document is re-branded from a fresh `GET /api/config`
* through `window.applyBrandFromConfig` (the ONE pass brand.js owns
* and the boot runs — this file never writes the title / brand /
* footer / placeholder itself). No brand key in the batch → no
* extra request; a failed re-apply leaves the document exactly as
* it was (never a blanked brand); the catalogue re-GET stays the
* catalogue's own refresh. The Theme tab (theme.js) runs the SAME
* shared pass after its own save / reset.
* • search — #settings-filter: a case-insensitive substring over
* the label + BOR_* name cells, applied on input with NO fetch
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
@@ -447,6 +459,48 @@ export async function mount(root) {
}
}
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
* The phase-168 header contract is "the change should take effect on
* clicking 'save'" — for the three BRAND strings that means the OPEN
* document too, not just the catalogue. One pass, ONE owner:
* brand.js exports the boot's own function as
* window.applyBrandFromConfig, and this helper feeds it a fresh
* GET /api/config (the same no-store request the boot makes).
*
* • gated on the key — a save / Reset of any of the ~72 other rows
* costs NO extra request and no DOM churn; Clear all passes all
* three because clearing hands every one of them back to the
* Theme-tab row or the environment;
* • the §7.4 guard, EXTENDED not forked — the save itself is already
* inside `saveInFlight` (this helper is awaited before that flag
* drops, so a second Save can never start mid-re-apply) and the
* one flag below covers the settle paths that share the pass, so a
* second save / Reset / Clear all never stacks a second fetch;
* • never a blanked brand — a non-2xx or an unreachable app returns
* WITHOUT touching the DOM (the phase-168 "never a blanked panel"
* precedent): the open page keeps its last applied name, footer
* and placeholder, and the save's own status / error line stays
* the visibility. */
const BRAND_KEYS = ["app_name", "input_placeholder", "footer_text"];
let brandReapplyInFlight = false;
async function reapplyBrand(keys) {
if (!keys.some((key) => BRAND_KEYS.includes(key))) return; // not a brand row
if (brandReapplyInFlight) return; // one re-apply at a time (§7.4)
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
brandReapplyInFlight = true;
try {
const r = await fetch("/api/config", { cache: "no-store" });
if (!r.ok) return; // the brand keeps its last applied state
window.applyBrandFromConfig(await r.json());
} catch {
/* unreachable app or a non-JSON body — nothing is written (the
brand never blanks; the settle path's own line says it) */
} finally {
brandReapplyInFlight = false;
}
}
/* ---------- Save (dirty rows only — stop at the first failure,
K4; the §7.4 never-stale lifecycle) ---------- */
async function saveSettings() {
@@ -499,6 +553,9 @@ export async function mount(root) {
);
const input = root.querySelector("#set-" + failure.key);
if (input) input.focus();
/* A brand row that DID land before the failure changed the app
(K4 saves the prefix) — the open document re-reads it too. */
await reapplyBrand(saved);
} else {
/* One status line (role=status) — a saved restart-required
row says so (the owner is never told "saved" and then sees
@@ -511,6 +568,10 @@ export async function mount(root) {
(anyRestart ? " — applies on the next restart" : ""),
);
await loadSettings(); // every badge/value is the settled state
/* …and the brand strings apply to the OPEN document (no reload) —
additive to the catalogue re-GET above, still inside the
saveInFlight guard (phase 190, task 03). */
await reapplyBrand(saved);
}
saveInFlight = false;
if (saveBtn) {
@@ -535,6 +596,7 @@ export async function mount(root) {
}
announce(`${envVar} is back to the environment value.`);
await loadSettings(); // the badge falls back to env/default
await reapplyBrand([key]); // …and the open document falls back too
}
/* ---------- Clear all overrides (the house two-step inline
@@ -570,6 +632,7 @@ export async function mount(root) {
restore();
announce(`Cleared ${cleared} overrides.`);
await loadSettings(); // every badge falls back to env/default
await reapplyBrand(BRAND_KEYS); // every brand string may have moved
}
function showClearAllConfirm() {
+107 -50
View File
@@ -1,5 +1,7 @@
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
* task 04): the admin palette + branding editor.
* task 04; COLORS ONLY since phase 190, task 02): the admin palette
* editor — the three brand strings moved to the admin Settings panel
* (A1: that panel is their single writable owner).
*
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
* shape): the router (assets/router.js) lazy-imports this module on
@@ -17,19 +19,22 @@
* gate (the #nav-theme link is already hidden by header.js — the
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
* /api/ui-settings request is ever made outside the admin branch.
* • load — GET /api/ui-settings → populate the 20 inputs with the
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
* merge): the tab always shows the live theme — env defaults when
* the row is empty. A failed fetch keeps the static form (the
* built-in values ship in the inputs) and shows #theme-error with
* a retry (the loadHealth house style — never a blanked panel).
* • live preview (colors only, B4) — on `input` of any of the 17
* color pickers the value is written straight onto <html> as an
* inline custom property, so the WHOLE page repaints (every view,
* the header) while the owner is picking. Text fields have NO page
* effect: the 3 strings keep the brand.js runtime application
* (owner-locked B4) — they apply via the /api/config boot fetch on
* the NEXT page load, and the sub-copy says so.
* • load — GET /api/ui-settings → populate the 17 color inputs with
* the EFFECTIVE values (the resolver's DB-over-env /
* DB-over-built-in merge): the tab always shows the live theme —
* the built-in palette when the row is empty. A failed fetch keeps
* the static form (the built-in values ship in the inputs) and
* shows #theme-error with a retry (the loadHealth house style —
* never a blanked panel). The response still carries the 3 brand
* strings (read-only, old-client compat) — populate() simply has no
* field to write them into.
* • live preview (colors only) — on `input` of any of the 17 color
* pickers the value is written straight onto <html> as an inline
* custom property, so the WHOLE page repaints (every view, the
* header) while the owner is picking. The brand strings are not
* this tab's surface at all since phase 190 (A1) — the Settings
* panel edits them (and, from phase 190 task 03, applies them to
* the open document on save).
* • favicon live preview (phase 177, A4 — owner-confirmed
* 2026-10-13) — a pick of one of the THREE driving fields
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
@@ -95,10 +100,10 @@
* the admin opens the tab (a normal load is a no-op — the served
* tag and the overrides agree).
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
* PUT /api/ui-settings with the 20 form values (a cleared/empty
* text field → null; colors always their current hex — the
* server's built-in→NULL normalization keeps the row empty when
* the owner saves the defaults) → 200: #theme-result "Theme
* PUT /api/ui-settings with the 17 form colors (always their
* current hex — the server's built-in→NULL normalization keeps the
* row empty when the owner saves the defaults; NO string key in
* the body, phase 190 A1) → 200: #theme-result "Theme
* saved." (role=status), refetch + re-populate (canonical state),
* reconcile the open document to the settled values (#bor-theme
* text + the <html> overrides — the OPEN page paints the saved
@@ -111,13 +116,26 @@
* field), the form is KEPT (the owner fixes + retries); any other
* non-2xx: the fixed error line; a network error: the "is the app
* reachable?" line.
* • Reset — the same lifecycle ("Resetting…") with all 20 values
* null (the API's documented "defaults" operation) → #theme-result
* • Reset — the same lifecycle ("Resetting…") with all 17 colors
* null (the API's documented "defaults" operation for the palette;
* a brand string is reset from the Settings panel, phase 190 A1) →
* #theme-result
* "Reset to the built-in theme." → refetch + re-populate (the
* env/built-in defaults) → reconcile the open document: the tag
* is REMOVED (effective = the built-ins → content null) and the
* <html> overrides are dropped (a failed refetch still drops
* them — the picks are stale once the reset landed).
* • the live brand re-apply (phase 190, task 03 — A2) — after BOTH
* settle paths (Save success and Reset success) the open document
* re-reads `GET /api/config` through `window.applyBrandFromConfig`,
* the ONE brand pass brand.js owns and the boot runs (this file
* never writes the title / brand / footer / composer itself). The tab
* no longer EDITS the strings (A1) and its PUT never writes those
* columns — so the re-read is the SELF-HEAL half: whatever the server
* resolves (panel row → the legacy tab row → env) is what the open
* page shows the moment the settle lands, including a name the panel
* changed while this tab was open. One request at a time; a failed
* re-apply leaves the document as it was (never a blanked brand).
* • WCAG contrast (the phase-93 design's EIGHT pairs — the pairs
* the layout actually pairs, see app/core/theming.py's docstring —
* this file's PAIRS is its client-side mirror, and the two must
@@ -168,21 +186,22 @@ export async function mount(root) {
const SAVE_LABEL = "Save theme";
const RESET_LABEL = "Reset to defaults";
/* The 20 form fields, in the form's order: `field` is the API key
(the input's name attribute), `id` the E2E-stable element id,
`kind` how the value is read for a PUT — a string field that is
empty after the trim sends null (the server stores NULL = "use
the default"); a color field always sends its current #rrggbb
(the server's built-in→NULL normalization keeps the row empty
when the owner saves the defaults). The 17 color fields follow
the server's theming.COLOR_FIELDS order (the 9 identity
variables, then the 8 semantic state variables — phase 93), so
everything FIELDS-driven (live preview, collectBody,
/* The 17 color form fields, in the form's order: `field` is the API
key (the input's name attribute), `id` the E2E-stable element id,
`kind` how the value is read for a PUT — a color field always
sends its current #rrggbb (the server's built-in→NULL
normalization keeps the row empty when the owner saves the
defaults). THE 3 BRAND STRINGS ARE NOT HERE (phase 190, A1 —
owner-confirmed 2026-10-18): the admin Settings panel is the
single writable owner of app_name / input_placeholder /
footer_text, so the tab renders no string input and its PUT body
carries no string key — a Theme-tab save can never re-bake a name
and shadow the panel (the TODO.md L3 incident). The 17 color
fields follow the server's theming.COLOR_FIELDS order (the 9
identity variables, then the 8 semantic state variables — phase
93), so everything FIELDS-driven (live preview, collectBody,
applyServedTheme's tag content) stays in COLOR_FIELDS order. */
const FIELDS = [
{ field: "app_name", id: "theme-app-name", kind: "string" },
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
{ field: "footer_text", id: "theme-footer", kind: "string" },
{ field: "bg", id: "theme-bg", kind: "color" },
{ field: "surface", id: "theme-surface", kind: "color" },
{ field: "ink", id: "theme-ink", kind: "color" },
@@ -324,8 +343,8 @@ export async function mount(root) {
* inline style beats the served <style id="bor-theme"> :root, so
* the whole page repaints live while the owner is picking. An empty
* value removes the override (the page falls back to the served
* theme). Text fields write NOTHING here — the strings apply via
* brand.js on the next page load (the sub-copy says so). */
* theme). Only the 17 color pickers bind here — the tab has no
* string input at all since phase 190 (A1). */
function previewColor(field, value) {
if (value) {
document.documentElement.style.setProperty(cssVar(field), value);
@@ -488,9 +507,10 @@ export async function mount(root) {
}
}
/* GET /api/ui-settings → populate the 20 inputs with the EFFECTIVE
values (the tab always shows the live theme — env defaults when
the row is empty) and re-check the eight pairs (a SAVED palette
/* GET /api/ui-settings → populate the 17 color inputs with the
EFFECTIVE values (the tab always shows the live theme — the
built-in palette when the row is empty) and re-check the eight
pairs (a SAVED palette
can itself fail AA — the warning then tracks it). A failed fetch
keeps the static form + shows #theme-error with a retry (the
loadHealth house style — never a blanked panel). Returns the
@@ -524,17 +544,16 @@ export async function mount(root) {
/* ---------- the PUT (Save + Reset share it) ---------- */
function collectBody() {
/* All 20 fields — the 3 strings + the 17 colors (the 9 identity
+ the 8 semantic state, phase 93). */
/* The 17 colors ONLY (the 9 identity + the 8 semantic state) — no
string key ever rides the wire (phase 190, A1: the Settings
panel owns the brand strings, and the server ignores them in
this body anyway — an old cached client can no longer re-bake
one). */
const body = {};
for (const f of FIELDS) {
const input = inputs[f.field];
const value = input ? input.value : "";
if (f.kind === "string") {
body[f.field] = value.trim() || null; // cleared field → null
} else {
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
return body;
}
@@ -577,6 +596,39 @@ export async function mount(root) {
}
}
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
* The reconcile above owns the COLORS; the three brand STRINGS are the
* Settings panel's (A1), and this tab's PUT never writes those columns.
* The re-read is still owed: the effective string can move under an
* open tab (the panel saved or reset one, another browser changed the
* row), and every settle is the moment the document is re-branded to
* what the server actually resolves. Both settle paths therefore run the
* SAME shared
* pass brand.js owns and the boot runs:
* fetch("/api/config") → window.applyBrandFromConfig(cfg). One code
* path for Save and Reset (the PUT body difference is the colors), one
* request at a time (§7.4 — a second settle never stacks a second
* fetch), and NEVER a blanked brand: a non-2xx or an unreachable app
* returns without touching the DOM, leaving the last applied name,
* footer and placeholder (the save's own status / error line is the
* visibility). */
let brandReapplyInFlight = false;
async function reapplyBrand() {
if (brandReapplyInFlight) return;
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
brandReapplyInFlight = true;
try {
const r = await fetch("/api/config", { cache: "no-store" });
if (!r.ok) return; // the brand keeps its last applied state
window.applyBrandFromConfig(await r.json());
} catch {
/* unreachable app or a non-JSON body — nothing is written */
} finally {
brandReapplyInFlight = false;
}
}
async function saveTheme() {
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
if (!outcome.ok) {
@@ -592,11 +644,12 @@ export async function mount(root) {
/* A failed refetch keeps the current <html> overrides on purpose:
the PUT body came from these very inputs, so they ARE the saved
palette — never revert onto the stale tag (the phase-91 defect). */
await reapplyBrand(); // the strings ride the same settle (task 03)
}
async function resetTheme() {
const body = {};
for (const f of FIELDS) body[f.field] = null; // all null = the defaults
for (const f of FIELDS) body[f.field] = null; // 17 nulls = the defaults
const outcome = await putSettings(body, "Resetting…", resetBtn);
if (!outcome.ok) {
showError(outcome.detail);
@@ -611,6 +664,11 @@ export async function mount(root) {
clearPreview(); // the reset landed — the picks are stale
restoreFavicon(); // stale picks — back to the route URL
}
/* The palette row is back to the defaults — and the open document
re-reads the effective brand too, so a string the panel moved
while this tab was open paints now, not on the next load
(phase 190, task 03). */
await reapplyBrand();
}
/* ---------- view boot (phase 91 task 05) ----------
@@ -642,10 +700,9 @@ export async function mount(root) {
/* Bindings — armed BEFORE the first load: a fast owner can start
picking while the GET is still out; the preview writes are
idempotent and the settled load re-populates afterwards. Color
inputs drive the live preview + the contrast re-check; text
inputs drive neither (B4 — the strings apply on the next page
load, the sub-copy says so). */
idempotent and the settled load re-populates afterwards. The 17
color inputs drive the live preview + the contrast re-check (the
brand strings are the Settings panel's surface, phase 190 A1). */
for (const f of FIELDS) {
const input = inputs[f.field];
if (!input || f.kind !== "color") continue;
+17 -48
View File
@@ -1275,8 +1275,9 @@
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
folded view (the phase-76 fold pattern, the phase-79 Tokens
view as the most recent precedent): the admin-only palette +
branding editor. /theme.html serves THIS document (the shell
view as the most recent precedent): the admin-only palette
editor (the brand strings are the Settings panel's since phase
190). /theme.html serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. The CSS-file theming (BOR_THEME, phase 62) is
retired (task 03): the effective theme is injected into every
@@ -1297,7 +1298,7 @@
<div class="container theme-shell">
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
#sources-gate pattern (phase 16) and the same .sources-gate
visual language: the palette + branding is what the login
visual language: the palette is what the login
locks (B5, owner-locked 2026-09-09). Visible for
anonymous, hidden for the admin (theme.js). The gate's
Sign in returns to the Theme view (the header's ?next=
@@ -1308,7 +1309,7 @@
</div>
<h2 id="theme-gate-title">Sign in to change the theme</h2>
<p class="sources-gate-sub">
The palette and branding are admin-only. Chat — and any
The palette is admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
@@ -1325,7 +1326,9 @@
Changes preview live as you pick;
<strong>Save theme</strong> bakes the palette into every
page it is served on — it applies on the first paint, no
pop-in.
pop-in. The app name, the chat placeholder and the footer
line are the Settings panel's: it owns the brand strings
and applies them the moment they are saved.
</p>
</div>
@@ -1333,50 +1336,16 @@
the bindings — effective-value populate, the live preview,
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
in #theme-contrast). No real submit: both buttons are
type="button"; maxlength=300 mirrors the server's 300-char
limit (the server re-validates — 422 naming the field).
The color inputs ship the BUILT-IN values (app/core/
theming.py BUILTIN_COLORS) — task 05 re-populates them
with the EFFECTIVE values on mount. -->
type="button". The 17 color inputs ship the BUILT-IN
values (app/core/theming.py BUILTIN_COLORS) — task 05
re-populates them with the EFFECTIVE values on mount.
Phase 190 (task 02, A1): the old "Branding" fieldset and
its three string inputs (#theme-app-name /
#theme-placeholder / #theme-footer) are GONE — the admin
Settings panel is the single writable owner of the brand
strings, so the tab neither shows nor sends them (a save
here can no longer shadow a panel value). -->
<form id="theme-form" novalidate>
<fieldset class="theme-group">
<legend class="theme-group-title">Branding</legend>
<!-- B4 (owner-locked): the strings keep the brand.js
runtime application — they apply via the /api/config
boot fetch on the NEXT page load; the live preview
covers the palette only. An empty field restores the
default (the env value). -->
<p class="theme-note">
These strings apply on the next page load — the live
preview covers the palette only. Leaving a field empty
restores its default.
</p>
<label for="theme-app-name">App name</label>
<input
id="theme-app-name"
name="app_name"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-placeholder">Chat input placeholder</label>
<input
id="theme-placeholder"
name="input_placeholder"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-footer">Footer line</label>
<input
id="theme-footer"
name="footer_text"
type="text"
maxlength="300"
autocomplete="off"
>
</fieldset>
<fieldset class="theme-group">
<!-- The eight text/background pairs (the five identity
pairs + the three semantic ink-on-bg pairs from the
+152 -72
View File
@@ -17,16 +17,24 @@ Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
20 inputs show the effective defaults (the 3 template strings +
the 17 built-in hexes — the 9 identity + the 8 semantic state,
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
suite can never drift from the stylesheet); Save
17 color inputs show the effective defaults (the built-in hexes —
the 9 identity + the 8 semantic state, phase 93 — parsed out of
``styles.css``'s ``:root`` IN-TEST — the suite can never drift from
the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only).
(warning-only), and the row lands with its 17 colors and NO brand
string (phase 190, A1).
1b. ``test_tab_renders_the_17_colors_and_no_string_inputs`` — the
phase-190 (A1) strip in the browser: the tab's DOM holds exactly the
17 ``type=color`` inputs and NONE of the old string inputs
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
count 0), the admin Settings panel being the single writable owner of
the brand strings; a Save posts a body with the 17 colors and no
string key.
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
@@ -34,8 +42,11 @@ Test → contract mapping (one story, one phase, one isolated file):
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
stylesheet application). Phase 190 (A1) turned the other half of the
old B4 split into a contract: a palette save writes NO brand string,
so the anonymous page still renders the env/template name,
placeholder and footer line after the save (the boot fetch has
nothing new to apply — a theme save can no longer rename the app).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
@@ -45,8 +56,8 @@ Test → contract mapping (one story, one phase, one isolated file):
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
20 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
17 built-in colors, serves NO theme tag, and the served bytes equal
a row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
@@ -89,7 +100,7 @@ from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.core.theming import COLOR_FIELDS, STRING_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
@@ -133,14 +144,10 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# The three brand strings are GONE from this surface (phase 190, A1 —
# the admin Settings panel is their single writable owner), so this
# suite carries no saved-string fixture any more: a palette save writes
# colors only, and every string assertion reads the env/template default.
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
@@ -306,12 +313,21 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save itself is test 1's job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings and this API ignores them. The seed asserts the
colors round-trip AND that the effective strings are still the
env/template defaults, i.e. a theme save cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -336,27 +352,22 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
#: The three string inputs the Theme tab used to render (phase 91) —
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
#: writable owner of the brand strings). Every pin below asserts they
#: count 0 in the tab DOM.
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 20 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
"""Assert the 17 color inputs show the given effective values."""
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
@@ -401,7 +412,6 @@ def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_0
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
@@ -418,15 +428,13 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 20 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 17 built-in hexes (9 identity + 8 semantic)
# parsed straight out of styles.css's :root (the resolver's
# missing-row branch).
_expect_form_values(page, defaults, builtin)
# The 17 color inputs show the EFFECTIVE defaults: the built-in
# hexes (9 identity + 8 semantic) parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, builtin)
# Set a distinct palette + the 3 strings, then Save through the
# real form — the PUT held so the §7.4 in-flight state is
# observable deterministically.
# Set a distinct palette, then Save through the real form — the PUT
# held so the §7.4 in-flight state is observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
@@ -448,19 +456,20 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# The inputs re-populate to the SAVED (effective) colors (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
_expect_form_values(page, PALETTE)
# The row landed in Postgres (the id-1 single row, all 20 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
# The row landed in Postgres (the id-1 single row) with its 17
# colors — every palette color differs from its built-in, so nothing
# collapsed to NULL — and with NO brand string: the tab no longer
# writes them, which is the write half of the TODO.md L3 fix
# (phase 190, A1).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in STRING_FIELDS:
assert getattr(row, field) is None, f"{field} must never be stored"
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
@@ -476,10 +485,73 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 1b. The phase-190 (A1) strip in the browser: 17 color inputs, no
# string inputs, and a Save body that carries no string key
# ---------------------------------------------------------------------------
def test_tab_renders_the_17_colors_and_no_string_inputs(
page: Page, app_url: str, db_ready: None
) -> None:
"""The owner-visible shape of the strip (phase 190, A1): the Theme
tab is a COLORS surface — its DOM holds exactly the 17
``type=color`` pickers and NONE of the three string inputs it used
to render, and the body Save posts carries the 17 colors and no
string key. That is the read half of "changing the App name in
settings broke": the tab can neither show a stale name nor bake one
into its row (the write half is the ignored-string PUT, pinned by
``tests/integration/test_ui_settings_api.py``)."""
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The three string inputs are ABSENT — not hidden, not empty: gone
# from the DOM, so no selector can fill one and no populate() can
# write a stale effective value into it.
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
# The 17 color pickers are all there and still type=color.
color_inputs = page.locator('#view-theme input[type="color"]')
expect(color_inputs).to_have_count(len(COLOR_FIELDS))
_expect_form_values(page, builtin)
# Save posts the 17 colors only — the captured PUT body names no
# string field (the client half of the bake being dead).
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
_fill_theme_form(page, PALETTE)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert set(body) == set(COLOR_FIELDS), (
f"the PUT body must be the 17 colors only, got {sorted(body)}"
)
for field in STRING_FIELDS:
assert field not in body
assert body.get("bg") == PALETTE["bg"]
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# + the computed palette at load (the no-pop-in proof), and the
# brand strings UNTOUCHED by a palette save (phase 190 A1)
# ---------------------------------------------------------------------------
@@ -527,16 +599,25 @@ def test_saved_theme_is_pre_paint_for_everyone(
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
# Phase 190 (A1): the palette save wrote NO brand string, so
# the anonymous page renders the env/template strings through
# the SAME brand.js boot fetch (name + window global, the
# composer placeholder, the footer line) — saving a theme can no
# longer rename the deployment, which is the owner-visible half
# of the TODO.md L3 fix. (The strings' own surface — the admin
# Settings panel — is pinned by test_admin_settings_panel.py and
# the phase-190 suite.)
defaults = _template_defaults()
expect(anon.locator(".brand-text")).to_have_text(
defaults["app_name"], timeout=15_000
)
assert anon.evaluate("() => window.BOR_BRAND") == defaults["app_name"]
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
"placeholder", defaults["input_placeholder"]
)
expect(anon.locator(".footer-text").first).to_have_text(
defaults["footer_text"]
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
@@ -625,7 +706,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 17 built-in colors, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -633,14 +714,13 @@ def test_anonymous_and_token_user_are_walled(
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
@@ -672,9 +752,9 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 20 defaults (the env/built-in
# The form re-populates to the 17 built-in colors (the built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
+704
View File
@@ -0,0 +1,704 @@
"""Phase 190 E2E (Playwright): the Settings-panel app name IS the app name.
Source: ``TODO.md`` L3 — "changing the App name in settings broke, it no
longer updates the name." The incident, in two defects:
1. **the shadow bake** — the admin Theme tab (phase 91) carried the three
brand strings in its form pre-populated with the EFFECTIVE value, and
``PUT /api/ui-settings`` wrote every one of them. So the first palette
save BAKED the then-effective name into the single ``ui_settings`` row,
``effective_settings`` resolved the strings tab-row-first, and every
LATER Settings-panel rename was shadowed: the panel showed the new value
with its ``db`` badge while ``/api/config`` — and the page — kept the
baked one;
2. **the stale open document** — neither surface re-applied the brand
strings after a save: ``settings.js`` re-GET the catalogue and stopped,
``theme.js`` ran the phase-92 palette reconcile and stopped, ``brand.js``
ran its pass once at boot. Even a save the server agreed with kept
painting the old name until a manual reload.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_incident_the_panel_name_survives_a_theme_tab_color_save`` — the
owner's own sequence at the wire: set the name in the Settings panel
(``/api/config`` serves it, the catalogue badge says ``db``) → change ONE
color in the Theme tab and save → ``/api/config`` STILL reports the panel
value, the stored ``ui_settings`` string columns stay NULL (never baked),
and a SECOND panel rename reaches ``/api/config`` and a fresh page (the
shadow the incident is). An OLD client whose PUT still carries the string
keys is accepted and changes NOTHING (A1's defensive half).
2. ``test_panel_brand_save_repaints_the_open_document`` — the A2 contract:
save the panel's three brand rows and the document that is ALREADY open
carries them in ``document.title``, ``.brand-text``, the footer line and
the composer placeholder (the no-reload half pinned by a document-life
sentinel) — and a SECOND rename repaints again, because the pass replaces
the name the page SHOWS and not only the template literal.
3. ``test_panel_row_reset_hands_the_brand_back_to_the_env_live`` — the
per-row Reset (``DELETE /api/admin/settings/{key}``) IS the string
fallback path, and it is live: all four places fall back to the env
values without a reload.
4. ``test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live`` —
the A1 fallback + the tab's self-heal: a pre-190 baked ``ui_settings``
row still paints at boot when no panel row owns the string; a panel row
written from ANOTHER browser wins; the Theme tab's own Save re-reads it
onto the open document without a reload; the Theme-tab Reset never blanks
the brand; and the stored row keeps its legacy string (the tab's PUT
neither rewrites nor clears those columns).
5. ``test_boot_paints_the_effective_name_with_one_config_request`` — the boot
path stays byte-identical (task 03's extraction, not a duplication): a
fresh load with no save paints the env name in all four places and issues
EXACTLY ONE ``/api/config`` request (no double-apply).
6. ``test_theme_tab_dom_is_colors_only`` — the task-02 strip in the browser:
17 ``type=color`` pickers and NONE of ``#theme-app-name`` /
``#theme-placeholder`` / ``#theme-footer``, and the Save body carries no
string key.
Deviation note on the phase file's (c) ("a Theme-tab Reset → the brand falls
back"): the Theme tab's Reset is the phase-91 all-null COLORS PUT, and A1
forbids it from touching the string columns, so a palette reset can never
change a brand string. The live fall-back the owner sees is therefore pinned
where it actually happens — the panel's per-row Reset (test 3) and a panel
row removed while the tab is open (test 4) — and the tab's Reset is pinned to
re-read the effective string and NEVER blank the brand (test 4).
Per-module app env (the phase-168 / phase-91 module-app pattern): ONE app
with ``BOR_SETTINGS_DB=1`` (the panel writes must actually take effect — the
conftest pins the overlay OFF for every other suite) and the three brand vars
pinned to DISTINCTIVE env values, so "the DB row now wins" and "the row is
gone again" are visible DOM changes rather than no-ops against the code
defaults.
DB isolation: the shared e2e Postgres keeps ``app_settings`` (the panel rows —
a leftover row retunes every later suite's app) and the single ``ui_settings``
row across suites. An autouse fixture truncates ``ui_settings`` AND clears the
overrides THROUGH THE API (``DELETE /api/admin/settings``, which deletes every
row and calls ``invalidate_settings()``) before and after every test — a bare
TRUNCATE would leave the app's cached ``Settings`` (the phase-168
``lru_cache``) serving a previous test's override, and every test here starts
from the real env base. The module teardown pins both tables empty.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, Request, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# ---------------------------------------------------------------------------
# The fixtures of this incident — every value distinct from every other AND
# from the code defaults, so "which name is on screen" is never ambiguous.
# ---------------------------------------------------------------------------
#: The env base — the module app's own environment, the fallback target of
#: every Reset here and the boot path's answer with no rows anywhere.
ENV_NAME = "Environment Brain E2E"
ENV_PLACEHOLDER = "Environment placeholder E2E"
ENV_FOOTER = "Environment footer E2E"
#: The panel's first rename (the incident's "set the name in Settings").
PANEL_ONE = "Panel Name One E2E"
PANEL_ONE_PLACEHOLDER = "Panel placeholder E2E"
PANEL_ONE_FOOTER = "Panel footer E2E"
#: The panel's SECOND rename — the one the baked Theme-tab row used to
#: swallow, and the one the live re-apply has to paint over the first.
PANEL_TWO = "Panel Name Two E2E"
#: What a pre-phase-190 Theme-tab save baked into ``ui_settings``.
LEGACY_BAKED_NAME = "Legacy Baked Tab Name"
#: One color pick for the Theme-tab save — distinct from the built-in
#: ``#f43f5e``, so the save is a real write and not the NULL collapse.
THEME_FIELD = "brand"
THEME_INPUT = "#theme-brand"
THEME_PICK = "#4f46e5"
THEME_PICK_2 = "#556677"
#: The three string inputs the Theme tab used to render (phase 91), REMOVED
#: in phase 190 (A1 — the panel is the strings' single writable owner).
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
# ---------------------------------------------------------------------------
# Per-module app env (the phase-168 panel / phase-91 theme pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the overlay ON (the whole
subject here is a SAVED brand string; the conftest pins it off for every
other suite — the ``test_pictorial_pdf.py`` opt-in pattern) and the three
brand vars pinned to DISTINCTIVE env values (the phase-61/62 leak guard:
an operator's local ``.env`` may carry the owner's name, and "the DB row
wins / the env wins again" has to be measured against values this suite
owns)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# The mock-calibrated gate pins (the conftest pattern) — no chat turn is
# sent here, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# Phase 168's kill switch, ON for this suite (the opt-in).
env["BOR_SETTINGS_DB"] = "1"
# The distinctive env base — what every Reset hands control back to.
env["BOR_APP_NAME"] = ENV_NAME
env["BOR_INPUT_PLACEHOLDER"] = ENV_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = ENV_FOOTER
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _admin_client() -> httpx.Client:
"""The API side of the form login — the panel in ANOTHER browser, i.e.
the out-of-band writer the self-heal test needs."""
client = httpx.Client(timeout=60.0)
r = client.post(f"{APP_URL}/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login → {r.status_code}: {r.text[:120]}"
return client
def _clean_state() -> None:
"""Back to the env base: truncate the single ``ui_settings`` row (raw —
the theme row is read per request, no process cache) and clear the panel
overrides THROUGH THE API, which deletes every ``app_settings`` row AND
invalidates the app's cached ``Settings`` (a bare TRUNCATE would leave
the previous test's override in effect inside the app)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
client = _admin_client()
try:
r = client.delete(f"{APP_URL}/api/admin/settings", timeout=30)
assert r.status_code == 200, r.text[:200]
finally:
client.close()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_state()
yield
_clean_state()
with SessionLocal() as db:
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
assert db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
def _save_panel_rows(page: Page, values: dict[str, str]) -> None:
"""Type the given catalogue rows and press Save (the phase-168 flow),
waiting for the save's own status line AND the settled dirty hint (the
transition, not just the text — a second save lands the same line)."""
page.fill("#settings-filter", "")
for key, value in values.items():
page.fill(f'tr[data-key="{key}"] .settings-control', value)
expect(page.locator("#settings-dirty")).to_have_text(
f"{len(values)} unsaved changes"
)
page.click("#settings-save")
expect(page.locator("#settings-status")).to_contain_text(
f"Saved {len(values)} settings.", timeout=30_000
)
expect(page.locator("#settings-dirty")).to_have_text("", timeout=30_000)
def _config(page: Page) -> dict[str, object]:
"""``GET /api/config`` from the browser context — the very payload
``brand.js`` applies at boot and every live re-apply reads."""
r = page.request.get(f"{APP_URL}/api/config")
assert r.status == 200, r.text()[:200]
body: dict[str, object] = r.json()
return body
def _panel_source(page: Page, key: str) -> str:
"""The catalogue's own source badge for one key (``db`` / ``env`` /
``default``) — the panel's statement of WHO owns the value."""
r = page.request.get(f"{APP_URL}/api/admin/settings")
assert r.status == 200, r.text()[:200]
for item in r.json()["items"]:
if item["key"] == key:
return str(item["source"])
raise AssertionError(f"{key} missing from the catalogue")
def _seed_legacy_tab_row(app_name: str) -> None:
"""Put a pre-phase-190 baked row in the table — the shape an old
Theme-tab save wrote (the then-effective name beside a color). The API
CANNOT write the string columns any more (that is what A1 changed), so
the legacy row is seeded directly."""
with SessionLocal() as db:
db.merge(
UiSettings(
id=1,
app_name=app_name,
input_placeholder=None,
footer_text=None,
brand=THEME_PICK,
)
)
db.commit()
def _tab_row_strings() -> dict[str, str | None]:
"""The STORED string columns of the single ``ui_settings`` row — the DB
truth, not the effective view the GET reports."""
with SessionLocal() as db:
row = db.get(UiSettings, 1)
if row is None:
return {"app_name": None, "input_placeholder": None, "footer_text": None}
return {
"app_name": row.app_name,
"input_placeholder": row.input_placeholder,
"footer_text": row.footer_text,
}
def _arm_no_reload(page: Page) -> None:
"""A document-life sentinel: any navigation or reload wipes it, so the
later equality check IS the "without a reload" proof."""
page.evaluate("() => { window.__bor190Doc = 1; }")
def _assert_same_document(page: Page) -> None:
assert page.evaluate("() => window.__bor190Doc") == 1, (
"the open document was replaced — the assertions around this one "
"would pass on a reload and prove nothing about the live apply"
)
def _expect_brand(page: Page, name: str, placeholder: str, footer: str) -> None:
"""The four DOM places the A2 contract covers, on the document that is
open RIGHT NOW (each ``expect`` retries on its own clock)."""
expect(page).to_have_title(re.compile(re.escape(name)), timeout=15_000)
expect(page.locator(".brand-text")).to_have_text(name)
expect(page.locator(".footer-text").first).to_have_text(footer)
expect(page.locator("#message-input")).to_have_attribute("placeholder", placeholder)
# ---------------------------------------------------------------------------
# 1. THE INCIDENT: a Theme-tab color save used to shadow every later
# Settings-panel rename
# ---------------------------------------------------------------------------
def test_incident_the_panel_name_survives_a_theme_tab_color_save(
page: Page, db_ready: None
) -> None:
"""The owner's sequence end to end: rename in the panel → save a color in
the Theme tab → the panel's name is STILL the effective one → rename
AGAIN and the new name is what the server serves and the next page paints
(pre-fix the FIRST rename was baked into ``ui_settings`` and shadowed the
second one forever)."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# The starting state: no row anywhere, so the effective name is the env
# base this app booted with.
assert _config(page)["app_name"] == ENV_NAME
assert _panel_source(page, "app_name") == "env"
# 1. Set the app name in the Settings panel (the phase-168 flow).
_save_panel_rows(page, {"app_name": PANEL_ONE})
expect(page.locator('tr[data-key="app_name"] .settings-src')).to_have_text("DB")
assert _config(page)["app_name"] == PANEL_ONE, (
"the panel row must be what /api/config serves"
)
assert _panel_source(page, "app_name") == "db"
# 2. The admin opens the Theme tab, changes ONE color and saves. Pre-fix
# this PUT carried the pre-populated effective strings and baked
# PANEL_ONE into the row.
page.goto(APP_URL + "/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
# …the panel value survives the palette save (the wire answer the brand
# layer boots from).
assert _config(page)["app_name"] == PANEL_ONE, (
"a Theme-tab color save must not take ownership of the app name"
)
# …the row it wrote carries the color and NO brand string: the string
# columns stay exactly as stored (NULL here — never baked).
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, f"the Theme-tab save baked the brand strings: {_tab_row_strings()}"
# …and the tab's own GET still REPORTS the effective strings, read-only
# (old-client compat): the panel's name, the env placeholder and footer.
r = page.request.get(f"{APP_URL}/api/ui-settings")
assert r.status == 200, r.text()[:200]
body = r.json()
assert body["app_name"] == PANEL_ONE
assert body["input_placeholder"] == ENV_PLACEHOLDER
assert body["footer_text"] == ENV_FOOTER
# 3. THE INCIDENT HALF: rename again in the panel. Pre-fix the baked row
# shadowed this save — the panel showed "Two", the app kept "One".
page.goto(APP_URL + "/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_save_panel_rows(page, {"app_name": PANEL_TWO})
assert _config(page)["app_name"] == PANEL_TWO, (
"the second rename must reach /api/config — the shadow IS the incident"
)
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_TWO)))
# 4. The defensive half (A1): an OLD client that still PUTs the three
# string keys is accepted and changes NOTHING — the strings are
# dropped before validation and the columns are neither written nor
# cleared. Only the color moves.
stale = page.request.put(
f"{APP_URL}/api/ui-settings",
data=json.dumps(
{
THEME_FIELD: THEME_PICK_2,
"app_name": "Rebaked By An Old Client",
"input_placeholder": "Rebaked placeholder",
"footer_text": "Rebaked footer",
}
),
headers={"Content-Type": "application/json"},
)
assert stale.status == 200, stale.text()[:200]
assert _config(page)["app_name"] == PANEL_TWO, "an old client cannot re-bake the name"
assert _tab_row_strings() == {
"app_name": None,
"input_placeholder": None,
"footer_text": None,
}, "the ignored strings must not be written into the row either"
assert stale.json()["app_name"] == PANEL_TWO, (
"the response reports the EFFECTIVE strings, not the body it dropped"
)
# ---------------------------------------------------------------------------
# 2. The live apply (A2): the panel's save repaints the OPEN document
# ---------------------------------------------------------------------------
def test_panel_brand_save_repaints_the_open_document(page: Page, db_ready: None) -> None:
"""Save the brand rows in the panel and the document that is ALREADY open
repaints — the title, the header brand, the footer line and the composer
placeholder — with no reload (the phase-168 "takes effect on clicking
save" contract, extended from the catalogue to the display). A second
rename repaints again: the shared pass replaces the name the page SHOWS,
not only the template literal, or the second save would paint nothing."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
# The starting paint is the env base, on the surface that is open now.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
# ONE batch over the panel's three brand rows.
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
# …the SAME document carries all of them — no navigation, no reload (the
# sentinel below is what turns "without a reload" into an assertion).
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_ONE
_assert_same_document(page)
# The second rename: the page shows PANEL_ONE, so a pass that only ever
# replaced the template literal would silently paint nothing.
_save_panel_rows(page, {"app_name": PANEL_TWO})
_expect_brand(page, PANEL_TWO, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == PANEL_TWO
_assert_same_document(page)
# A FRESH document (the boot pass alone) shows the same name everywhere —
# the prose the boot pass rewrites included, with no stale env name left.
page.goto(APP_URL + "/")
expect(page.locator(".brand-text")).to_have_text(PANEL_TWO, timeout=15_000)
expect(page.locator("h1.empty-state-title")).to_have_text(f"Hey! I'm {PANEL_TWO}.")
assert ENV_NAME not in page.locator("#view-chat").inner_text(), (
"a stale env name survived the boot pass"
)
# ---------------------------------------------------------------------------
# 3. The panel's Reset IS the string fallback — and it is live too
# ---------------------------------------------------------------------------
def test_panel_row_reset_hands_the_brand_back_to_the_env_live(
page: Page, db_ready: None
) -> None:
"""``DELETE /api/admin/settings/{key}`` is how a brand string falls back
(the Theme tab's PUT never touches those columns), and the fall-back is
visible on the open document with no reload."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/settings.html")
expect(page.locator("#settings-content")).to_be_visible(timeout=15_000)
# A saved override is live first (the baseline the Reset has to undo).
_save_panel_rows(
page,
{
"app_name": PANEL_ONE,
"input_placeholder": PANEL_ONE_PLACEHOLDER,
"footer_text": PANEL_ONE_FOOTER,
},
)
_arm_no_reload(page)
_expect_brand(page, PANEL_ONE, PANEL_ONE_PLACEHOLDER, PANEL_ONE_FOOTER)
# Reset all three rows: each DELETE lands its own status line AND
# re-applies the brand to the open document.
for key, env_var in (
("app_name", "BOR_APP_NAME"),
("input_placeholder", "BOR_INPUT_PLACEHOLDER"),
("footer_text", "BOR_FOOTER_TEXT"),
):
page.click(f'tr[data-key="{key}"] .settings-reset')
expect(page.locator("#settings-status")).to_contain_text(
f"{env_var} is back to the environment value.", timeout=30_000
)
expect(page.locator(f'tr[data-key="{key}"] .settings-src')).to_have_text("env")
# …the open document is back on the env base, still the same document.
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
_assert_same_document(page)
assert _config(page)["app_name"] == ENV_NAME
# ---------------------------------------------------------------------------
# 4. A legacy baked row still applies (the A1 fallback), the panel row beats
# it, and the Theme tab's own settle re-reads the effective name
# ---------------------------------------------------------------------------
def test_legacy_baked_tab_row_paints_then_the_panel_row_beats_it_live(
page: Page, db_ready: None
) -> None:
"""No data migration happened (A1): a row an old Theme-tab save baked
still paints when no panel row owns the string, and stops shadowing the
moment the panel takes ownership — including onto the OPEN tab, whose
Save re-reads the effective brand while the stored row keeps its legacy
string (the tab's PUT neither rewrites nor clears those columns)."""
page.set_default_timeout(30_000)
_seed_legacy_tab_row(LEGACY_BAKED_NAME)
# The fallback branch: no panel row → the baked tab row IS the name
# (the public /api/config answer, and the catalogue's own badge: the
# panel owns nothing here).
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
login(page, APP_URL, next="/theme.html")
assert _panel_source(page, "app_name") == "env"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_arm_no_reload(page)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
# The panel (ANOTHER browser) takes ownership of the string while this
# tab is open — the open tab cannot know until its next settle.
other = _admin_client()
try:
r = other.put(f"{APP_URL}/api/admin/settings/app_name", json={"value": PANEL_ONE})
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == PANEL_ONE, "the panel row beats the baked row"
page.wait_for_timeout(500) # nothing pulls the open tab — it stays put
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME)
# The Theme tab's Save (a color pick only — the strings are not its to
# write) re-reads the effective brand onto the OPEN document.
page.fill(THEME_INPUT, THEME_PICK_2)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text("Theme saved.", timeout=30_000)
expect(page.locator(".brand-text")).to_have_text(PANEL_ONE, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(PANEL_ONE)))
_assert_same_document(page)
# The stored row still carries the legacy string (never rewritten, never
# cleared), so the documented fallback keeps working.
assert _tab_row_strings()["app_name"] == LEGACY_BAKED_NAME
# Handing the string back to the environment (the panel's DELETE, the
# string fallback path) while the tab is open, then the tab's Reset: the
# open document re-reads the effective name — the legacy row again — and
# a palette reset NEVER blanks the brand.
other = _admin_client()
try:
r = other.delete(f"{APP_URL}/api/admin/settings/app_name")
assert r.status_code == 200, r.text
finally:
other.close()
assert _config(page)["app_name"] == LEGACY_BAKED_NAME
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator(".brand-text")).to_have_text(LEGACY_BAKED_NAME, timeout=15_000)
expect(page).to_have_title(re.compile(re.escape(LEGACY_BAKED_NAME)))
_assert_same_document(page)
# ---------------------------------------------------------------------------
# 5. The boot path stays byte-identical: ONE /api/config request, no re-apply
# ---------------------------------------------------------------------------
def test_boot_paints_the_effective_name_with_one_config_request(
page: Page, db_ready: None
) -> None:
"""Task 03 extracted the boot DOM pass into the shared function; it must
not DOUBLE-apply. A fresh load with no save paints the effective (here:
env) name in all four places and issues EXACTLY ONE ``/api/config``
request — the boot fetch ``brand.js`` has always made, nothing more."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
config_requests: list[str] = []
def watch(request: Request) -> None:
if "/api/config" in request.url:
config_requests.append(request.url)
page.on("request", watch)
page.goto(APP_URL + "/")
expect(page.locator("#message-input")).to_be_visible(timeout=15_000)
_expect_brand(page, ENV_NAME, ENV_PLACEHOLDER, ENV_FOOTER)
assert page.evaluate("() => window.BOR_BRAND") == ENV_NAME
assert page.evaluate("() => window.BOR_BRAND_APPLIED") == ENV_NAME, (
"the shared pass records what the document shows (the re-apply's "
"source string) — and the boot pass is the only one that ran"
)
page.wait_for_timeout(500) # any second, stray fetch would have landed
assert config_requests == [f"{APP_URL}/api/config"], (
f"the boot is ONE /api/config request, got {config_requests}"
)
# ---------------------------------------------------------------------------
# 6. The Theme tab is a COLORS surface (the task-02 strip)
# ---------------------------------------------------------------------------
def test_theme_tab_dom_is_colors_only(page: Page, db_ready: None) -> None:
"""The read half of the single-writer fix, in the isolated suite: the tab
renders the 17 color pickers and NONE of the three string inputs — so no
save can pre-populate an effective name and bake it back — and its Save
posts a body with no string key."""
page.set_default_timeout(30_000)
login(page, APP_URL, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
expect(page.locator('#view-theme input[type="color"]')).to_have_count(17)
# The body the Save posts carries the colors only — no string key.
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
page.fill(THEME_INPUT, THEME_PICK)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert "app_name" not in body
assert "input_placeholder" not in body
assert "footer_text" not in body
assert body.get(THEME_FIELD) == THEME_PICK
+42 -44
View File
@@ -145,14 +145,9 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
# No saved-string fixture since phase 190 (A1): the admin Settings
# panel owns the brand strings, so this surface saves COLORS only and
# every string assertion reads the env/template default.
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
@@ -234,17 +229,23 @@ def _template_defaults() -> dict[str, str]:
}
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate is
the last visible step — the app-name input carries the effective
default (the static markup ships it empty, so only a settled
``loadSettings`` can have written it). From here on, no in-flight
mount load can race the test's own save/reset refetch (an
out-of-order settle would re-reconcile the open document onto a
stale read)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
Phase 190 (task 02) moved it: the settle used to be read off the
``#theme-app-name`` input (the only input the static markup ships
empty, so a value in it proved ``populate`` had run) — that input is
gone with the tab's string fieldset, so the network is the source of
truth now. From here on, no in-flight mount load can race the test's
own save/reset refetch (an out-of-order settle would re-reconcile the
open document onto a stale read)."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
@@ -476,12 +477,20 @@ def _cookies(page: Page) -> dict[str, str]:
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save path is this module's own tests' job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings — and the response keeps reporting the effective
(env/template) strings, so a palette seed cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -506,18 +515,9 @@ def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
@@ -545,16 +545,14 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The mount's initial load has SETTLED inside this helper (the
# row-less state — no tag, no overrides) before the test touches
# the form: no in-flight load can race the save's own refetch.
_login_to_theme(page, app_url)
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The mount's initial load has settled (the row-less state — no
# tag, no overrides) before the test touches the form: no
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
# Fill the 20 inputs (the 3 strings + the 17-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# Fill the 17 color inputs and Save through the real form — the PUT
# held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
_fill_theme_form(page, PALETTE)
+10 -7
View File
@@ -467,11 +467,11 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# feed the PUT body, the live preview, and the refetch).
for field in COLOR_FIELDS:
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
# The 3 strings stay default (untouched — they hold the effective
# template defaults the module server pins).
# The tab has no string inputs (phase 190, A1) — the 3 brand
# strings are the Settings panel's, so nothing here can touch them.
# Capture the PUT body the form builds (the wiring proof: all 17
# grays ride the wire together with the 3 default strings).
# Capture the PUT body the form builds (the wiring proof: the 17
# grays ride the wire and NOTHING else — no string key, phase 190).
captured: dict[str, Any] = {}
def handle(route: Route) -> None:
@@ -498,11 +498,14 @@ def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) ->
# hidden (it re-checks the saved state on the save's refetch).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The PUT carried every field: the 17 grays + the 3 default strings
# (the strings were never touched, so they ride at their defaults).
# The PUT carried the 17 grays and NOTHING else (phase 190, A1:
# the brand strings left the tab's body — an old client's save can
# no longer re-bake one and shadow the Settings panel).
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert body == {**GRAY, **defaults}, f"unexpected PUT body: {body}"
assert body == GRAY, f"unexpected PUT body: {body}"
for field in defaults:
assert field not in body, f"{field} must never ride the tab's PUT body"
# The inputs re-populate to the SAVED (effective) grays (the
# save's refetch is the canonical state)…
+21 -34
View File
@@ -176,26 +176,20 @@ def _parse_roles(svg: str) -> dict[str, str]:
}
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _login_to_theme(page: Page, app_url: str) -> None:
"""Log in straight into the Theme view AND wait for its mount-time
``GET /api/ui-settings`` to ANSWER — the settle signal.
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate
is the last visible step — the app-name input carries the
effective default (the static markup ships it empty, so only a
settled ``loadSettings`` can have written it)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
Phase 190 (task 02) moved it with the tab's string fieldset: the
settle used to be read off the (now removed) ``#theme-app-name``
input, the only input the static markup shipped empty. The network
is the source of truth now."""
with page.expect_response(
lambda r: "/api/ui-settings" in r.url, timeout=30_000
) as info:
login(page, app_url, next="/theme.html")
assert info.value.status == 200, "the mount's GET /api/ui-settings failed"
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
def _favicon_href(page: Page) -> str:
@@ -519,9 +513,7 @@ def test_live_preview_repaints_the_favicon(
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A clean start: the href is the route (no row → the golden
# art served by the route).
@@ -568,9 +560,7 @@ def test_save_restores_the_route_url(
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
@@ -588,9 +578,10 @@ def test_save_restores_the_route_url(
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
# palette's bytes (the PUT saved the 17 form colors — the 3
# driving colors + the 14 built-ins (→ NULL); the brand strings are
# the Settings panel's since phase 190 (A1) and this body carried
# none, so the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
@@ -614,9 +605,7 @@ def test_reset_restores_the_route_url(
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
@@ -672,9 +661,7 @@ def test_view_refresh_restores_the_route_url(
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
_login_to_theme(page, app_url)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):
+136 -24
View File
@@ -44,6 +44,28 @@ database returned them, so the comparison measures the index. Setup stays inside
generate ≈ 0.5 s, seed ≈ 2 s, ``CREATE INDEX CONCURRENTLY`` ≈ 1.4 s, read-back +
exact top-100 ≈ 3.5 s — ≈ 8 s against the ~60 s target.
**The determinism premise (measured while fixing the 2026-10-18 flake).** A
plan is a cost decision, and two things outside this module's control move it
on a shared dev DB: the statistics of BOTH relations the tier's SELECT joins
(the fixture used to ``ANALYZE`` only ``chunks``, so the join side could carry
an estimate from whatever ran before it) and the ``embedding IS NULL`` fraction
of ``chunks`` — a NULL-embedding row is a candidate the index scan must skip
before the LIMIT fills, so ambient NULL rows inflate the estimated index cost.
Measured on this corpus with ambient NULL-embedding rows inserted alongside the
5,000 fixture rows: at 4,000 of them the UNFORCED planner leaves
``ix_chunks_embedding_hnsw`` for ``Sort → Hash Join → Seq Scan on chunks`` with
the index present and valid (at 2,000 and at 18,000 it uses it again — the band
moves with the statistics, which is exactly what makes it a flake rather than a
finding). Therefore: the fixture analyzes both joined relations, asserts the
index is present and VALID before any plan is read, and the index assertions run
with the alternatives that can disguise a scan refused
(``enable_seqscan`` AND ``enable_bitmapscan`` — verified across the pollution
levels above, and verified to still report ``False`` with the index dropped, so
the pin cannot pass on a missing index). The UNFORCED preference — the shape of
the 2026-10-15 incident — is asserted when the relation holds nothing but the
fixture corpus, which is the case a clean CI DB and this file's own teardown
leave.
**What 5,000 rows proves and what it does not.** It is enough for a PLAN pin
(the planner needs a populated relation to prefer an ordered index scan over a
seq scan + top-N sort) and enough for a deterministic index-vs-exact
@@ -326,6 +348,23 @@ def _plan(session: Session, sql: str) -> list[dict[str, Any]]:
return nodes
def _index_validity(session: Session) -> bool | None:
"""``pg_index.indisvalid`` for :data:`INDEX_NAME` (``None`` = absent) — the
catalog fact behind every plan assertion here, so a failure names the
missing/INVALID index instead of a plan shape."""
value = session.execute(
text(
"SELECT i.indisvalid"
" FROM pg_index i"
" JOIN pg_class c ON c.oid = i.indexrelid"
" WHERE c.relname = :name"
),
{"name": INDEX_NAME},
).scalar()
session.rollback()
return None if value is None else bool(value)
def _describe(nodes: list[dict[str, Any]]) -> str:
return ", ".join(
f"{node.get('Node Type')}[{node.get('Index Name') or node.get('Relation Name') or '?'}]"
@@ -369,7 +408,12 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
config = Config() # no alembic.ini — env.py reads the URL from app config
config.set_main_option("script_location", "alembic")
try:
preexisting = session.execute(text(f"SELECT count(*) FROM {RELATION}")).scalar() or 0
# `preexisting` is the relation's own state (the skip guard below);
# its EMBEDDED half is what the exact reference can score, and the
# pristine flag is the premise of the unforced-plan arm of pin 1.
preexisting, preexisting_embedded = session.execute(
text(f"SELECT count(*), count(embedding) FROM {RELATION}")
).one()
if preexisting > MAX_RELATION_ROWS:
pytest.skip(
f"{RELATION} holds {preexisting} rows: the exact reference is a Python "
@@ -384,15 +428,34 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
session.rollback()
command.upgrade(config, "0029") # the ONE definition of the index
session.rollback()
# Planner statistics for the plan pin: a freshly loaded relation has
# none, and a row estimate of 0 makes "seq scan + top-N sort" look free.
# The premise of every pin below, checked where it is established: an
# INVALID remnant (phase 188 D2) or a drop by whatever else owns the
# schema on this box must report ITSELF, not surface three tests later
# as "the plan did not name the index".
validity = _index_validity(session)
assert validity is True, (
f"{INDEX_NAME} is {validity!r} after migration 0029 "
"(None = absent, False = the INVALID remnant D2 describes) — every "
"plan assertion below would be measuring a scan"
)
# Planner statistics for the plan pins — BOTH relations the tier's
# SELECT joins, because the plan is a JOIN plan and a stale estimate on
# either side flips it (see the module docstring). A freshly loaded
# relation has none, and a row estimate of 0 makes
# "seq scan + top-N sort" look free.
session.execute(text(f"ANALYZE {RELATION}"))
session.execute(text("ANALYZE documents"))
session.commit()
rows = session.execute(select(Chunk.id, Chunk.embedding)).all()
# The SAME predicate the tier applies: a NULL-embedding row is in
# neither the index nor the exact reference (a zero vector would score
# as a real candidate and land in no result set).
rows = session.execute(
select(Chunk.id, Chunk.embedding).where(Chunk.embedding.is_not(None))
).all()
stored: dict[Any, list[float]] = {row[0]: row[1] or [] for row in rows}
assert len(stored) >= preexisting + CORPUS_CHUNKS, (
f"expected {CORPUS_CHUNKS} fixture rows on top of {preexisting}, "
f"the relation holds {len(stored)}"
assert len(stored) >= preexisting_embedded + CORPUS_CHUNKS, (
f"expected {CORPUS_CHUNKS} fixture rows on top of "
f"{preexisting_embedded} embedded rows, the relation holds {len(stored)}"
)
# The exact sets are the expensive half (30 × N dot products); the two
# recall pins differ only in the ef_search they ask the index for.
@@ -401,6 +464,9 @@ def hnsw_corpus() -> Iterator[dict[str, Any]]:
"exact": [_exact_top(stored, query) for query in queries],
"chunk_ids": set(stored),
"plan_sql": _vector_statement_sql(),
# The unforced-planner assertion (pin 1) is meaningful only on a
# relation this module owns — see the determinism premise above.
"pristine": preexisting == 0,
}
finally:
session.rollback()
@@ -437,14 +503,27 @@ def _force_index_scan(session: Session) -> None:
caller's transaction instead of reaching the tier. Every caller resets it in
a ``finally`` — a leaked ``off`` would slow every later test on this pooled
connection — and the assertion beside it proves the lever took effect.
``enable_bitmapscan`` goes off with it because refusing sequential scans
alone does not refuse a scan: measured on the 2026-10-18 flake, the planner
answered ``enable_seqscan = off`` with
``Sort → Nested Loop → Index Scan on documents → Bitmap Heap Scan on chunks
(ix_chunks_document_id)`` — every chunk row read and sorted through the
btree on ``document_id``, the very full read these pins exist to rule out,
wearing an index's name. With both levers off the only access path left for
``chunks`` that can serve the ORDER BY is the HNSW graph; with the index
dropped the same arm reports ``False`` (verified), so the guard cannot pass
on a missing index.
"""
session.rollback()
session.execute(text("SET enable_seqscan = off"))
session.execute(text("SET enable_bitmapscan = off"))
def _release_index_scan(session: Session) -> None:
session.rollback()
session.execute(text("RESET enable_seqscan"))
session.execute(text("RESET enable_bitmapscan"))
session.rollback()
@@ -467,34 +546,66 @@ def test_plan_uses_the_hnsw_index(db: Session, hnsw_corpus: dict[str, Any]) -> N
Explaining the captured statement is also what proves D1's "no query
rewrite": the ``<=>`` the retriever already emits is exactly the operator
``vector_cosine_ops`` serves.
Two arms, because the unforced one is a cost decision this file does not
own alone (module docstring, "The determinism premise"): the DEFAULT plan is
asserted while the relation holds nothing but the fixture corpus (a clean CI
DB, and this module's own teardown), and the FORCED plan — sequential and
bitmap scans refused, so nothing that reads every row can be chosen — is
asserted always.
"""
nodes = _plan(db, hnsw_corpus["plan_sql"])
assert _uses_index(nodes, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan — the incident is back: [{_describe(nodes)}]"
)
assert not _seq_scans(nodes, RELATION), (
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
)
db.rollback()
sql = hnsw_corpus["plan_sql"]
if hnsw_corpus["pristine"]:
nodes = _plan(db, sql)
assert _uses_index(nodes, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan — the incident is back "
f"(index validity: {_index_validity(db)}): [{_describe(nodes)}]"
)
assert not _seq_scans(nodes, RELATION), (
f"a sequential scan of {RELATION} survived the index: [{_describe(nodes)}]"
)
_force_index_scan(db)
try:
forced = _plan(db, sql)
assert _uses_index(forced, INDEX_NAME), (
f"{INDEX_NAME} is not in the plan even with sequential and bitmap "
f"scans refused (index validity: {_index_validity(db)}): "
f"[{_describe(forced)}]"
)
assert not _seq_scans(forced, RELATION), (
f"a sequential scan of {RELATION} survived the index: "
f"[{_describe(forced)}]"
)
finally:
_release_index_scan(db)
db.rollback()
def test_plan_without_the_index_seq_scans_the_relation(
db: Session, hnsw_corpus: dict[str, Any]
) -> None:
"""Vacuity guard: the same statement, same session, same statistics, with
index scans refused, MUST fall back to ``Seq Scan on chunks``.
the index access paths refused, MUST fall back to ``Seq Scan on chunks``.
``SET LOCAL enable_indexscan = off`` rather than dropping the index: a
``DROP INDEX CONCURRENTLY`` + rebuild would cost a second graph build and
leave the corpus unindexed if the rebuild failed, while this GUC is
transaction-scoped (it dies with the rollback) and removes exactly the
access path under test. Without this test the plan pin would pass vacuously
on any plan that merely mentions some index.
``SET LOCAL enable_indexscan/indexonlyscan/bitmapscan = off`` rather than
dropping the index: a ``DROP INDEX CONCURRENTLY`` + rebuild would cost a
second graph build and leave the corpus unindexed if the rebuild failed,
while these GUCs are transaction-scoped (they die with the rollback) and
together remove every access path that reaches an index — including the
bitmap path into ``ix_chunks_document_id``, which alone would let this guard
answer with a disguised full read instead of the seq scan it asserts.
Without this test the plan pin would pass vacuously on any plan that merely
mentions some index.
"""
sql = hnsw_corpus["plan_sql"]
db.rollback()
db.execute(text("SET LOCAL enable_indexscan = off"))
db.execute(text("SET LOCAL enable_indexonlyscan = off"))
# Bitmap scans go too: they are the other way into ``ix_chunks_document_id``,
# so leaving them on let this guard depend on the planner's mood instead of
# on the access path it removes (the 2026-10-18 flake answer was a Bitmap
# Heap Scan of chunks and no Seq Scan at all).
db.execute(text("SET LOCAL enable_bitmapscan = off"))
nodes = _plan(db, sql)
assert _seq_scans(nodes, RELATION), (
f"the vacuity guard itself did not seq-scan {RELATION}: [{_describe(nodes)}]"
@@ -534,8 +645,9 @@ def test_top100_recall_at_the_shipped_ef_search(
depth_nodes = _plan(db, hnsw_corpus["plan_sql"])
assert _uses_index(depth_nodes, INDEX_NAME), (
f"this recall run at ef_search={EF_PIN} did not use {INDEX_NAME} even "
"with sequential scans refused — the pin would be measuring a scan "
f"and calling it parity: [{_describe(depth_nodes)}]"
"with sequential AND bitmap scans refused — the pin would be "
"measuring a scan and calling it parity (index validity: "
f"{_index_validity(db)}): [{_describe(depth_nodes)}]"
)
overlaps: list[float] = []
+214 -23
View File
@@ -17,6 +17,21 @@ The auth + public-contract half of task 01, driven through the real app
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
and the two effective strings are the entire response.
Phase 190 (tasks 01 + 02) adds the brand-string OWNERSHIP pins (A1): the
Settings-panel row (``app_settings``, phase 168) beats a conflicting
``ui_settings`` (Theme-tab) row — the ``PUT /api/ui-settings`` string
bake is dead (the PUT ignores string fields in the body and never
writes the string columns), the GET reports the EFFECTIVE (panel)
strings read-only, and the anonymous ``/api/config`` boot fetch serves
the panel's name over a legacy baked-in tab row. Task 01 pinned those
three as documented-RED ``xfail(strict=True)`` cases; task 02 flipped
the resolution order and stopped the bake, so the markers are gone and
all three are GREEN. The colors half of the PUT contract stayed GREEN
throughout (the 17 colors, their normalization and the upsert behave
exactly as phases 91/92/93 left them); the STRINGS half of the older
phase-91 pins moved with the write path — a body string no longer
round-trips, it is ignored.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
@@ -30,12 +45,30 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.config import get_settings, invalidate_settings
from app.core import theming
from app.main import app as fastapi_app
from app.models import UiSettings
from app.models import AppSetting, UiSettings
from tests.conftest import ADMIN_PASSWORD
#: The three brand strings and their distinct panel/tab/body values
#: (phase 190) — one triple per field so every pin reads as one story.
_PANEL_STRINGS = {
"app_name": "Panel Name",
"input_placeholder": "Panel placeholder…",
"footer_text": "Panel footer",
}
_TAB_STRINGS = {
"app_name": "Tab Name",
"input_placeholder": "Tab placeholder…",
"footer_text": "Tab footer",
}
_BODY_STRINGS = {
"app_name": "Baked By PUT",
"input_placeholder": "Baked placeholder…",
"footer_text": "Baked footer",
}
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
@@ -60,6 +93,44 @@ def _admin_client() -> TestClient:
return c
@pytest.fixture()
def overlay_panel(db: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Phase 190: the settings overlay ON with an empty ``app_settings``
table (the ``test_admin_settings_api`` idiom) — the Settings panel's
write path is only IN EFFECT through the overlay, so the pins that
plant panel rows need the switch on. Teardown deletes the rows and
re-resolves the cache — NO row and no stale resolution may survive
this module (conftest pins the overlay OFF for every other suite)."""
db.execute(text("DELETE FROM app_settings"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
invalidate_settings()
yield
db.execute(text("DELETE FROM app_settings"))
db.commit()
invalidate_settings()
get_settings.cache_clear()
def _seed_tab_row(db: Session, **colors: str) -> None:
"""The single ``ui_settings`` row with the three TAB strings, plus
any colors the pin needs — committed directly (after task 02 the
API no longer writes strings, so the seed must not depend on the
behavior under test)."""
db.add(UiSettings(id=1, **_TAB_STRINGS, **colors))
db.commit()
def _seed_panel_rows(db: Session) -> None:
"""The three Settings-panel rows, committed (the panel writes them
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
sees the table), then the settings cache is cleared so the next
resolution layers them over the env base."""
db.add_all(AppSetting(key=key, value=value) for key, value in _PANEL_STRINGS.items())
db.commit()
invalidate_settings()
def test_anonymous_get_and_put_403(client: TestClient) -> None:
"""Router-level ``require_admin``: both routes are 403 ``admin only``
for the unsigned-in caller (one fixed detail — no enumeration)."""
@@ -94,7 +165,10 @@ def test_token_user_get_and_put_403(client: TestClient) -> None:
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
"""The admin passes the gate on both methods: GET reports the
effective defaults (row missing), PUT persists + reports the new
effective values, and a follow-up GET reads them back."""
effective COLORS, and a follow-up GET reads them back. Phase 190
(A1): the PUT body carries a brand string and the response reports
the ENV one instead — the string is ignored, the palette half of the
phase-91 round trip is unchanged."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.get("/api/ui-settings")
@@ -109,12 +183,12 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
json={"app_name": "Reese Brain", "brand": "#818cf8"},
)
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["app_name"] == get_settings().app_name # the string is ignored
assert r.json()["brand"] == "#818cf8"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["app_name"] == get_settings().app_name # never stored (A1)
assert r.json()["brand"] == "#818cf8"
# Untouched fields stay at their defaults (DB-over-env / -built-in).
assert r.json()["footer_text"] == get_settings().footer_text
@@ -248,31 +322,148 @@ def test_api_config_carries_no_theme_key(client: TestClient) -> None:
def test_api_config_returns_db_strings_after_admin_put(
client: TestClient, db: Session
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
(the frontend's boot fetch — no admin needed) serves the DB strings
over the env values; the untouched fields keep the env values; the
five-key set is unchanged (the retired ``theme`` key is absent)."""
admin = _admin_client()
r = admin.put(
"/api/ui-settings",
json={
"app_name": "Brain of Testy",
"input_placeholder": "Ask the vault…",
"footer_text": "Powered by my own models",
},
)
assert r.status_code == 200, r.text
"""B1 with a set row: once the admin has saved the three brand
strings, the ANONYMOUS /api/config (the frontend's boot fetch — no
admin needed) serves them over the env values; the untouched fields
keep the env values; the six-key set is unchanged (the retired
``theme`` key is absent).
Phase 190 (A1) moved the writer: the strings are seeded through the
Settings panel's surface (``app_settings`` + the overlay, the phase
168 ``PUT /api/admin/settings/{key}`` rows), because the Theme-tab
PUT no longer writes them — the DB-over-env contract this pin owns
is the same one, now owned by the panel."""
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == "Brain of Testy"
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The colors never ride /api/config (the pre-paint injection is
# task 02; brand.js's surface is the five keys).
assert "theme" not in body
assert body["version"] == get_settings().app_version
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
def test_put_ignores_string_fields_in_body(
client: TestClient, db: Session
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Theme-tab PUT no longer
writes the three brand strings — a body carrying ``app_name`` /
``input_placeholder`` / ``footer_text`` DIFFERENT from the stored
row's values (and from the effective value) is still 200, but the
stored row's string columns stay EXACTLY as they were (re-GET shows
the old values, a direct row read shows no write). An old client's
save can therefore never re-bake a string and shadow the Settings
panel again.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail`` on the defect: ``_validate_strings`` used to trim and
store every string in the body — that bake is the mechanism of the
``TODO.md`` L3 incident).
"""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
# The strings half — pre-fix the response round-trips the baked
# body value ("Baked By PUT"); post-fix the stored value stands.
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
assert r.json()["input_placeholder"] == _TAB_STRINGS["input_placeholder"]
assert r.json()["footer_text"] == _TAB_STRINGS["footer_text"]
# The stored row itself was never touched (not just the response).
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
assert row.input_placeholder == _TAB_STRINGS["input_placeholder"]
assert row.footer_text == _TAB_STRINGS["footer_text"]
# A follow-up GET shows the OLD values, not the body's.
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
def test_put_still_stores_the_colors_beside_the_ignored_strings(
client: TestClient, db: Session
) -> None:
"""The colors half of the same contract — GREEN pre-phase AND
post-phase: the 17 colors in the same body as the ignored strings
ARE stored and reported (the Theme tab stays a colors surface; the
string drop does not narrow the palette half by one color)."""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
assert r.json()["bg"] == "#001122" # GREEN pre-fix and post-fix
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.bg == "#001122" # GREEN pre-fix and post-fix
assert row.brand is None # an unsent color stays NULL (the built-in reports)
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["bg"] == "#001122"
def test_get_reports_effective_strings_read_only(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""A1 reaching the API: with a conflicting Settings-panel row,
``GET /api/ui-settings`` reports the EFFECTIVE (panel) value for
each string — the tab's read side stays the live view (old-client
compat), and it stays READ-ONLY (the GET never writes the row).
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
as a strict ``xfail``: the endpoint used to report the baked
``ui_settings`` value).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
admin = _admin_client()
r = admin.get("/api/ui-settings")
assert r.status_code == 200, r.text
body = r.json()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The 20-value shape is unchanged (17 colors + 3 strings).
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
# Read-only: the GET never wrote the effective values into the row.
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
def test_api_config_reports_panel_strings_over_the_baked_tab_row(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""The owner-visible incident through the frontend's boot fetch
(phase 190, ``TODO.md`` L3): with a panel row AND a legacy baked-in
``ui_settings`` row, the ANONYMOUS ``GET /api/config`` — the very
call ``brand.js`` drives the title / brand / footer / placeholder
from — serves the Settings-panel strings, not the baked tab values.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail``: pre-fix ``effective_settings`` resolved the tab row
first, so the open page kept the OLD name after every later panel
change — the incident as the browser sees it).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # public, unchanged key set
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
+408
View File
@@ -0,0 +1,408 @@
"""Unit: the live-brand-on-save marker pins (phase 190, tasks 01 + 03).
Defect (b) of the phase (``TODO.md`` L3, the stale half): a save in
either admin surface never re-applied the brand strings to the OPEN
document. ``settings.js`` re-GETed the catalogue after a save but never
touched the brand; ``theme.js``'s save ran ``applyServedTheme`` (the
phase-92 COLOR reconcile) and stopped there; ``brand.js`` applied the
strings ONCE at boot from ``/api/config`` — so the open page kept the old
name until a manual reload.
Task 01 pinned that as the ABSENT behavior; **task 03 inverted it**, and
this module now pins the fix:
* ``APPLY_BRAND`` (``applyBrandFromConfig``) is ONE function in
``brand.js`` — the single owner of the brand DOM — exported on the
layer's existing ``window`` surface and called by the BOOT
(``applyBrand``) and by both admin surfaces. The boot is the
EXTRACTION, not a duplication: the four DOM writes exist once;
* ``settings.js`` re-applies after a save / per-row Reset / Clear all
that touched one of the three brand keys, and after nothing else;
* ``theme.js`` re-applies after Save and after Reset (a reset can make a
legacy baked-in string fall back, so the page must re-read it);
* a FAILED re-apply leaves the DOM at its last applied state — the
phase-168 "never a blanked panel" precedent applied to the brand.
House JS convention (``test_frontend_brand.py`` /
``test_settings_frontend_view.py`` / ``test_save_chat_ui.py``): the
browser behavior is pinned at source-marker level here, E2E in the
phase's ``tests/e2e/test_app_name_settings_fix.py`` (task 04).
``BRAND_DOM_TOKENS`` (the four writes that constitute "applying the
brand strings": the document title, the ``.brand-text`` header, the
``#message-input`` composer placeholder, the ``.footer-text`` line) stays
the ownership pin it was in task 01 — those belong to the ONE shared pass
in ``brand.js`` and must NEVER be duplicated inside ``settings.js`` / the
``theme.js`` save paths. The live-apply is asserted by presence of the
shared CALL, never by a second copy of the DOM path.
"""
from __future__ import annotations
from pathlib import Path
ASSETS = Path(__file__).resolve().parents[2] / "frontend" / "assets"
BRAND_JS = ASSETS / "brand.js"
SETTINGS_JS = ASSETS / "settings.js"
THEME_JS = ASSETS / "theme.js"
#: The ONE shared brand pass task 03 extracted out of ``brand.js`` (the
#: phase-190 contract name) — boot and live-apply run this function.
APPLY_BRAND = "applyBrandFromConfig"
#: The four DOM writes that ARE "re-applying the brand strings" — the
#: shared pass owns them; a save surface that writes one of these
#: directly has duplicated the brand path (the defect the phase fixes
#: must not be re-introduced as a second copy).
BRAND_DOM_TOKENS = ("document.title", "brand-text", "message-input", "footer-text")
#: The three keys the Settings panel owns and whose save/reset therefore
#: re-applies the brand (phase 190 A1). A save of any other row must not.
BRAND_KEYS = ("app_name", "input_placeholder", "footer_text")
#: The two statements every live re-apply is made of, pinned verbatim so
#: the counts below count CALL SITES and not prose (the house
#: ``= fetch("/api/config"`` idiom in ``test_frontend_brand.py``). Both
#: surfaces write them identically — the shared semantics, one shape.
CONFIG_FETCH = 'const r = await fetch("/api/config", { cache: "no-store" });'
SHARED_CALL = "window.applyBrandFromConfig(await r.json());"
# The three settings.js settle paths (save / per-row Reset / clear-all) —
# each ends its success arm with the catalogue re-GET (phase 168,
# untouched) followed by the brand re-apply (phase 190 task 03, additive).
SETTINGS_SETTLE_PATHS = (
"async function saveSettings()",
"async function resetRow(",
"async function confirmClearAll(",
)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _body(js: str, header: str, closer: str = "\n }") -> str:
"""The source of the function whose declaration starts at ``header``
up to its closing brace — the house slice idiom
(``test_settings_frontend_view.py``): these view modules nest their
functions at two spaces inside ``mount``, so the first ``\\n }``
after the header closes it (nested blocks close at four).
``closer`` opens the same idiom to top-level functions (``brand.js``,
whose blocks close at column 0)."""
start = js.index(header)
end = js.find(closer, start)
assert end != -1, f"unterminated function body for {header!r}"
return js[start:end]
def _every_marker_preceded_by(body: str, marker: str, before: str) -> None:
"""Every ``marker`` occurrence sits after some ``before``
occurrence (the order pin for a body with more than one of each)."""
positions = []
start = 0
while (i := body.find(marker, start)) != -1:
positions.append(i)
start = i + 1
assert positions, f"marker {marker!r} missing from the body"
for pos in positions:
assert body.rfind(before, 0, pos) != -1, (
f"{marker!r} must never run before {before!r}"
)
# ---------------------------------------------------------------------------
# brand.js — the ONE shared pass, exported, and the boot runs IT
# ---------------------------------------------------------------------------
def test_brand_js_exports_the_shared_pass() -> None:
"""Task 03's extraction: the brand DOM lives in ONE named function
``applyBrandFromConfig(cfg)`` and reaches the page's MODULE scripts
through the layer's existing export surface — the ``window`` globals
it has always had (``window.BOR_BRAND``, ``window.BOR_CONFIG_PROMISE``).
``brand.js`` stays a classic script (no ``export`` statement —
``test_frontend_brand.py`` owns that pin), so the assignment IS the
export."""
js = _read(BRAND_JS)
assert "function applyBrandFromConfig(cfg) {" in js, (
"the shared pass is a named function taking the /api/config payload"
)
assert "window.applyBrandFromConfig = applyBrandFromConfig;" in js, (
"the shared pass is exported on the window surface settings.js / "
"theme.js already read (window.BOR_BRAND, window.BOR_CONFIG_PROMISE)"
)
def test_boot_call_site_is_the_extraction_not_a_second_copy() -> None:
"""The boot behavior is byte-identical (same request, same
assignments, same order) because the boot RUNS the shared pass:
``applyBrand`` awaits the parse-time promise and hands the settled
payload over — its own body contains NONE of the four DOM writes and
each of them occurs exactly ONCE in the file (the extraction, not a
duplication)."""
js = _read(BRAND_JS)
boot = _body(js, "function applyBrand() {", closer="\n}")
assert "applyBrandFromConfig(cfg)" in boot, "the boot calls the shared pass"
assert "BOR_CONFIG_PROMISE.then" in boot, "the boot still rides the parse-time fetch"
for token in BRAND_DOM_TOKENS:
assert token not in boot, (
f"the boot must not carry its own copy of the {token!r} write — "
"the shared pass is the ONE owner"
)
# Each of the four brand DOM writes exists exactly once in the file.
assert js.count('document.querySelectorAll(".brand-text")') == 1
assert js.count('document.querySelectorAll(".footer-text")') == 1
assert js.count('document.querySelector("#message-input")') == 1
# …and every `document.title` write sits inside the shared pass.
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
assert js.count("document.title =") == shared.count("document.title =") >= 1
# The boot wiring is the phase-39 one (pinned in test_frontend_brand.py).
assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js
assert js.count('= fetch("/api/config"') == 1, (
"the boot fetch stays the file's only fetch — the SURFACES fetch, "
"brand.js never fetches twice"
)
def test_the_shared_pass_reapplies_over_the_name_already_shown() -> None:
"""A LIVE re-apply runs against a document whose literal passes
already rewrote the default literal into the previous name — so the
shared pass also replaces ``window.BOR_BRAND_APPLIED`` (the title,
the prose text nodes, the aria-label/placeholder/meta attributes:
the three places the literal passes touch). At boot the global is
``""`` so the clause is skipped and the boot path stays exactly the
phase-39 sequence (the byte-identical requirement)."""
js = _read(BRAND_JS)
shared = _body(js, "function applyBrandFromConfig(cfg) {", closer="\n}")
assert 'window.BOR_BRAND_APPLIED = "";' in js, (
"the previous-name global ships empty — at boot the clause never runs"
)
assert "window.BOR_BRAND_APPLIED" in shared, "the shared pass reads the last applied name"
assert "if (previous && previous !== name) {" in shared, (
"the re-apply clause is gated on a previous name that differs "
"(the boot no-op stays byte-identical)"
)
assert "document.title.replaceAll(previous, name)" in shared
assert "node.nodeValue.replaceAll(previous, name)" in shared
assert 'window.BOR_BRAND_APPLIED = name;' in shared, (
"the pass records what the document now shows, so the NEXT "
"re-apply replaces the right string"
)
# ---------------------------------------------------------------------------
# settings.js — the panel re-applies, gated on the three brand keys
# ---------------------------------------------------------------------------
def test_settings_js_settle_paths_reapply_the_brand() -> None:
"""The INVERTED task-01 pin: every settle path of the Settings panel
(Save, the per-row Reset, Clear all) re-applies the brand to the open
document through the shared pass — AFTER the phase-168 catalogue
re-GET, which stays the catalogue's own refresh (untouched). Clear
all passes all three keys (clearing hands every brand string back to
the environment); the Reset passes its own key; Save passes the keys
that actually landed — including the prefix of a batch that stopped
at a later failure (K4), because that value DID take effect."""
js = _read(SETTINGS_JS)
assert APPLY_BRAND in js, (
"task 03 inverted the task-01 absence pin: the panel now reaches "
"the shared brand pass"
)
for header in SETTINGS_SETTLE_PATHS:
body = _body(js, header)
assert "await reapplyBrand(" in body, (
f"{header} must re-apply the brand on settle"
)
assert "await loadSettings()" in body, (
f"the catalogue re-GET stays ({header}) — the phase-168 "
"refresh the re-apply EXTENDS, never replaces"
)
_every_marker_preceded_by(body, "await reapplyBrand(", "await loadSettings()")
save = _body(js, "async function saveSettings()")
assert "await reapplyBrand(saved)" in save, (
"Save re-applies the keys that landed (the K4 prefix included)"
)
assert "await reapplyBrand([key])" in _body(js, "async function resetRow("), (
"the per-row Reset re-applies the key it handed back to the env"
)
assert "await reapplyBrand(BRAND_KEYS)" in _body(
js, "async function confirmClearAll("
), "Clear all re-applies every brand key"
def _brand_keys_line(js: str) -> str:
"""The ``BRAND_KEYS`` declaration (the gate's data)."""
start = js.index("const BRAND_KEYS = [")
end = js.index("];", start)
return js[start:end]
def test_settings_js_gates_the_reapply_on_the_three_brand_keys() -> None:
"""The gate itself, in ONE helper: only ``app_name`` /
``input_placeholder`` / ``footer_text`` re-apply. A save of any of
the ~72 other rows costs no extra request and no DOM churn — and the
helper is the single place the panel reaches ``/api/config``, so the
surfaces cannot drift (the pin: exactly one shared-pass call and one
config fetch in the file)."""
js = _read(SETTINGS_JS)
helper = _body(js, "async function reapplyBrand(")
keys_line = _brand_keys_line(js)
assert tuple(BRAND_KEYS) == (
"app_name",
"input_placeholder",
"footer_text",
), "the phase-190 brand trio is the panel's own key set"
for key in BRAND_KEYS:
assert f'"{key}"' in keys_line, (
f"the gate must name {key!r} — otherwise its save never re-applies"
)
assert "if (!keys.some((key) => BRAND_KEYS.includes(key))) return;" in helper, (
"a settle that touched no brand key makes NO re-apply request"
)
assert js.count(SHARED_CALL) == 1, (
"ONE shared-pass call site in the panel — inside the helper"
)
assert js.count(CONFIG_FETCH) == 1, (
"ONE /api/config fetch in the panel module (the helper's)"
)
assert '{ cache: "no-store" }' in helper, (
"never a cached brand — the same no-store read the boot makes"
)
def test_settings_js_reapply_is_single_flight_under_the_existing_guard() -> None:
"""The §7.4 never-stale guard, EXTENDED rather than forked: the Save
button's own ``saveInFlight`` flag still owns the save (the re-apply
is awaited before it drops, so a second Save cannot start while the
config fetch is out), and the ONE flag inside the shared helper covers
the settle paths that reach it — a second Save / Reset / Clear all
never stacks a second ``/api/config`` fetch."""
js = _read(SETTINGS_JS)
helper = _body(js, "async function reapplyBrand(")
assert "let brandReapplyInFlight = false;" in js
assert "if (brandReapplyInFlight) return;" in helper, (
"one re-apply at a time — a second settle never stacks a fetch"
)
assert "brandReapplyInFlight = true;" in helper
finally_arm = helper[helper.index("} finally {") :]
assert "brandReapplyInFlight = false;" in finally_arm, (
"the flag clears in the finally — a failed re-apply can never "
"strand the guard on (the house stuck-button contract)"
)
save = _body(js, "async function saveSettings()")
guard_off = save.index("saveInFlight = false;")
assert save.index("await reapplyBrand(saved)") < guard_off, (
"the brand re-apply sits INSIDE the saveInFlight scope — the "
"existing guard's reach, not a parallel one"
)
# ---------------------------------------------------------------------------
# theme.js — the tab re-applies after Save and after Reset
# ---------------------------------------------------------------------------
def test_theme_js_save_and_reset_reapply_the_brand() -> None:
"""The Theme tab's two settle paths run the SAME shared pass after
the phase-92 palette reconcile: the tab no longer EDITS the strings
(A1) but a Reset nulls the stored row, and a legacy baked-in string
then FALLS BACK (panel row → env) — the open document must re-read
it without a reload. One code path for both surfaces' semantics
(``reapplyBrand``, called by Save and by Reset)."""
js = _read(THEME_JS)
save = _body(js, "async function saveTheme()")
reset = _body(js, "async function resetTheme()")
for name, body in (("saveTheme", save), ("resetTheme", reset)):
assert "await reapplyBrand()" in body, (
f"{name} re-applies the brand strings on success"
)
assert body.index("applyServedTheme(settings)") < body.index(
"await reapplyBrand()"
), "the palette reconcile keeps its place; the brand follows it"
assert js.count(SHARED_CALL) == 1, (
"ONE shared-pass call site in the tab module — inside its helper"
)
assert js.count(CONFIG_FETCH) == 1, (
"ONE /api/config fetch in the tab module, and the SAME statement "
"the panel uses (one shared semantics, one shape)"
)
helper = _body(js, "async function reapplyBrand()")
assert "if (brandReapplyInFlight) return;" in helper, (
"one re-apply at a time across Save and Reset (§7.4)"
)
# ---------------------------------------------------------------------------
# the failure arm — never a blanked brand (both surfaces)
# ---------------------------------------------------------------------------
def test_a_failed_reapply_leaves_the_dom_at_its_last_applied_state() -> None:
"""The phase-168 "never a blanked panel" precedent, applied to the
brand: in BOTH helpers a non-2xx returns BEFORE the shared pass runs,
and a network/JSON failure is swallowed with no DOM write at all —
the open page keeps the name, footer and placeholder it already
shows, and the save's own status / error line is the visibility."""
for path in (SETTINGS_JS, THEME_JS):
js = _read(path)
helper = _body(js, "async function reapplyBrand(")
assert "if (!r.ok) return;" in helper, (
f"{path.name}: a non-2xx /api/config must never reach the DOM"
)
assert helper.index("if (!r.ok) return;") < helper.index(
SHARED_CALL
), f"{path.name}: the shared pass runs only on a settled payload"
assert "} catch {" in helper, (
f"{path.name}: an unreachable app / non-JSON body is caught, "
"not thrown (the brand never blanks)"
)
catch_arm = helper[helper.index("} catch {") : helper.index("} finally {")]
for token in BRAND_DOM_TOKENS:
assert token not in catch_arm, (
f"{path.name}: the failure arm writes nothing ({token!r})"
)
# ---------------------------------------------------------------------------
# the ownership pins carried unchanged from task 01
# ---------------------------------------------------------------------------
def test_settings_js_never_writes_the_brand_dom_directly() -> None:
"""The shared-pass ownership pin — GREEN before, during AND after the
phase: the four brand DOM writes (title, ``.brand-text``, the composer
placeholder, ``.footer-text``) belong to the ONE brand path in
``brand.js``; the Settings panel must never duplicate one of them
inline. Task 03's live-apply goes through the shared
``applyBrandFromConfig`` call, so this file stays free of the tokens
even after the fix (no second copy of the DOM path)."""
js = _read(SETTINGS_JS)
for token in BRAND_DOM_TOKENS:
assert token not in js, (
f"settings.js must not own the brand DOM write {token!r} — "
"the shared brand.js pass is its ONE owner"
)
def test_theme_js_save_and_reset_reconcile_colors_only() -> None:
"""GREEN pre-phase AND through the phase: ``saveTheme``'s (and
``resetTheme``'s) success path calls ``applyServedTheme`` — the
phase-92 COLOR reconcile — and neither writes the brand DOM (no
title, no ``.brand-text``, no composer placeholder, no footer line).
Task 03's live brand reaches the document through the shared call,
which is exactly what this pin keeps true."""
js = _read(THEME_JS)
save = _body(js, "async function saveTheme()")
reset = _body(js, "async function resetTheme()")
assert "applyServedTheme(settings)" in save, (
"the success path keeps the phase-92 color reconcile"
)
assert "applyServedTheme(settings)" in reset, (
"the reset path keeps the phase-92 color reconcile"
)
for name, body in (("saveTheme", save), ("resetTheme", reset)):
for token in BRAND_DOM_TOKENS:
assert token not in body, (
f"{name} must stay colors-only at the DOM level — "
f"the brand write {token!r} belongs to the shared brand pass"
)
+17 -11
View File
@@ -292,7 +292,7 @@ def test_router_writes_active_state_title_and_meta() -> None:
assert "Generate and revoke the API tokens that let people use the app." in js
assert 'theme: "Theme · Brain of Reese"' in js
assert (
"Set the palette and branding — the theme is baked into every served page, "
"Set the palette — the theme is baked into every served page, "
"live on the first paint."
) in js
assert 'settings: "Settings · Brain of Reese"' in js
@@ -1444,20 +1444,26 @@ def test_theme_view_scaffold_in_the_shell() -> None:
"#theme-content must ship hidden (anonymous-safe)"
)
# The static form skeleton (the E2E-stable-selectors house
# convention): the 3 labeled branding text inputs (maxlength=300)
# and the 17 labeled type=color palette inputs (the 9 identity
# variables, then the 8 semantic state variables — phase 93 —
# one per theming.COLOR_FIELDS field).
# convention): the 17 labeled type=color palette inputs (the 9
# identity variables, then the 8 semantic state variables — phase
# 93 — one per theming.COLOR_FIELDS field), and NOTHING else: the
# phase-190 (A1) strip removed the "Branding" fieldset and its
# three string inputs — the admin Settings panel owns the brand
# strings, so the tab neither renders nor sends them.
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
"the #theme-form must be STATIC markup in the shell"
)
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
), f"missing the visible label for #{field_id}"
assert re.search(
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
), f"#{field_id} must be a text input with maxlength=300"
assert f'id="{field_id}"' not in body, (
f"#{field_id} is gone from the Theme tab (phase 190 A1)"
)
assert f'for="{field_id}"' not in body, f"#{field_id}'s label is gone too"
assert "maxlength=\"300\"" not in body, (
"the 300-char string inputs were the branding fieldset's — gone"
)
assert not re.search(r"<legend[^>]*>\s*Branding\s*</legend>", body), (
"the Branding fieldset is gone (phase 190 A1)"
)
for field_id in (
"theme-bg",
"theme-surface",
+65
View File
@@ -221,6 +221,71 @@ def test_get_engine_creates_once_and_caches() -> None:
assert first is second
# ---------------------------------------------------------------------------
# applied_override_keys — "which keys does the panel own?" (phase 190)
# ---------------------------------------------------------------------------
class _KeyResult:
"""The ``session.execute(select(AppSetting.key))`` shape — the one
call :func:`applied_override_keys` makes on the caller's session."""
def __init__(self, keys: list[str]) -> None:
self._keys = keys
def scalars(self) -> _KeyResult:
return self
def all(self) -> list[str]:
return list(self._keys)
class _RecordingSession:
"""A stand-in session: it answers the key read and counts the calls
(the resolver's "one read for all three string fields" contract)."""
def __init__(self, keys: list[str]) -> None:
self.keys = keys
self.calls = 0
def execute(self, _stmt: object) -> _KeyResult:
self.calls += 1
return _KeyResult(self.keys)
def test_applied_override_keys_filters_orphans_and_locked_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typing import Any, cast
"""Only a row the overlay would ACTUALLY apply counts: an orphan row
(a field a later release removed) and a LOCKED A3 row (a
hand-inserted ``database_url`` — read-only, never in effect) are
excluded, so a row that changes nothing can never win a brand
string from the Theme-tab fallback (phase 190 A1)."""
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
session = _RecordingSession(
["app_name", "database_url", "embedding_dim", "no_such_setting"]
)
assert settings_overlay.applied_override_keys(
cast(Any, session)
) == frozenset({"app_name"})
assert session.calls == 1
def test_applied_override_keys_kill_switch_reads_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from typing import Any, cast
"""``BOR_SETTINGS_DB=0`` means NO row is in effect (D6) — the helper
says so WITHOUT touching the database, so ``effective_settings``
falls through to the Theme-tab row instead of shadowing it with a
row the resolver ignores."""
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
session = _RecordingSession(["app_name"])
assert settings_overlay.applied_override_keys(cast(Any, session)) == frozenset()
assert session.calls == 0 # short-circuited before any statement
# ---------------------------------------------------------------------------
# merge — the defensive fallback (a validator change in a later release
# blames a field no override owns and no single drop heals the set: the
+77 -38
View File
@@ -3,7 +3,10 @@ pins).
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
semantic state colors — B3 revised, owner permission 2026-09-10,
TODO.md L3) plus the 3 branding strings: 20 form fields total. The
TODO.md L3) and NOTHING else since phase 190 (task 02, A1: the admin
Settings panel is the single writable owner of the 3 brand strings, so
the tab's "Branding" fieldset, its three string inputs and the string
half of its PUT body are gone). The
live behavior (live preview, the Save/Reset PUT body, the served-theme
sync, the contrast warnings) is E2E-gated (``tests/e2e/
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
@@ -12,20 +15,25 @@ design depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
frontend sources and asserts on their mechanisms):
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
(the 3 branding strings, then the 17 colors in the server's
``theming.COLOR_FIELDS`` order — identity, brand, then state):
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
the 8 semantic pickers automatically only if this order holds;
* ``theme.js`` ``FIELDS`` — exactly 17 entries (the colors ONLY — no
``kind: "string"`` entry may come back) in the FORM's order, the
server's ``theming.COLOR_FIELDS`` order (identity, brand, then
state): everything that iterates FIELDS (live preview,
``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'
tag content) covers the 8 semantic pickers automatically only if this
order holds — and the body ``collectBody`` builds carries no string
key either (the phase-190 pin: an old cached client's save can no
longer re-bake a brand string);
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
(4.5:1) pairs: the five identity pairs + the three semantic
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
(decorative borders, no contrast duty — the same rule as
``--line`` / ``--grid-line``); the docstring mirror is pinned in
``tests/unit/test_theming.py``;
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
+ visible labels; the 17 color inputs are ``type="color"`` and ship
* ``index.html`` ``#view-theme`` — the 17 color inputs carry E2E-stable
ids + visible labels, and the three removed string inputs stay absent
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
the phase-190 A1 strip); the color inputs are ``type="color"`` and ship
the BUILT-IN static values — asserted against
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
house drift pattern — the same guard ``test_theming.py`` runs on
@@ -88,16 +96,24 @@ def _root_declarations() -> dict[str, str]:
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
# ---------- theme.js: FIELDS — the 20 form fields, in order ----------
# ---------- theme.js: FIELDS — the 17 color form fields, in order ----------
def test_fields_lists_all_20_in_form_order() -> None:
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
then the 17 color fields — in the form's own order (the palette
fieldset's 9 identity pickers, then the State colors fieldset's 8
semantic pickers), each with its E2E-stable ``theme-*`` id and
kind (the color entries are ``kind: "color"`` — the live preview,
``collectBody``, and the served-theme sync all key on it)."""
def test_fields_lists_the_17_colors_and_no_strings() -> None:
"""``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — in the
form's own order (the palette fieldset's 9 identity pickers, then
the State colors fieldset's 8 semantic pickers), each with its
E2E-stable ``theme-*`` id and kind (the color entries are
``kind: "color"`` — the live preview, ``collectBody``, and the
served-theme sync all key on it).
Phase 190 (task 02, A1) is what makes this a CONTRACT and not a
coincidence: the admin Settings panel is the single writable owner
of ``app_name`` / ``input_placeholder`` / ``footer_text``, so no
entry may carry ``kind: "string"`` — an entry means a populated
input AND a PUT-body key, and a string key is exactly how the
Theme tab used to bake the then-effective name into its row and
shadow every later panel change (``TODO.md`` L3)."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
@@ -105,11 +121,8 @@ def test_fields_lists_all_20_in_form_order() -> None:
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}"
assert len(fields) == 17, f"FIELDS must list exactly 17 entries, got {len(fields)}"
assert [(f, i) for f, i, _ in fields] == [
("app_name", "theme-app-name"),
("input_placeholder", "theme-placeholder"),
("footer_text", "theme-footer"),
("bg", "theme-bg"),
("surface", "theme-surface"),
("ink", "theme-ink"),
@@ -127,11 +140,14 @@ def test_fields_lists_all_20_in_form_order() -> None:
("accent_bg", "theme-accent-bg"),
("accent_ink", "theme-accent-ink"),
("accent_line", "theme-accent-line"),
], "FIELDS must list the 20 fields in the form's order"
assert all(kind == "color" for _, _, kind in fields[3:]), (
"the 17 palette entries are all kind color"
], "FIELDS must list the 17 color fields in the form's order"
assert all(kind == "color" for _, _, kind in fields), (
"every palette entry is kind color — no string field is back in the tab"
)
assert all(kind == "string" for _, _, kind in fields[:3])
for name in theming.STRING_FIELDS:
assert f'{{ field: "{name}"' not in js[start:end], (
f"{name} left the Theme tab in phase 190 — the panel owns it"
)
def test_fields_color_order_is_the_server_color_fields_order() -> None:
@@ -153,6 +169,28 @@ def test_fields_color_order_is_the_server_color_fields_order() -> None:
)
def test_collectbody_builds_the_17_colors_and_no_string_keys() -> None:
"""``collectBody`` — the PUT body the Save button sends — is
FIELDS-driven and therefore carries the 17 colors ONLY: no string
key appears anywhere in its source (phase 190, A1), and the old
trimmed-string branch (``value.trim() || null`` — the bake) is gone.
The server ignores body strings defensively too
(``app/api/ui_settings.py``), so this is the client half of "a
Theme-tab save can never shadow the Settings panel again"."""
js = _js()
start = js.index("function collectBody()")
end = js.index("\n }", start)
body_src = js[start:end]
assert body_src, "collectBody must exist"
for name in theming.STRING_FIELDS:
assert name not in body_src, f"{name} must never ride the PUT body"
assert "value.trim()" not in body_src, "the string-trim bake branch must be gone"
assert "for (const f of FIELDS)" in body_src, (
"the body stays FIELDS-driven — one ordering source for the PUT"
)
assert "isHex(value)" in body_src, "the colors keep their hex-or-null rule"
# ---------- theme.js: PAIRS — exactly the eight WCAG pairs ----------
@@ -197,23 +235,24 @@ def test_line_vars_are_excluded_from_pairs() -> None:
)
# ---------- index.html: the #view-theme form (20 inputs) ----------
# ---------- index.html: the #view-theme form (17 color inputs) ----------
def test_theme_view_carries_all_20_labeled_inputs() -> None:
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
selectors convention): each with a visible ``<label for>``; the 17
palette inputs are ``type="color"`` (the 3 branding inputs
``type="text"``)."""
def test_theme_view_carries_the_17_labeled_color_inputs() -> None:
"""The 17 color inputs are STATIC markup in the shell (the
E2E-stable selectors convention), each with a visible
``<label for>`` and ``type="color"`` — and NO string input is in the
view (phase 190, task 02: the "Branding" fieldset and its
``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer``
inputs are gone, so the tab can neither show nor send a brand
string; the same absence is asserted in the browser by
``tests/e2e/test_admin_theme_tab.py``)."""
body = _theme_view(_html())
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
for field_id in text_ids:
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="text"[^>]*>', body), (
f"#{field_id} must be a text input"
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
assert f'id="{field_id}"' not in body, (
f"#{field_id} must be gone from the Theme tab (phase 190 A1)"
)
assert f'for="{field_id}"' not in body, f"#{field_id}'s label must be gone"
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
+219 -3
View File
@@ -29,6 +29,20 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3).
* Phase 190 (tasks 01 + 02): the brand-string OWNERSHIP pins (A1, owner
confirmed 2026-10-18) — the Settings-panel row (``app_settings``,
phase 168) is the single writable owner of the three strings, so the
resolution order is panel row → ``ui_settings`` (Theme-tab) row →
env. Task 01 pinned the order as a strict ``xfail`` (the baked-in tab
row SHADOWED the panel — the TODO.md L3 incident); task 02 flipped
``effective_settings`` and removed the marker, so the ownership pin
(``test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row``)
is GREEN here. Beside it: the no-panel-row fallback pin (the tab row
still applies — the A1 fallback a legacy baked-in row lands in, green
before AND after the flip), the PER-FIELD pin (a panel row for one
string never touches the other two), and the kill-switch pin
(``BOR_SETTINGS_DB=0`` → no row is in effect, so the tab row keeps
its phase-91 fallback).
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
renderer, byte-identical for the built-in palette (the golden
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
@@ -47,12 +61,13 @@ import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from sqlalchemy import select
import pytest
from sqlalchemy import select, text, update
from sqlalchemy.orm import Session
from app.config import Settings
from app.config import Settings, invalidate_settings
from app.core import theming
from app.models import UiSettings
from app.models import AppSetting, UiSettings
REPO_ROOT = Path(__file__).resolve().parents[2]
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
@@ -72,6 +87,16 @@ def _start_row_missing(db: Session) -> None:
db.commit()
def _clear_panel_rows(db: Session) -> None:
"""Phase 190: the ``app_settings`` rows (the Settings-panel surface,
phase 168) are global state exactly like the single ``ui_settings``
row — wipe them so the ownership pins start AND end with an empty
panel (a surviving row would shadow every later suite's strings
once the overlay is enabled)."""
db.execute(text("DELETE FROM app_settings"))
db.commit()
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped, in file order."""
@@ -133,6 +158,38 @@ def _env_settings() -> Settings:
)
def _panel_settings() -> Settings:
"""The OVERLAY-RESOLVED ``Settings`` (phase 190): the object
:func:`app.config.get_settings` hands every consumer when the
Settings panel holds ``app_name`` / ``input_placeholder`` /
``footer_text`` rows — the DB rows layered over the env base
(``_env_settings`` standing for that base). The ownership pin
pairs this explicit object with REAL committed ``app_settings``
rows so the contract holds whichever side of the overlay the
resolver consults — the panel row is the source, never the bare
env value.
"""
return Settings(
app_name="Panel Name",
input_placeholder="Panel placeholder…",
footer_text="Panel footer",
)
def _seed_panel_rows(db: Session) -> None:
"""The three Settings-panel rows, committed (production writes them
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
sees the table, so seeding it directly IS the panel state)."""
db.add_all(
[
AppSetting(key="app_name", value="Panel Name"),
AppSetting(key="input_placeholder", value="Panel placeholder…"),
AppSetting(key="footer_text", value="Panel footer"),
]
)
db.commit()
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
@@ -193,6 +250,165 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
db.commit()
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
def test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Settings panel is the SINGLE
writable owner of the three brand strings, so an ``app_settings``
row beats a conflicting ``ui_settings`` (Theme-tab) row for EVERY
one of them — the resolution order is panel row → tab row → env.
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
as a strict ``xfail`` on the defect: ``effective_settings`` used to
return the ``ui_settings`` value whenever it was a non-empty string,
so a Theme-tab save BAKED the then-effective name into the row and
every later Settings-panel change was SHADOWED — the exact
``TODO.md`` L3 incident, "changing the App name in settings broke,
it no longer updates the name").
"""
_start_row_missing(db)
_clear_panel_rows(db)
_seed_panel_rows(db)
db.add(
UiSettings(
id=1,
app_name="Tab Name",
input_placeholder="Tab placeholder…",
footer_text="Tab footer",
)
)
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # the overlay is the panel's mechanism
try:
effective = theming.effective_settings(db, _panel_settings())
assert effective["app_name"] == "Panel Name" # pre-fix: "Tab Name" (the shadow)
assert effective["input_placeholder"] == "Panel placeholder…"
assert effective["footer_text"] == "Panel footer"
# The ownership flip is STRINGS-only — the tab row keeps the
# colors half untouched (the 17-color palette is unchanged).
assert effective["bg"] == theming.BUILTIN_COLORS["bg"]
assert effective["brand"] == theming.BUILTIN_COLORS["brand"]
finally:
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_fall_back_to_ui_settings_row_without_panel_row(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The A1 FALLBACK (no data migration): a ``ui_settings`` row set and
NO Settings-panel row means the tab value still applies (a legacy
baked-in row keeps rendering what it holds until the panel
overrides it) — the env value stays the LAST resort.
GREEN pre-phase AND post-phase: the pin that protects the fallback
branch of the new order. An implementation that lets the plain env
``Settings`` value beat the tab row (instead of consulting the
actual ``app_settings`` rows for a real override) fails here.
"""
_start_row_missing(db)
_clear_panel_rows(db)
db.add(
UiSettings(
id=1,
app_name="Tab Name",
input_placeholder="Tab placeholder…",
footer_text="Tab footer",
)
)
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1") # overlay live, panel table simply empty
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Tab Name"
assert effective["input_placeholder"] == "Tab placeholder…"
assert effective["footer_text"] == "Tab footer"
finally:
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_resolve_per_field_panel_then_tab_then_env(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The order is PER FIELD, not per row (phase 190 task 02): one
Settings-panel row owns exactly its own key, so ``app_name`` reads
the panel while ``input_placeholder`` falls to the tab row and
``footer_text`` — absent from BOTH rows — falls to the env value.
The panel row that equals the env value still wins, too: presence,
never a value compare (a compare is how the shadow would sneak
back — an owner who sets the panel name to what the env already
says would see a legacy tab bake instead)."""
_start_row_missing(db)
_clear_panel_rows(db)
db.add(AppSetting(key="app_name", value="Panel Name"))
db.add(UiSettings(id=1, app_name="Tab Name", input_placeholder="Tab placeholder…"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
env = _env_settings()
try:
effective = theming.effective_settings(
db,
Settings(
app_name="Panel Name",
input_placeholder=env.input_placeholder,
footer_text=env.footer_text,
),
)
assert effective["app_name"] == "Panel Name" # the panel row (tab row ignored)
assert effective["input_placeholder"] == "Tab placeholder…" # no panel row → tab
assert effective["footer_text"] == env.footer_text # neither row → env
# A panel row that EQUALS the env value still owns the field —
# the tab row must not resurrect itself on a value tie.
db.execute(
update(AppSetting).where(AppSetting.key == "app_name").values(
value=env.app_name
)
)
db.commit()
tied = theming.effective_settings(db, Settings(app_name=env.app_name))
assert tied["app_name"] == env.app_name
assert tied["input_placeholder"] == "Tab placeholder…"
finally:
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_strings_ignore_panel_rows_when_the_overlay_is_off(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``BOR_SETTINGS_DB`` kill switch (phase 168 D6) governs the
ownership too: with the overlay off NO row is in effect (so
``settings.app_name`` is the plain env value), and the Theme-tab row
keeps its phase-91 fallback rather than being shadowed by a row the
resolver never applies."""
_start_row_missing(db)
_clear_panel_rows(db)
_seed_panel_rows(db)
db.add(UiSettings(id=1, app_name="Tab Name"))
db.commit()
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Tab Name" # no override in effect → the tab row
assert effective["input_placeholder"] == _env_settings().input_placeholder
assert effective["footer_text"] == _env_settings().footer_text
finally:
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
_clear_panel_rows(db)
db.execute(_delete_row())
db.commit()
invalidate_settings()
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
"""``settings=None`` (the design's call shape) resolves the env
fallback from the cached :func:`app.config.get_settings` — the
+62 -40
View File
@@ -1,4 +1,5 @@
"""Unit: the admin UI-settings API (phase 91, task 01).
"""Unit: the admin UI-settings API (phase 91, task 01; colors-only since
phase 190, task 02).
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
contract and the GET/PUT persistence on the single ``ui_settings`` row
@@ -6,13 +7,18 @@ contract and the GET/PUT persistence on the single ``ui_settings`` row
the 8 semantic state):
* PUT validation — the 422s NAME the offending field (fixed details):
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
a non-``#rrggbb`` color (wrong prefix, 3-digit shorthand, 8 hex
chars, missing ``#``) — identity AND semantic fields alike (the loops
are ``COLOR_FIELDS``-driven);
* the STRINGS are accepted and IGNORED (phase 190, A1 — the admin
Settings panel is the single writable owner of ``app_name`` /
``input_placeholder`` / ``footer_text``): no body string is written,
whatever its length or whitespace, and the stored columns are left
exactly as they were (never cleared) — so an old cached client's Save
cannot re-bake a name and shadow the panel;
* normalization — colors are lowercased on store; a color EQUAL to its
built-in is stored as NULL (the owner-locked rule: "save the defaults"
must leave the row empty — the no-op injection contract); an empty /
whitespace-only string is the clear operation (NULL);
must leave the row empty — the no-op injection contract);
* GET — the effective merge (a partial row reports the DB values over
the env/built-in defaults);
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
@@ -65,25 +71,33 @@ def _row(db: Session) -> UiSettings | None:
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
def test_put_too_long_string_422_names_the_field(
def test_put_string_fields_are_ignored_not_validated(
admin_client: TestClient, db: Session
) -> None:
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
that field; a rejected PUT half-writes nothing; exactly 300 still
passes (the column is VARCHAR(300))."""
"""Phase 190 (A1): the 3 string fields of the body are DROPPED before
the handler reads them — no trim, no length check, no 422. A
400-char name (the old 422 case) is a plain 200 that writes nothing,
and the response reports the effective (env) value; the palette in
the same body still saves. An old cached client can therefore never
re-bake a brand string from this surface — and can never be blocked
by a validation error on a field this API no longer owns."""
for field in theming.STRING_FIELDS:
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
assert r.status_code == 422, (field, r.text)
assert r.json()["detail"] == f"{field} is too long (max 300)"
# A whitespace-padded 301 is still 301 after the trim…
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
assert r.status_code == 422, (field, r.text)
# No rejected PUT created the row — the upsert runs after validation.
assert _row(db) is None
# Exactly 300 passes — stored, trimmed.
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "y" * 300
r = admin_client.put(
"/api/ui-settings", json={field: "x" * 301, "brand": "#818cf8"}
)
assert r.status_code == 200, (field, r.text)
assert r.json()[field] == getattr(get_settings(), field)
db.expire_all()
row = _row(db)
assert row is not None
assert getattr(row, field) is None, f"{field} must never be stored"
assert row.brand == "#818cf8" # the colors half still saves beside it
# And the row the PUT created holds no string at all.
db.expire_all()
row = _row(db)
assert row is not None
for field in theming.STRING_FIELDS:
assert getattr(row, field) is None
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
@@ -210,27 +224,35 @@ def test_put_semantic_built_in_is_stored_as_null(
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
def test_put_empty_string_is_the_clear_operation(
def test_put_cannot_clear_a_stored_string(
admin_client: TestClient, db: Session
) -> None:
"""A whitespace-only (or empty) string trims to empty → NULL — the
clear operation, not a 422 and not a stored blank: the effective
value falls back to the env default."""
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
assert r.status_code == 200, r.text
row = _row(db)
assert row is not None
assert row.app_name is None
assert row.footer_text is None
# The response reports the effective (env) fallback, not "".
assert r.json()["app_name"] == get_settings().app_name
assert r.json()["footer_text"] == get_settings().footer_text
"""The A1 fallback survives the write strip: a legacy row that holds
a baked-in string keeps it through an empty / whitespace / explicit
``null`` PUT — the Theme tab neither writes nor CLEARS the strings
(clearing one is the panel's ``DELETE /api/admin/settings/{key}``).
The response keeps reporting the effective value, so the old client
still shows what the deployment shows."""
db.add(UiSettings(id=1, app_name="Legacy bake", footer_text="Legacy footer"))
db.commit()
for body in ({"app_name": " ", "footer_text": ""}, {"app_name": None}):
r = admin_client.put("/api/ui-settings", json=body)
assert r.status_code == 200, (body, r.text)
db.expire_all()
row = _row(db)
assert row is not None
assert row.app_name == "Legacy bake" # never written, never cleared
assert row.footer_text == "Legacy footer"
# The response reports the EFFECTIVE value (the stored fallback).
assert r.json()["app_name"] == "Legacy bake"
assert r.json()["footer_text"] == "Legacy footer"
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
"""GET reports the DB values over the defaults, column by column: a
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
built-ins and the env strings — all 12 keys, no nulls."""
built-ins and the env strings — all 20 keys, no nulls."""
db.add(UiSettings(id=1, bg="#123456"))
db.commit()
r = admin_client.get("/api/ui-settings")
@@ -258,19 +280,19 @@ def test_upsert_creates_then_updates_the_id_1_row(
row = _row(db)
assert row is not None and row.id == 1
assert row.brand == "#123abc"
assert row.app_name == "First"
assert row.app_name is None # phase 190 A1: the body's string is dropped
r2 = admin_client.put(
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
)
assert r2.status_code == 200, r2.text
assert r2.json()["brand"] == "#abcdef"
assert r2.json()["input_placeholder"] == "Second"
assert r2.json()["input_placeholder"] == get_settings().input_placeholder
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
rows = db.execute(select(UiSettings)).scalars().all()
assert len(rows) == 1, "the upsert must never create a second row"
assert rows[0].id == 1
assert rows[0].brand == "#abcdef" # updated, not appended
assert rows[0].input_placeholder == "Second" # the new string landed
assert rows[0].app_name is None # absent in the second body → NULL
assert rows[0].input_placeholder is None # the strings are never written
assert rows[0].app_name is None