phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
+35
-12
@@ -1,12 +1,27 @@
|
||||
"""Public app metadata (display name + version) for the frontend brand
|
||||
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
|
||||
phase-62 UI customization strings (composer placeholder, footer line,
|
||||
theme file name)."""
|
||||
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
|
||||
house pattern — the route is sync, matching the middleware world).
|
||||
|
||||
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
|
||||
deleted with the mechanism — the five keys below are the entire
|
||||
contract (the colors never rode this endpoint; the server injects
|
||||
them pre-paint, :mod:`app.core.theming`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core import theming
|
||||
from app.db import SessionLocal
|
||||
|
||||
router = APIRouter(tags=["config"])
|
||||
|
||||
@@ -15,17 +30,25 @@ router = APIRouter(tags=["config"])
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
|
||||
customization keys (``input_placeholder``, ``footer_text``,
|
||||
``theme``) — all display strings, the SAME boot fetch (no new
|
||||
network surface) and the same public posture as ``app_name``
|
||||
(no secrets). Values are passed through verbatim: the frontend
|
||||
brand layer treats an empty string as "keep the template default"
|
||||
(the unset => byte-identical contract)."""
|
||||
customization keys (``input_placeholder``, ``footer_text``) — all
|
||||
display strings, the SAME boot fetch (no new network surface) and
|
||||
the same public posture as ``app_name`` (no secrets). Phase 91:
|
||||
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
|
||||
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
|
||||
the env values — DB-over-env, B1); the frontend brand layer treats
|
||||
an empty string as "keep the template default" (the unset =>
|
||||
byte-identical contract). Phase 91 (task 03): the retired
|
||||
CSS-file theming's ``theme`` key is gone — the five keys are the
|
||||
entire response."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db, settings)
|
||||
finally:
|
||||
db.close()
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"app_name": effective["app_name"],
|
||||
"version": settings.app_version,
|
||||
"docs_repo_configured": settings.docs_configured,
|
||||
"input_placeholder": settings.input_placeholder,
|
||||
"footer_text": settings.footer_text,
|
||||
"theme": settings.theme,
|
||||
"input_placeholder": effective["input_placeholder"],
|
||||
"footer_text": effective["footer_text"],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""UI settings admin API (phase 91, task 01).
|
||||
|
||||
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 8
|
||||
identity colors. 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).
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core import theming
|
||||
from app.core.auth import require_admin
|
||||
from app.db import get_db
|
||||
from app.models import UiSettings
|
||||
from app.schemas import UiSettingsIn, UiSettingsOut
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/ui-settings",
|
||||
tags=["ui-settings"],
|
||||
dependencies=[Depends(require_admin)], # phase 91: the Theme tab is admin-only
|
||||
)
|
||||
|
||||
#: 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]:
|
||||
"""Validate + normalize the 8 identity colors: strict ``#rrggbb``
|
||||
(else 422 naming the field), lowercased on store, and a value equal
|
||||
to its BUILT-IN is stored as ``None`` — the owner-locked
|
||||
normalization that keeps "save the defaults" byte-identical (the
|
||||
row stays empty, the no-op injection contract)."""
|
||||
values: dict[str, str | None] = {}
|
||||
for field in theming.COLOR_FIELDS:
|
||||
raw = getattr(payload, field)
|
||||
if raw is None:
|
||||
values[field] = None
|
||||
continue
|
||||
if _HEX_COLOR.fullmatch(raw) is None:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"{field} must be a #rrggbb hex color"
|
||||
)
|
||||
value = raw.lower()
|
||||
values[field] = None if value == theming.BUILTIN_COLORS[field] else value
|
||||
return values
|
||||
|
||||
|
||||
@router.get("", response_model=UiSettingsOut)
|
||||
def get_ui_settings(
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UiSettingsOut:
|
||||
"""The effective UI settings — DB-over-env / DB-over-built-in (B1).
|
||||
|
||||
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.
|
||||
"""
|
||||
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||
|
||||
|
||||
@router.put("", response_model=UiSettingsOut)
|
||||
def update_ui_settings(
|
||||
payload: UiSettingsIn,
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UiSettingsOut:
|
||||
"""Replace the single row with the body's 11 values (validated and
|
||||
normalized — see the module docstring), then report the new
|
||||
effective values.
|
||||
|
||||
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()
|
||||
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||
+3
-25
@@ -48,12 +48,11 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- UI customization (phase 62, TODO L3) ---
|
||||
# Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
|
||||
# (Phase 91, task 03: the retired CSS-file theme env var is gone —
|
||||
# the admin Theme tab is the only theming surface; a leftover value
|
||||
# in a deployment's .env is simply ignored.)
|
||||
input_placeholder: str = "Ask me anything…"
|
||||
footer_text: str = "Powered by self-hosted models"
|
||||
#: Theme file NAME under frontend/assets/themes/ (e.g. "indigo.css");
|
||||
#: empty = the built-in dark-tech palette. Validated: bare filename
|
||||
#: only — no paths, no ".." (no-CDN: served from the static dir).
|
||||
theme: str = ""
|
||||
|
||||
# --- Database (PostgreSQL 17 + pgvector) ---
|
||||
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
|
||||
@@ -238,27 +237,6 @@ class Settings(BaseSettings):
|
||||
#: separate from ``sources_dir`` (the source checkouts).
|
||||
docs_work_dir: str = "~/bor-docs"
|
||||
|
||||
@field_validator("theme", mode="after")
|
||||
@classmethod
|
||||
def _theme_bare_css_filename(cls, v: str) -> str:
|
||||
r"""Phase 62 (A5): the theme is a FILE NAME under
|
||||
``frontend/assets/themes/``, served from the static dir
|
||||
(no-CDN) — so only a bare lowercase ``.css`` filename is legal
|
||||
(``^[a-z0-9_-]+\.css$``). Anything else (a path, ``..``,
|
||||
uppercase, a missing extension) is a typo that would silently
|
||||
404 at runtime — fail loudly at startup instead, naming the
|
||||
offending value and the allowed shape (the phase-56 fail-loud
|
||||
house style)."""
|
||||
if v == "":
|
||||
return v # empty = the built-in dark-tech palette
|
||||
if re.fullmatch(r"[a-z0-9_-]+\.css", v) is None:
|
||||
raise ValueError(
|
||||
"theme must be a bare .css filename under "
|
||||
"frontend/assets/themes/ (lowercase letters/digits/"
|
||||
f"'_'/'-', e.g. 'indigo.css') — got {v!r}"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("import_extensions")
|
||||
@classmethod
|
||||
def _import_extensions_known(cls, v: str) -> str:
|
||||
|
||||
+75
-2
@@ -28,6 +28,36 @@ outbound validators. The asymmetry is deliberate: a 304 on
|
||||
a 304 on an HTML page is never safe — the served body depends on the
|
||||
process token, which the validator ignores.
|
||||
|
||||
Phase 91 (task 02) — the pre-paint theme tag: in the SAME rewrite
|
||||
branch, AFTER the ``?v=<token>`` asset rewrite, the effective
|
||||
``ui_settings`` row (task 01's :func:`app.core.theming.effective_settings`
|
||||
resolver — one short-lived session per response, NO process cache: the
|
||||
owner changes the theme at runtime from the admin tab, so the next
|
||||
request must see it without a restart, and a single-row SELECT is
|
||||
negligible at homelab page traffic) is rendered as an inline
|
||||
``<style id="bor-theme">:root{…}</style>`` and inserted immediately
|
||||
BEFORE the first ``</head>`` (:func:`app.core.theming.inject_theme`),
|
||||
so a themed deployment paints its palette on the FIRST paint — no red
|
||||
flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` —
|
||||
the identity no-op — and serves the EXACT pre-phase-91 rewrite-only
|
||||
bytes (the byte-identical contract, B4); a DB blip (or a pre-migration
|
||||
boot) is the same no-op, the page never breaks. The asset rewrite is
|
||||
untouched, and ``/api/*`` / ``/assets/*`` still pass through
|
||||
byte-identical.
|
||||
|
||||
Phase 91 (task 05, defect fix) — the CSP extension: the phase-82
|
||||
policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the
|
||||
inline tag in every real browser, so a themed HTML page's response
|
||||
also carries ``style-src 'self' 'sha256-<hash>'`` appended to the A1
|
||||
string, where ``<hash>`` is the CSP3 hash of the EXACT tag content
|
||||
(:func:`app.core.theming.theme_csp_hash`) — the current theme is the
|
||||
only inline style ever permitted (no ``'unsafe-inline'``; a different
|
||||
palette or any other inline style is still blocked). The untagged
|
||||
response keeps the plain A1 string (the outer
|
||||
:class:`~app.core.security_headers.SecurityHeadersMiddleware`
|
||||
preserves a CSP an inner layer has already set), and no non-HTML
|
||||
response ever gets the extension.
|
||||
|
||||
The token is computed **once per process** (``functools.cache``, i.e.
|
||||
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
|
||||
when a new commit lands (git path) or the frontend tree's mtimes/sizes
|
||||
@@ -61,6 +91,9 @@ from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.core.security_headers import CSP
|
||||
from app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
@@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/git-sources.html", # phase 35: the admin git sources page
|
||||
"/history.html", # phase 50: the admin saved-chats page
|
||||
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
|
||||
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
|
||||
# phase 51: the shared page's STATIC path (the static mount serves
|
||||
# shared.html at /shared.html as well as the real route serves the
|
||||
# dynamic /shared/<token> — both must carry the no-cache + ?v=
|
||||
@@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
# Phase 91 (task 02): the pre-paint theme tag. One short-lived
|
||||
# session per response (the sync-endpoint house pattern from
|
||||
# app/db.py — the middleware world is sync); NO process cache —
|
||||
# the theme changes at runtime from the admin tab, so the next
|
||||
# request must see it without a restart. A DB blip (or a
|
||||
# pre-migration boot) must never break the page: fall back to
|
||||
# ``tag == ""`` (the built-in palette) and keep the no-cache
|
||||
# contract (loadHealth house style).
|
||||
tag = ""
|
||||
try:
|
||||
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
tag = theming.theme_style_tag(
|
||||
{key: effective[key] for key in theming.COLOR_FIELDS}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"cache busting: theme read failed for %s — serving without the theme tag",
|
||||
path,
|
||||
)
|
||||
tag = ""
|
||||
|
||||
try:
|
||||
new_body = theming.inject_theme(
|
||||
rewrite_asset_refs(body.decode("utf-8"), token), tag
|
||||
).encode("utf-8")
|
||||
except Exception:
|
||||
# The body IS buffered — re-serve the ORIGINAL bytes so a
|
||||
# rewrite hiccup never loses the page.
|
||||
@@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
headers=_no_cache_headers(response),
|
||||
)
|
||||
|
||||
headers = _no_cache_headers(response)
|
||||
if tag:
|
||||
# Phase 91 (task 05): the inline tag needs a style-src
|
||||
# exemption or the phase-82 CSP blocks it in the browser —
|
||||
# the strictest one: a sha256 hash of the EXACT tag content
|
||||
# (theming.theme_csp_hash), appended to the A1 string. The
|
||||
# outer SecurityHeadersMiddleware preserves this (it only
|
||||
# fills in a missing CSP); the untagged page keeps A1
|
||||
# verbatim — byte- AND header-identical to pre-phase-91.
|
||||
headers["Content-Security-Policy"] = (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
return Response(
|
||||
content=new_body,
|
||||
status_code=response.status_code,
|
||||
headers=_no_cache_headers(response),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,16 @@ class SecurityHeadersMiddleware:
|
||||
Adds exactly three headers to every HTTP response:
|
||||
|
||||
* ``Content-Security-Policy``: the strict same-origin policy above
|
||||
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04);
|
||||
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04) —
|
||||
EXCEPT when an inner layer has already set one: the phase-91
|
||||
(task 05) pre-paint theme tag is an inline ``<style>`` that the
|
||||
A1 policy would block in the browser, so the caching middleware
|
||||
publishes, on themed HTML pages only, the A1 string with
|
||||
``style-src 'self' 'sha256-<tag-content-hash>'`` appended (the
|
||||
current theme is the only inline style ever permitted — no
|
||||
``'unsafe-inline'``). A pre-existing CSP is that inner layer's
|
||||
deliberate one and is preserved; every other response (including
|
||||
every untagged page) gets the plain A1 string.
|
||||
* ``X-Frame-Options: DENY`` — legacy no-framing fallback;
|
||||
* ``X-Content-Type-Options: nosniff`` — MIME-confusion belt.
|
||||
|
||||
@@ -73,7 +82,12 @@ class SecurityHeadersMiddleware:
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers["Content-Security-Policy"] = CSP
|
||||
# Phase 91 (task 05): preserve a CSP an inner layer set
|
||||
# (the caching middleware's theme-extended policy — see
|
||||
# the class docstring); the A1 string covers every
|
||||
# response without one.
|
||||
if "content-security-policy" not in headers:
|
||||
headers["Content-Security-Policy"] = CSP
|
||||
headers["X-Frame-Options"] = "DENY"
|
||||
headers["X-Content-Type-Options"] = "nosniff"
|
||||
await send(message)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""The built-in identity palette + the effective UI-settings resolver
|
||||
(phase 91).
|
||||
|
||||
Single source of the built-in **identity** palette. Phase 62's
|
||||
custom-CSS-file theming (an env var named a drop-in ``:root`` override
|
||||
stylesheet that ``brand.js`` linked AFTER the boot fetch — the "red
|
||||
first, then pop" the owner saw) is retired in this phase: task 03
|
||||
deleted the env var, the example-stylesheet directory, and the link
|
||||
insertion, and the admin Theme tab is now the only theming surface.
|
||||
The contract that directory's authoring guide carried is re-homed here
|
||||
(built-in table, the five contrast pairs, the never-white-on-brand
|
||||
trap — see below), and the 8 variables + built-in values are the
|
||||
authoritative table (the unit drift test parses
|
||||
``frontend/assets/styles.css``'s ``:root`` and asserts equality, so
|
||||
the two can never silently diverge).
|
||||
|
||||
The **8 identity variables** (bare names, README order) and their
|
||||
built-in values (from ``frontend/assets/styles.css`` ``:root``):
|
||||
|
||||
=================== ========== =================================================
|
||||
Variable Built-in Role
|
||||
=================== ========== =================================================
|
||||
``bg`` ``#0f0a0a`` page background (text on it: ``ink``)
|
||||
``surface`` ``#1a0f0f`` cards, panels, code blocks (text: ``ink``)
|
||||
``ink`` ``#f0e6e6`` primary text
|
||||
``ink_soft`` ``#b8a8a8`` secondary text (5.1:1 on ``surface``)
|
||||
``line`` ``#2d1a1a`` decorative 1px borders (no contrast duty)
|
||||
``brand`` ``#f43f5e`` brand accent — buttons, links (text ON
|
||||
it is the DARK ``bg`` ink)
|
||||
``brand_soft`` ``#2d0a0a`` brand-tinted surface (chips, hover washes)
|
||||
``brand_ink`` ``#fca5a5`` brand-tinted text (9.0:1 on ``surface``)
|
||||
=================== ========== =================================================
|
||||
|
||||
The **semantic families are deliberately NOT identity** (B3,
|
||||
owner-locked 2026-09-09): ``--accent-*`` (deflection amber), ``--ok-*``
|
||||
(success green), ``--err-*`` (error red) encode *states*, are already AA
|
||||
in the built-in theme, and are not configurable from the tab — a theme
|
||||
that keeps them stays honest.
|
||||
|
||||
**The five contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1,
|
||||
AGENTS.md rule 5) — the pairs the layout actually pairs: ``ink`` on
|
||||
``bg``, ``ink`` on ``surface``, ``ink_soft`` on ``surface``, ``bg`` on
|
||||
``brand`` (the text on brand buttons is the DARK background ink —
|
||||
that is the pattern; never white on brand: white on the built-in
|
||||
``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on ``surface``. The
|
||||
tab's client-side warnings (task 05) compute exactly these five ratios
|
||||
against the values being saved; the built-in palette itself passes, so
|
||||
the default deployment stays AA without any warning.
|
||||
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import UiSettings
|
||||
|
||||
#: The 8 built-in identity colors, keyed by BARE variable name (no ``--``)
|
||||
#: in the themes-README order. Copied from ``frontend/assets/styles.css``
|
||||
#: ``:root`` — the unit drift test (``tests/unit/test_theming.py``)
|
||||
#: re-parses the stylesheet and asserts equality on every run.
|
||||
BUILTIN_COLORS: dict[str, str] = {
|
||||
"bg": "#0f0a0a",
|
||||
"surface": "#1a0f0f",
|
||||
"ink": "#f0e6e6",
|
||||
"ink_soft": "#b8a8a8",
|
||||
"line": "#2d1a1a",
|
||||
"brand": "#f43f5e",
|
||||
"brand_soft": "#2d0a0a",
|
||||
"brand_ink": "#fca5a5",
|
||||
}
|
||||
|
||||
#: The 8 color field names in the README's order (dicts preserve
|
||||
#: insertion order) — used by the resolver, the 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).
|
||||
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
|
||||
def effective_settings(
|
||||
session: Session, settings: Settings | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve the EFFECTIVE UI settings — DB-over-env / DB-over-built-in.
|
||||
|
||||
Reads the single ``ui_settings`` row (id 1) and merges it over the
|
||||
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);
|
||||
* **colors** (the 8 :data:`COLOR_FIELDS`) — the DB value when not
|
||||
``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for
|
||||
colors — the built-in palette IS the default).
|
||||
|
||||
A missing row (``GET`` creates nothing) means "defaults" — the env
|
||||
strings + the built-in palette. The ``settings`` parameter names the
|
||||
env-fallback source explicitly (the routes pass their
|
||||
dependency-injected instance so test overrides apply); ``None`` uses
|
||||
the cached :func:`app.config.get_settings`. Returns all 11 keys.
|
||||
"""
|
||||
if settings is None:
|
||||
settings = get_settings()
|
||||
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
effective: dict[str, str] = {}
|
||||
for field in STRING_FIELDS:
|
||||
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:
|
||||
value = getattr(row, key, None) if row is not None else None
|
||||
effective[key] = value if value is not None else BUILTIN_COLORS[key]
|
||||
return effective
|
||||
|
||||
|
||||
def theme_style_tag(colors: dict[str, str]) -> str:
|
||||
"""The pre-paint inline theme tag (task 02's injection input).
|
||||
|
||||
``""`` when every color equals its built-in — the byte-identical
|
||||
contract: an unset (or "defaults saved") deployment must serve
|
||||
exactly the pre-phase-91 HTML, no ``<style>`` tag anywhere.
|
||||
Otherwise one ``<style id="bor-theme">`` tag with ALL 8 variables in
|
||||
:data:`COLOR_FIELDS` order (the non-overridden ones repeat their
|
||||
built-in value — the tag is a complete ``:root`` override, so the
|
||||
page never mixes partial palettes)::
|
||||
|
||||
<style id="bor-theme">:root{--bg:#0f0a0a;…;--brand-ink:#fca5a5}</style>
|
||||
|
||||
Pure function of its input — :func:`inject_theme` places it before
|
||||
the first ``</head>`` of every served HTML page (the phase-91
|
||||
pre-paint injection), so the themed deployment renders its palette
|
||||
on the FIRST paint (no red flash, no pop-in).
|
||||
"""
|
||||
if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS):
|
||||
return ""
|
||||
declarations = "".join(
|
||||
f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS
|
||||
)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
|
||||
|
||||
def inject_theme(html: str, tag: str) -> str:
|
||||
"""Insert ``tag`` immediately BEFORE the first ``</head>`` of
|
||||
``html`` — the pure half of the phase-91 pre-paint injection.
|
||||
|
||||
The :class:`~app.core.caching.CachingMiddleware` (task 02) calls
|
||||
this on every known HTML page's rewritten body, so the helper stays
|
||||
pure (no DB, no app) and unit-testable on its own. Identity rules —
|
||||
the byte-identical contract (B4, owner-locked 2026-09-09):
|
||||
|
||||
* ``tag == ""`` (an unset or "defaults saved" deployment —
|
||||
:func:`theme_style_tag` returns exactly that) → ``html`` is
|
||||
returned EXACTLY as passed in, byte for byte;
|
||||
* no ``</head>`` occurrence → unchanged (nothing to anchor to);
|
||||
* ``id="bor-theme"`` already present → unchanged (defensive
|
||||
idempotence — the static files never contain the id, and one
|
||||
body can never reach the helper twice, but the guarantee is free
|
||||
for a pure function).
|
||||
|
||||
Otherwise the tag is placed with a leading newline (readable HTML)
|
||||
immediately before the FIRST ``</head>`` — the browser meets the
|
||||
complete ``:root`` override before it applies any stylesheet, so
|
||||
the palette is live on the first paint.
|
||||
"""
|
||||
if not tag or "</head>" not in html or 'id="bor-theme"' in html:
|
||||
return html
|
||||
index = html.index("</head>")
|
||||
return html[:index] + "\n" + tag + html[index:]
|
||||
|
||||
|
||||
def theme_csp_hash(tag: str) -> str:
|
||||
"""The CSP3 ``sha256-`` source expression for an inline theme tag.
|
||||
|
||||
Phase 91 (task 05 defect fix): the phase-82 CSP (A1 —
|
||||
``default-src 'self'`` with no explicit ``style-src``) BLOCKS the
|
||||
inline ``<style id="bor-theme">`` tag in every real browser
|
||||
(``style-src`` falls back to ``default-src 'self'``), so the
|
||||
pre-paint injection would be dead bytes in the served HTML. The
|
||||
fix is the strictest one that works: the hashing source expression
|
||||
of the tag's EXACT content (CSP3 §13.4 — the character data between
|
||||
the tags; the rendered content carries no leading/trailing
|
||||
whitespace, so no stripping applies). The caching middleware
|
||||
publishes it on themed HTML pages only, as ``style-src 'self'
|
||||
'sha256-…'`` appended to the A1 string — the current theme is the
|
||||
only inline style ever permitted, and a different palette (or any
|
||||
other inline style) is still blocked. No blanket
|
||||
``'unsafe-inline'`` — the A1 posture holds everywhere else. Returns
|
||||
``""`` for an empty tag (an unset/defaults deployment keeps the
|
||||
plain A1 policy — the byte- AND header-identical contract).
|
||||
"""
|
||||
if not tag:
|
||||
return ""
|
||||
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
|
||||
digest = hashlib.sha256(content.encode("utf-8")).digest()
|
||||
return "sha256-" + base64.b64encode(digest).decode("ascii")
|
||||
@@ -40,6 +40,7 @@ from app.api.steering import router as steering_router
|
||||
from app.api.suggestions import router as suggestions_router
|
||||
from app.api.sync import router as sync_router
|
||||
from app.api.tokens import router as tokens_router
|
||||
from app.api.ui_settings import router as ui_settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.auth import ensure_admin_configured
|
||||
from app.core.caching import configure_caching
|
||||
@@ -122,6 +123,10 @@ def create_app() -> FastAPI:
|
||||
# Phase 79: the admin token surface (create/list/revoke) — admin-only
|
||||
# (router-wide require_admin; a token USER stays 403 here, task 03).
|
||||
app.include_router(tokens_router, prefix="/api")
|
||||
# Phase 91 (task 01): the admin UI-settings surface (GET/PUT the
|
||||
# single ui_settings row — the Theme tab's persistence) — admin-only
|
||||
# (router-wide require_admin; anonymous AND token users stay 403).
|
||||
app.include_router(ui_settings_router, prefix="/api")
|
||||
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
||||
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
||||
# page route below, registered without a prefix) is the page.
|
||||
@@ -156,6 +161,7 @@ def create_app() -> FastAPI:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/tokens.html", # phase 79 task 06: the Tokens view
|
||||
"/theme.html", # phase 91 task 04: the Theme view (shell route)
|
||||
),
|
||||
)
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
@@ -57,6 +57,12 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
|
||||
on ``POST /api/token-auth`` (task 03 — the only
|
||||
request that presents the token; the in-app gate
|
||||
re-sends the cached token on every page load).
|
||||
* ``ui_settings`` — single-row UI settings (phase 91): the admin
|
||||
Theme tab's app name, input placeholder, footer
|
||||
text and the 8 identity colors, one row
|
||||
(``id = 1``); every column NULL = "use the
|
||||
default" (env value for the strings, the built-in
|
||||
palette for the colors — task 01).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -384,3 +390,40 @@ class ApiToken(Base):
|
||||
#: (enforced immediately on the holder's next request); NULL while
|
||||
#: active.
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class UiSettings(Base):
|
||||
"""Single-row UI settings (phase 91, task 01).
|
||||
|
||||
The admin Theme tab (``/theme.html``, tasks 04/05) persists everything
|
||||
the ``BOR_`` env vars and the retired custom-CSS theming supported in
|
||||
ONE row (``id = 1`` — the single row is always id 1; ``GET`` creates
|
||||
nothing, ``PUT`` upserts). The NULL = default rule (B1, owner-locked
|
||||
2026-09-09): every column is nullable, and a NULL (or empty) column
|
||||
means "use the default" — the env value for the three strings
|
||||
(``settings.app_name`` etc.), the built-in palette
|
||||
(:data:`app.core.theming.BUILTIN_COLORS`) for the eight identity
|
||||
colors (B1: no env fallback for colors). :func:`app.core.theming.
|
||||
effective_settings` resolves the effective 11 values both the
|
||||
``GET /api/ui-settings`` and ``GET /api/config`` endpoints serve.
|
||||
"""
|
||||
|
||||
__tablename__ = "ui_settings"
|
||||
|
||||
#: The single row is always id 1 (the ``kb_overview`` / ``sources_meta``
|
||||
#: 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) ---
|
||||
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)
|
||||
# --- The 8 identity colors (NULL = the built-in — B1), #rrggbb ---
|
||||
bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
surface: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
ink_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
|
||||
@@ -812,3 +812,56 @@ class TokenAuthRequest(BaseModel):
|
||||
"""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class UiSettingsIn(BaseModel):
|
||||
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
|
||||
replacement of the single ``ui_settings`` row.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
app_name: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
footer_text: str | None = None
|
||||
bg: str | None = None
|
||||
surface: str | None = None
|
||||
ink: str | None = None
|
||||
ink_soft: str | None = None
|
||||
line: str | None = None
|
||||
brand: str | None = None
|
||||
brand_soft: str | None = None
|
||||
brand_ink: str | None = None
|
||||
|
||||
|
||||
class UiSettingsOut(BaseModel):
|
||||
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
|
||||
phase 91, task 01).
|
||||
|
||||
All 11 values, all non-null strings: the resolver's
|
||||
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.
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
input_placeholder: str
|
||||
footer_text: str
|
||||
bg: str
|
||||
surface: str
|
||||
ink: str
|
||||
ink_soft: str
|
||||
line: str
|
||||
brand: str
|
||||
brand_soft: str
|
||||
brand_ink: str
|
||||
|
||||
Reference in New Issue
Block a user