diff --git a/.env.example b/.env.example
index a7ffd18..4088536 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,9 @@
# --- App ---
BOR_ENVIRONMENT=development
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
+# BOR_INPUT_PLACEHOLDER=Ask me anything… # composer placeholder, chat page (phase 62)
+# BOR_FOOTER_TEXT=Powered by self-hosted models # footer line on every page (phase 62)
+# BOR_THEME= # filename under frontend/assets/themes/ (e.g. indigo.css) — overrides the built-in palette; empty = built-in (phase 62)
# BOR_LOG_LEVEL=INFO
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
diff --git a/Containerfile b/Containerfile
index 70b21a1..6bda853 100644
--- a/Containerfile
+++ b/Containerfile
@@ -26,6 +26,7 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
+ && cp -r ./assets/themes /out/assets/themes \
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
# ---------- Stage 2: python dependencies ----------
diff --git a/README.md b/README.md
index d988af8..6d48791 100644
--- a/README.md
+++ b/README.md
@@ -732,6 +732,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| Env | Default | Meaning |
|-----|---------|---------|
| `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page `
`, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by `GET /api/config` and applied by `assets/brand.js`; a name starting `Brain of ` keeps the bold split (`Brain of rest`), any other name renders in normal weight. Unset ⇒ byte-identical to the default |
+| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | the chat composer placeholder (`#message-input`, chat page only); applied by `assets/brand.js` from `GET /api/config`; unset ⇒ the template default |
+| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | the footer line on all 9 pages (the `.footer-text` spans); same mechanism; unset ⇒ the template default |
+| `BOR_THEME` | *(empty)* | a filename under `frontend/assets/themes/` (e.g. `indigo.css`) — a `:root` palette override injected after `styles.css` (later wins the cascade); the server refuses a malformed name at startup (bare `^[a-z0-9_-]+\.css$` filename); a missing file degrades to the built-in theme; unset ⇒ the built-in dark-tech palette |
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
@@ -761,6 +764,21 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
| `BOR_LOG_LEVEL` | `INFO` | app log level |
+### Customizing the look
+
+The app ships as “Brain of Reese”, but every identity string is an env
+var: `BOR_APP_NAME` (display name), `BOR_INPUT_PLACEHOLDER` (chat composer
+placeholder), and `BOR_FOOTER_TEXT` (the footer line on every page) — all
+served by `GET /api/config` and applied by `assets/brand.js` at boot.
+Color themes are plain CSS variable overrides: write a `:root` block in
+`frontend/assets/themes/` and point `BOR_THEME` at the filename
+([the themes `README.md`](frontend/assets/themes/README.md) is the
+authoring guide, `indigo.css` the working example). The server refuses a
+malformed `BOR_THEME` at startup, and a missing theme file degrades to the
+built-in palette — the page never breaks. Leave everything unset and the
+app renders the defaults byte-identically: the dark-tech palette shown
+throughout this README is the no-config default.
+
## Troubleshooting
- **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`).
diff --git a/app/api/config.py b/app/api/config.py
index 7d2f061..95d47c6 100644
--- a/app/api/config.py
+++ b/app/api/config.py
@@ -1,5 +1,7 @@
"""Public app metadata (display name + version) for the frontend brand
-layer, plus the phase-59 docs-push flag (the "Save as doc" gating)."""
+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)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
@@ -12,12 +14,18 @@ router = APIRouter(tags=["config"])
@router.get("/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 chat page's
- "Save as doc" button gating, surfaced the way ``app_name`` is
- (the SAME boot fetch, no new network surface). Inert false while
- ``BOR_DOCS_REPO`` is empty (the feature is off, D3)."""
+ 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)."""
return {
"app_name": settings.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,
}
diff --git a/app/config.py b/app/config.py
index 06fddf5..6576073 100644
--- a/app/config.py
+++ b/app/config.py
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
log_level: str = "INFO"
static_dir: str = "frontend"
+ # --- UI customization (phase 62, TODO L3) ---
+ # Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
+ 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"
@@ -202,6 +211,27 @@ 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:
diff --git a/frontend/assets/brand.js b/frontend/assets/brand.js
index 0189e13..ca13094 100644
--- a/frontend/assets/brand.js
+++ b/frontend/assets/brand.js
@@ -31,13 +31,42 @@
* skipped so page source is never mutated;
* 4. an attribute pass — the aria-label / placeholder / meta
* content attributes containing the literal (the #messages
- * aria-label, the input label, the meta descriptions).
+ * aria-label, the input label, the meta descriptions);
+ * 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
+ * settled config also carries the three UI-customization
+ * keys, applied in this same .then, 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:
+ * 5. input_placeholder — non-empty → the #message-input
+ * placeholder (the chat page only; every other page
+ * no-ops via the null guard);
+ * 6. footer_text — non-empty → every .footer-text node's
+ * textContent (all 9 pages, the phase-61 hook; an
+ * operator string can't inject markup via textContent);
+ * 7. theme — non-empty → a inserted
+ * IMMEDIATELY AFTER the styles.css link (the theme's
+ * :root overrides win by cascade order). The styles.css
+ * finder matches the RAW attribute path with any query/
+ * fragment stripped — the phase-33/54 cache-busting
+ * middleware serves the HTML with the asset refs rewritten
+ * to "…/styles.css?v=", and el.href (the absolute
+ * URL) would never end with "styles.css" once versioned.
+ * The filename is validated server-side (a bare *.css
+ * name — no path can reach here via /api/config); a
+ * MISSING file degrades to the built-in theme (onerror →
+ * console.warn — A5, the page never breaks). Guarded by
+ * #theme-override: never inserted twice.
* • fetch failure / empty name → the default stays + console.warn
* (the loadHealth house style: progressive enhancement, the page
* never breaks).
*
- * No-op property: with BOR_APP_NAME unset the /api/config answer IS the
- * literal, so every replacement below is a byte-identical no-op.
+ * No-op property: with the customization env vars (BOR_APP_NAME,
+ * BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) 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), the theme is empty (the link is skipped) — so an
+ * unset deployment renders byte-identical.
*/
/* The synchronous default — set BEFORE any fetch, so module scripts
@@ -90,65 +119,135 @@ 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) return; // empty / missing: the default stands
- window.BOR_BRAND = name;
+ 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 ${escapeHTML(rest)}`;
- } 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