phase: 190_app_name_settings_fix
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:
+8
-6
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user