diff --git a/CLAUDE.md b/CLAUDE.md
index cf1d978..21904c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
-pytest # 1949 tests, ~2min
+pytest # 1979 tests, ~2min
# PLAN.md tracks what is and is not built
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -87,6 +87,8 @@ src/lembas/
admin_search.py web search provider and credentials
admin_images.py the ComfyUI, and the workflow templates on it
admin_prompts.py the prompt fragment editor and its preview
+ admin_branding.py the name, the logo, the wording and the themes
+ branding.py /branding.css and the assets behind it, both unauthenticated
admin_suggestions.py the cards offered on the new-chat screen
admin_tools.py custom HTTP tools and MCP servers
admin_agents.py whether agent chats exist, and what they may spend
@@ -155,6 +157,8 @@ src/lembas/
reasoning.py splits thinking from the answer
subagent.py a helper another model sent: a hidden chat, one turn,
and everything it may not do
+ branding.py whose instance this is: the name, the artwork, the
+ wording and the themes, cached once per process
settings_store.py runtime instance settings
canvas.py what is open in the canvas panel, and where it comes from
scratch.py a chat's own working document
@@ -190,6 +194,9 @@ touching the code it names -- these are the same notes, not a summary.
schedule something wrote a note and said it had).
- `docs/notes/image-generation.md` -- the ComfyUI workflow with holes in it, what
substitution walks, the review-and-retry loop, and how a failure reports itself.
+- `docs/notes/branding.md` -- the branding snapshot and why it is a Jinja global,
+ where the instance name went and how an upgrade keeps it, how a custom theme
+ inherits through `data-base`, and why `/branding.css` is a route.
- `docs/notes/subagents.md` -- the hidden chat a helper runs in, why `unattended`
is a column and not a kind, the two halves that stop a helper stalling on a
card nobody can see, what it may run and why Auto is never inherited, and where
diff --git a/PLAN.md b/PLAN.md
index 3fe612e..958a629 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -9,7 +9,7 @@ reasoning, tool calling with web search, custom HTTP tools and MCP servers,
agent chats that work on a machine over SSH, a knowledge library, notes, memory
and skills, speech in and out, image generation over ComfyUI, users and groups,
model administration, installable as an app, reports, messages, and scheduled
-work that runs on its own. 1949 tests, `ruff` clean.
+work that runs on its own. 1979 tests, `ruff` clean.
What remains before the first stable release is written out below, in phases,
under [The road to 1.0.0](#the-road-to-100).
@@ -477,12 +477,23 @@ seen working.
one
### Phase 4 — rebranding and customization (`0.9.4`)
-- [ ] **An instance can be somebody else's.** Name, logo, favicon and PWA icons,
- a tagline, and the Middle-earth strings as editable data — defaults in
- code and overrides in the database, so a later release still improves the
- wording nobody changed
-- [ ] Global CSS overrides, and a custom theme defined as a set of tokens rather
- than a stylesheet, since no component hard-codes a colour
+- [x] **An instance can be somebody else's.** Name, tagline, logo, favicon and
+ launcher icons derived from the logo, and the Middle-earth strings as
+ editable data — defaults in code and overrides in the database, so a later
+ release still improves the wording nobody changed. Blanked rather than
+ dropped, because the settings store merges and a dropped key means "leave
+ what was there"
+- [x] **One snapshot, reached from everywhere.** A Jinja global over a
+ process-level cache, because `render()` has no session and four render
+ paths never reach it — the sign-in page, the error pages, the offline page
+ and the SSE fragments
+- [x] **A custom theme is a set of tokens**, not a stylesheet, and inherits its
+ base through `data-base` — one selector added to `tokens.css` is what makes
+ a custom *light* theme land on parchment rather than on near-black
+- [x] The theme list stops being a hard-coded pair in five places
+- [x] Global CSS overrides, served as `/branding.css` — a route rather than an
+ inline block, so an administrator's CSS has no markup to escape from, with
+ a content hash in the link so a save is not left to the browser's cache
### Phase 5 — extraction, embeddings and hybrid search (`0.9.5`)
- [ ] **Extraction has settings** — upload size, image edge, PDF pages,
diff --git a/docs/notes/branding.md b/docs/notes/branding.md
new file mode 100644
index 0000000..98c41f8
--- /dev/null
+++ b/docs/notes/branding.md
@@ -0,0 +1,138 @@
+# Branding and customization
+
+Read this before touching `services/branding.py`, the `brand` Jinja global, the
+`data-theme` / `data-base` pair, or `/branding.css`.
+
+An instance can be somebody else's. That is four separate things — an identity,
+the flavour text, themes, and arbitrary CSS — and they are separate because they
+fail differently.
+
+## Why a snapshot, and why a Jinja global
+
+`render()` has no database session, and four render paths never reach it at all:
+the sign-in page, the error pages, the offline page and the SSE fragments. A
+context value would have to be threaded through every one of them, and would
+still miss the ones that bypass `render()`.
+
+So `branding.snapshot()` is a **process-level cache**, exposed as
+`templates.env.globals["brand"]` through a small proxy. It has to be a proxy, not
+the snapshot itself: a global is bound once at import, and the snapshot changes
+when somebody saves.
+
+`branding.forget()` is called by `api/admin_branding.py` and by nothing else. A
+save that did not drop the cache would take effect at the next restart — the
+"looks like it worked and did nothing" failure this codebase keeps cataloguing.
+`tests/conftest.py` drops it between tests for the same reason it clears the
+generation registry: otherwise the first test to render a page pins one
+instance's identity against a database that has since been thrown away.
+
+**`brand` is a global, so it works inside a macro.** That is what lets `mark()`
+branch on an uploaded logo without every one of its six call sites learning about
+branding. The macro that renders the sidebar brand link is called `brandlink` for
+exactly this reason: a macro imported as `brand` shadows the global for the whole
+template, which took out every page at once when it was called that.
+
+## Defaults in code, overrides in the database
+
+The prompt-fragment rule again, with **one difference that matters**. A fragment
+stored empty means *off*; a flavour string stored empty means *use the shipped
+wording*. A fragment being off is a state somebody wants, and a heading with no
+words is not.
+
+`stored_only` blanks anything equal to its shipped text rather than dropping the
+key, and the reason is `settings_store.update`: it **merges**, so an omitted key
+leaves whatever was stored last time. Dropping would make "I typed the default
+back in" and "I changed nothing" store different things, and would make clearing
+a box do nothing at all.
+
+## The instance name moved
+
+It lived in the general group before there was a branding one. Storage is
+unchanged for an upgrade: `_read` seeds from the general row **when the branding
+row has never said anything about the name** — `"instance_name" in row.value`,
+which is why it reads the raw `Setting` rather than `get_group` (that one fills
+in defaults and cannot tell absent from empty). An empty stored name is somebody
+clearing the box and has to mean the default; reading the two the same way would
+resurrect the old name underneath a cleared one.
+
+`/admin/general` lost the field rather than keeping a second copy of it. Two
+controls writing one value is how each becomes the answer to "why did my change
+not stick?" — the same complaint the plan makes about group membership.
+
+## Themes are token sets
+
+`tokens.css` declares every colour under `:root[data-theme="…"]`, and no
+component hard-codes one. That is what makes a third palette compose at all.
+
+A custom theme sets a handful of tokens and **inherits the rest**, and the
+inheritance is a CSS fact rather than a Python one:
+
+- Moria's block matches bare `:root`, so it always applies.
+- Shire's block matches `:root[data-theme="shire"]` **and
+ `:root[data-base="shire"]`**. That second selector is the whole mechanism.
+- `` carries both attributes. A custom light theme is
+ `data-theme="dusk" data-base="shire"`, so it gets the parchment palette
+ underneath its own four colours. Without it, four light colours would sit on
+ near-black surfaces.
+- `/branding.css` loads after `tokens.css`, so the custom block wins on order at
+ equal specificity.
+
+`--accent-soft`, `--leaf-soft` and `--danger-soft` are **derived** from the
+colours above them, not asked for. They are the same hue at 14%, and an
+administrator who set an accent without them would get focus rings in the old
+one — which reads as the setting half-working rather than as a field they missed.
+
+**Values are validated on read, not on save.** A theme written straight into the
+settings table, or stored by an older version, still has to produce a stylesheet
+that parses. A value that is not a colour is *dropped* rather than corrected: a
+colour nobody can read is visible, and a mangled one is not. This is not
+decoration — a `}` in a value ends the rule and silently breaks every rule after
+it, and `url(…)` in a colour slot is a request to a third party from every page.
+
+## The theme list is one list now
+
+It used to be a hard-coded pair in five places. It is `brand.theme_ids` on the
+server and `data-themes` on `` in the browser — `id:base` pairs, space
+separated, because both things that need it (`/theme` validating a name and
+`applyTheme` setting both attributes) want a list to split rather than a document
+to parse. `app.js:toggleTheme` goes round the list rather than flipping between
+two names; with only the built-in pair that is byte-for-byte what it did before.
+
+Every failure mode here is silent: `applyTheme` returning early on an unknown
+name looks exactly like a button that does nothing, and
+`POST /api/preferences/theme` answers a rejection with `{"ok": false}` that
+nothing displays. `tests/test_branding.py` and the DOM stub cover both
+directions.
+
+## `/branding.css` is a route
+
+A route and not an inline `` away from being a script on every page.
+
+The link carries `?v={{ brand.revision }}`, a hash of everything the route
+builds, so the URL changes exactly when the stylesheet does. It is **deliberately
+not in the service worker's precache list**: that cache is versioned by the
+release, and branding changes between releases, so a precached copy would outlive
+every rebrand until the next version bump.
+
+## Assets are served unauthenticated, and SVG is not accepted
+
+`/branding/{filename}` has no auth guard, for the reason the manifest and the
+offline page have none: the sign-in page needs the logo before anybody has signed
+in, and a browser fetches a manifest icon outside any session.
+
+What that exposes is a file an administrator uploaded on purpose to be shown to
+everybody, under a random name, in a format that cannot execute in an ``.
+`uploads.ALLOWED_TYPES` is what makes the last clause true, and it is why **SVG
+stays out** — the one place somebody will most want it is the one place it is
+least safe.
+
+Launcher icons are derived from the uploaded logo with Pillow at save time, not
+on demand: a manifest icon has to be a real PNG at the size it declares, and
+resizing on the path that serves it would be work per request. Best-effort — an
+instance whose logo cannot be resized keeps the shipped icons, which is a worse
+launcher tile and not a broken install. The manifest swaps the **whole set** or
+none of it, because a tile that changes when the device picks a different size
+reads as a bug in the install.
diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py
index 6f02c4c..f7ede1d 100644
--- a/src/lembas/__init__.py
+++ b/src/lembas/__init__.py
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
-__version__ = "0.9.3"
+__version__ = "0.9.4"
diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py
index 351aa2d..7bd227c 100644
--- a/src/lembas/api/admin.py
+++ b/src/lembas/api/admin.py
@@ -55,7 +55,6 @@ async def general_page(request: Request, db: Db, user: AdminUser, saved: bool =
async def save_general(
db: Db,
user: AdminUser,
- instance_name: str = Form("LLeMbas"),
allow_signup: bool = Form(False),
system_prompt: str = Form(""),
compact_threshold: int = Form(95),
@@ -69,7 +68,6 @@ async def save_general(
settings_store.update(
db,
{
- "instance_name": instance_name.strip()[:120] or "LLeMbas",
"allow_signup": allow_signup,
"system_prompt": system_prompt.strip()[:8000],
# 0 is "never"; anything else is clamped into a band where it can
diff --git a/src/lembas/api/admin_branding.py b/src/lembas/api/admin_branding.py
new file mode 100644
index 0000000..4865ded
--- /dev/null
+++ b/src/lembas/api/admin_branding.py
@@ -0,0 +1,207 @@
+"""Making an instance somebody else's.
+
+One page, four cards, one settings group. Everything it writes goes through
+`branding.stored_only`, so a field left at its shipped wording is never written
+down and a later release can still improve it — the prompt-fragment rule, and
+the reason this page can afford to render every flavour string as an editable
+box without freezing all of them the first time somebody presses Save.
+
+`branding.forget()` after every write, and this is the only module that calls
+it. The snapshot is a process-level cache read by a Jinja global; a save that
+did not drop it would take effect on the next restart, which is the shape of
+failure this codebase keeps cataloguing.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, File, Form, Request, Response, UploadFile, status
+from fastapi.responses import RedirectResponse
+
+from lembas.api.deps import AdminUser, Db
+from lembas.services import branding as branding_service
+from lembas.services import settings_store, uploads
+from lembas.web.templating import render
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/admin/customization", tags=["admin-branding"])
+
+MAX_CUSTOM_CSS = 40_000
+
+
+def _page(request: Request, db: Db, saved: str = "", error: str = "") -> Response:
+ values = settings_store.get_group(db, branding_service.BRANDING)
+ brand = branding_service.for_db(db)
+ return render(
+ request,
+ "admin/customization.html",
+ {
+ "values": values,
+ "current": brand,
+ # The flavour table drives the form, so a string added in code
+ # appears here with its default in the box and no template change.
+ "flavour": [
+ {
+ "key": key,
+ "label": label,
+ "hint": hint,
+ "default": default,
+ "value": str(values.get(f"text_{key}") or ""),
+ }
+ for key, (label, hint, default) in branding_service.FLAVOUR.items()
+ ],
+ "tokens": branding_service.THEME_TOKENS,
+ "custom_themes": [t for t in brand.themes if not t.built_in],
+ "bases": [name for name, _, _ in branding_service.BUILT_IN],
+ "saved": saved,
+ "error": error,
+ },
+ )
+
+
+@router.get("")
+async def customization_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
+ return _page(request, db, saved=saved)
+
+
+def _write(db: Db, changes: dict) -> None:
+ """Store a change and drop the cache, in that order and always together."""
+ settings_store.update(db, changes, key=branding_service.BRANDING)
+ branding_service.forget()
+
+
+@router.post("/identity")
+async def save_identity(
+ request: Request,
+ db: Db,
+ user: AdminUser,
+ instance_name: str = Form(""),
+ tagline: str = Form(""),
+ logo: UploadFile | None = File(None),
+ favicon: UploadFile | None = File(None),
+ remove_logo: bool = Form(False),
+ remove_favicon: bool = Form(False),
+) -> Response:
+ stored = settings_store.get_group(db, branding_service.BRANDING)
+ changes: dict = {
+ "instance_name": instance_name.strip()[:120],
+ "tagline": tagline.strip()[:200],
+ }
+
+ if remove_logo:
+ for name in (stored.get("logo_path"), *(stored.get("icon_paths") or {}).values()):
+ uploads.delete_branding_image(str(name or ""))
+ changes["logo_path"] = ""
+ changes["icon_paths"] = {}
+ if remove_favicon:
+ uploads.delete_branding_image(str(stored.get("favicon_path") or ""))
+ changes["favicon_path"] = ""
+
+ try:
+ if logo is not None and logo.filename:
+ payload = await logo.read()
+ changes["logo_path"] = uploads.save_branding_image(payload, logo.content_type or "")
+ # Derived here rather than on demand: a launcher asks for a 512px
+ # PNG and will not scale one itself, and doing it per request would
+ # mean resizing an image on the path that serves it.
+ changes["icon_paths"] = uploads.derive_icons(payload)
+ if favicon is not None and favicon.filename:
+ payload = await favicon.read()
+ changes["favicon_path"] = uploads.save_branding_image(
+ payload, favicon.content_type or ""
+ )
+ except uploads.UploadError as exc:
+ return _page(request, db, error=str(exc))
+
+ _write(db, branding_service.stored_only(changes))
+ log.info("branding identity changed by %s", user.email)
+ return RedirectResponse(
+ "/admin/customization?saved=Identity+saved.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.post("/flavour")
+async def save_flavour(request: Request, db: Db, user: AdminUser) -> Response:
+ """The Middle-earth strings.
+
+ Read from the raw form rather than declared as parameters, because the set
+ is `branding.FLAVOUR` and a parameter list would be a second copy of it that
+ goes stale the first time a string is added. A key that was not submitted is
+ left alone; one submitted empty falls back to its default, which is what
+ makes "clear the box" mean "give me the shipped wording back" rather than
+ "show nothing here".
+ """
+ form = await request.form()
+ changes = {
+ f"text_{key}": str(form.get(f"text_{key}") or "").strip()[:400]
+ for key in branding_service.FLAVOUR
+ if f"text_{key}" in form
+ }
+ _write(db, branding_service.stored_only(changes))
+ return RedirectResponse(
+ "/admin/customization?saved=Wording+saved.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.post("/css")
+async def save_css(db: Db, user: AdminUser, custom_css: str = Form("")) -> Response:
+ _write(db, {"custom_css": custom_css.strip()[:MAX_CUSTOM_CSS]})
+ log.info("custom CSS changed by %s", user.email)
+ return RedirectResponse(
+ "/admin/customization?saved=Stylesheet+saved.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+@router.post("/themes")
+async def save_themes(request: Request, db: Db, user: AdminUser) -> Response:
+ """Every custom theme, replaced wholesale.
+
+ One form for the lot rather than a row each, because a theme is a handful of
+ colours and the whole set fits on a screen — and because replacing the list
+ means a theme removed here is gone, with no reconciliation between what was
+ posted and what was stored.
+
+ Nothing is validated here beyond shape. `branding._theme_from` validates on
+ every **read**, so a theme written straight into the settings table by hand,
+ or stored by an earlier version, still has to produce a stylesheet that
+ parses. Validating only on save would put that guarantee in the wrong place.
+ """
+ form = await request.form()
+ themes = []
+ for index in range(_theme_count(form)):
+ theme_id = str(form.get(f"theme_{index}_id") or "").strip().lower()
+ if not theme_id:
+ continue
+ themes.append(
+ {
+ "id": theme_id,
+ "label": str(form.get(f"theme_{index}_label") or "").strip(),
+ "base": str(form.get(f"theme_{index}_base") or "moria"),
+ "tokens": {
+ name: value
+ for name, _ in branding_service.THEME_TOKENS
+ if (value := str(form.get(f"theme_{index}_{name}") or "").strip())
+ },
+ }
+ )
+ _write(db, {"themes": themes})
+ log.info("%d custom theme(s) saved by %s", len(themes), user.email)
+ return RedirectResponse(
+ "/admin/customization?saved=Themes+saved.", status_code=status.HTTP_303_SEE_OTHER
+ )
+
+
+def _theme_count(form) -> int:
+ """How many theme blocks the form carried.
+
+ Counted from the submitted keys rather than from a hidden field, so a form
+ rendered by an older page still saves what it holds.
+ """
+ indices = [
+ int(key.split("_")[1])
+ for key in form
+ if key.startswith("theme_") and key.split("_")[1].isdigit()
+ ]
+ return max(indices) + 1 if indices else 0
diff --git a/src/lembas/api/branding.py b/src/lembas/api/branding.py
new file mode 100644
index 0000000..b6850ac
--- /dev/null
+++ b/src/lembas/api/branding.py
@@ -0,0 +1,65 @@
+"""Serving what an administrator customised.
+
+Both routes here are deliberately **unauthenticated**, and for the same reason
+the manifest and the offline page are: the sign-in page needs the logo before
+anybody has signed in, and a browser fetches a stylesheet and a launcher icon
+outside any page's session.
+
+What that exposes is a file an administrator uploaded on purpose to be shown to
+everybody, under a random filename, in a format that cannot execute in an
+`
` — `services/uploads.py:ALLOWED_TYPES` is what makes the last part true,
+and it is why SVG is not in it.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Response, status
+from fastapi.responses import FileResponse
+
+from lembas.services import branding as branding_service
+from lembas.services import uploads
+
+router = APIRouter(tags=["branding"])
+
+
+@router.get("/branding.css", include_in_schema=False)
+async def branding_css() -> Response:
+ """The custom themes and the custom CSS.
+
+ A route rather than an inline `` away
+ from being a script on every page.
+
+ Cached hard and busted by a query string. `base.html` links this with
+ `?v={{ brand.revision }}`, a hash of everything below, so the URL changes
+ exactly when the stylesheet does. Without that the browser's cache is what
+ decides when a rebrand takes effect, which is a save that looks like it
+ worked and did nothing.
+ """
+ brand = branding_service.snapshot()
+ return Response(
+ branding_service.stylesheet(brand),
+ media_type="text/css",
+ headers={"Cache-Control": "public, max-age=604800"},
+ )
+
+
+@router.get("/branding/{filename}", include_in_schema=False)
+async def branding_asset(filename: str) -> Response:
+ """A logo, a favicon, or a launcher icon derived from one."""
+ path = uploads.branding_image_path(filename)
+ if path is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "No such file.")
+ return FileResponse(
+ path,
+ media_type=uploads.media_type_for(filename),
+ # Public, unlike a model avatar: this is served to somebody who is not
+ # signed in, so there is nothing private to keep out of a shared cache.
+ # Names are random, so a replacement is a new URL.
+ headers={
+ "Cache-Control": "public, max-age=604800",
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py
index b1cb2aa..80c3b88 100644
--- a/src/lembas/api/pages.py
+++ b/src/lembas/api/pages.py
@@ -23,6 +23,7 @@ from lembas.db.models import (
)
from lembas.security import permissions
from lembas.services import audio as audio_service
+from lembas.services import branding as branding_service
from lembas.services import canvas as canvas_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
@@ -443,19 +444,31 @@ async def manifest(db: Db) -> Response:
else would be wrong on the one screen that is hardest to correct: the
launcher.
"""
- name = settings_store.get(db, "instance_name") or "LLeMbas"
+ brand = branding_service.for_db(db)
+ icons = brand.icon_paths
return JSONResponse(
{
"id": "/",
- "name": name,
- "short_name": name[:12],
- "description": "A web UI for your language models.",
+ "name": brand.name,
+ "short_name": brand.name[:12],
+ "description": brand.tagline or "A web UI for your language models.",
"start_url": "/chat",
"scope": "/",
"display": "standalone",
"background_color": THEME_COLOUR["moria"],
"theme_color": THEME_COLOUR["moria"],
+ # An uploaded logo's derived icons, or the shipped ones. Whole-set
+ # rather than per size: a manifest listing two custom icons and one
+ # shipped is a launcher tile that changes when the device picks a
+ # different size, which reads as a bug in the install.
"icons": [
+ {"src": f"/branding/{icons['icon-192']}", "sizes": "192x192",
+ "type": "image/png", "purpose": "any"},
+ {"src": f"/branding/{icons['icon-512']}", "sizes": "512x512",
+ "type": "image/png", "purpose": "any"},
+ {"src": f"/branding/{icons['maskable']}", "sizes": "512x512",
+ "type": "image/png", "purpose": "maskable"},
+ ] if icons.get("icon-192") and icons.get("icon-512") and icons.get("maskable") else [
{"src": "/static/img/icon-192.png", "sizes": "192x192",
"type": "image/png", "purpose": "any"},
{"src": "/static/img/icon-512.png", "sizes": "512x512",
diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py
index 564c78b..d927473 100644
--- a/src/lembas/api/preferences.py
+++ b/src/lembas/api/preferences.py
@@ -18,7 +18,14 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
-THEMES = ("moria", "shire")
+# The built-in pair used to be spelled out here, and in four other places. It is
+# one server-resolved list now, because an administrator can define a theme and a
+# hard-coded pair would refuse it -- silently, since this route answers a
+# rejection with `{"ok": false}` that nothing displays.
+def themes() -> tuple[str, ...]:
+ from lembas.services import branding
+
+ return branding.snapshot().theme_ids
@router.post("/theme")
@@ -29,7 +36,7 @@ async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=Tru
the choice follow the user to another browser, and what lets the server
render the right theme on first paint instead of flashing the default.
"""
- if theme not in THEMES:
+ if theme not in themes():
return {"ok": False, "detail": "Unknown theme."}
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
diff --git a/src/lembas/main.py b/src/lembas/main.py
index b0d6a7f..ed5a236 100644
--- a/src/lembas/main.py
+++ b/src/lembas/main.py
@@ -16,6 +16,7 @@ from lembas.api import (
admin,
admin_agents,
admin_audio,
+ admin_branding,
admin_images,
admin_models,
admin_prompts,
@@ -27,6 +28,7 @@ from lembas.api import (
agents,
audio,
auth,
+ branding,
canvas,
chats,
files,
@@ -181,6 +183,7 @@ def create_app() -> FastAPI:
app.include_router(admin_users.router)
app.include_router(admin_models.router)
app.include_router(admin_audio.router)
+ app.include_router(admin_branding.router)
app.include_router(admin_search.router)
app.include_router(admin_schedules.router)
app.include_router(admin_images.router)
@@ -189,6 +192,7 @@ def create_app() -> FastAPI:
app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
app.include_router(push.router)
+ app.include_router(branding.router)
register_error_handlers(app)
return app
@@ -219,7 +223,7 @@ def register_error_handlers(app: FastAPI) -> None:
{
"status_code": exc.status_code,
"detail": exc.detail,
- "flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
+ "flavour": error_flavour(exc.status_code),
},
status_code=exc.status_code,
)
@@ -233,18 +237,24 @@ def register_error_handlers(app: FastAPI) -> None:
request,
"error.html",
{"status_code": 500, "detail": "Something went wrong.",
- "flavour": ERROR_FLAVOUR[500]},
+ "flavour": error_flavour(500)},
status_code=500,
)
# Flavour lives in error pages, empty states and theme names -- never in the
# functional UI. See CLAUDE.md.
-ERROR_FLAVOUR = {
- 403: "Speak, friend, and enter. This door is not yours to open.",
- 404: "Not all those who wander are lost. This page, however, is.",
- 500: "The Road goes ever on, but this stretch of it has washed out.",
-}
+#
+# The three lines themselves moved into `services/branding.py` with the rest of
+# what an administrator can replace. What is left here is the mapping from a
+# status code to which of them, which is not something anybody would want to
+# edit. `snapshot()` never raises, so an error page can still render its error
+# on an instance whose database is the thing that broke.
+def error_flavour(status_code: int) -> str:
+ from lembas.services import branding
+
+ text = branding.snapshot().text
+ return text.get(f"error_{status_code}") or text["error_500"]
app = create_app()
diff --git a/src/lembas/services/branding.py b/src/lembas/services/branding.py
new file mode 100644
index 0000000..7e31f45
--- /dev/null
+++ b/src/lembas/services/branding.py
@@ -0,0 +1,506 @@
+"""What this installation is called, and what it looks like.
+
+An instance can be somebody else's. That means four separate things, and they
+are separate because they fail differently:
+
+- an **identity** — a name, a tagline, a logo, a favicon, the icons a launcher
+ shows;
+- **flavour text** — the Middle-earth lines, which live in the artwork, the
+ empty states, the loading lines and the error pages and nowhere else (see the
+ flavour rule in CLAUDE.md), and which somebody rebranding needs to be able to
+ replace without editing templates;
+- **themes**, which are token sets rather than stylesheets, because the
+ invariant that no component hard-codes a colour is what makes a third one
+ compose at all;
+- **arbitrary CSS**, for the things the first three do not reach.
+
+## Defaults in code, overrides in the database
+
+The prompt-fragment rule, applied again and for the same reason: text equal to
+its default is never stored, so a later release improving a default still
+reaches an instance whose administrator once pressed Save. `stored_only` is what
+enforces it, and every save goes through it.
+
+## Why a snapshot, and why a Jinja global
+
+`web/templating.py:render()` has no database session, and the login page, the
+error pages, the offline page and the SSE path do not go through it at all. A
+context value would therefore have to be threaded through every one of those,
+and the ones that bypass `render()` could not be reached at all.
+
+So this is a **process-level cache** behind a lazy proxy registered as a Jinja
+global. One query per process, and after every save; every render path gets it
+including the ones that never see a `Request`. `forget()` is called by the admin
+page and by nothing else.
+
+The cost of being a cache is stated rather than discovered: with several
+workers, a save in one is not seen by the others until each next reads. That is
+already true of this application for other reasons -- see the "one worker" note
+in PLAN.md -- and this does not make it worse.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+from lembas.services import settings_store
+
+log = logging.getLogger(__name__)
+
+BRANDING = settings_store.BRANDING
+
+DEFAULT_NAME = "LLeMbas"
+
+# --- Flavour ------------------------------------------------------------------
+# Every Middle-earth string in the interface, with its current wording as the
+# default. Keyed rather than positional so a template names what it wants, and
+# a key nobody has overridden costs nothing to store.
+#
+# The label is what the admin page calls the field; the hint says where it is
+# seen, because a string with no context is one nobody can safely rewrite.
+FLAVOUR: dict[str, tuple[str, str, str]] = {
+ "login_tagline": (
+ "Under the sign-in mark",
+ "The one line on the sign-in page, beneath the name.",
+ "Waybread for the long road of thought.",
+ ),
+ "chat_empty": (
+ "Empty chat",
+ "Above the composer on a chat with nothing in it yet.",
+ "Speak, friend, and enter.",
+ ),
+ "offline_title": (
+ "Offline heading",
+ "The page the service worker shows when the server cannot be reached.",
+ "No road from here",
+ ),
+ "offline_line": (
+ "Offline line",
+ "Beneath that heading. The sentence below it is functional and is not "
+ "editable here.",
+ "The Road goes ever on and on — but not without a connection.",
+ ),
+ "error_403": (
+ "403 — not yours",
+ "Shown on a page somebody is not allowed to see.",
+ "Speak, friend, and enter. This door is not yours to open.",
+ ),
+ "error_404": (
+ "404 — not found",
+ "Shown on a page that does not exist.",
+ "Not all those who wander are lost. This page, however, is.",
+ ),
+ "error_500": (
+ "500 — something broke",
+ "Shown when something went wrong on the server.",
+ "The Road goes ever on, but this stretch of it has washed out.",
+ ),
+ "theme_moria": (
+ "Dark theme name",
+ "What the built-in dark theme is called, in the settings screen and in "
+ "the /theme command.",
+ "Moria",
+ ),
+ "theme_shire": (
+ "Light theme name",
+ "What the built-in light theme is called.",
+ "Shire",
+ ),
+}
+
+# --- Themes -------------------------------------------------------------------
+# The two built-ins. `css` is empty for both: their tokens are declared in
+# tokens.css, which is the one place colours live, and duplicating them here so
+# that a custom theme could "inherit" would be exactly the second copy that
+# rule exists to prevent. A custom theme inherits by naming a base instead --
+# see `theme_css` below.
+SCHEME_DARK = "dark"
+SCHEME_LIGHT = "light"
+
+BUILT_IN = (
+ ("moria", SCHEME_DARK, "#101317"),
+ ("shire", SCHEME_LIGHT, "#F6F1E4"),
+)
+
+# What a custom theme may set. A curated handful rather than every token a
+# theme block declares: sixty colour pickers is not a feature, and everything
+# left out inherits from the base, which is what makes a theme that changes
+# four things four things long.
+#
+# `--accent-soft`, `--leaf-soft` and `--danger-soft` are deliberately absent and
+# are derived instead: they are the same colour at 14% and an administrator who
+# changed the accent without them would get focus rings in the old hue, which
+# looks like the setting half-working.
+THEME_TOKENS: tuple[tuple[str, str], ...] = (
+ ("bg", "Page background"),
+ ("bg-sunken", "Behind the page — the sidebar and panel gutters"),
+ ("surface", "Cards, menus and the composer"),
+ ("surface-raised", "Anything sitting on a surface"),
+ ("surface-hover", "A surface under the pointer"),
+ ("border", "Ordinary borders"),
+ ("border-strong", "Borders that have to be seen"),
+ ("ink", "Body text"),
+ ("ink-muted", "Secondary text"),
+ ("ink-faint", "Hints and timestamps"),
+ ("accent", "Links, focus and interactive accents"),
+ ("accent-hover", "The accent under the pointer"),
+ ("accent-ink", "Text on top of the accent"),
+ ("leaf", "The brand accent and the assistant's mark"),
+ ("danger", "Errors and destructive actions"),
+ ("success", "Confirmations and unread dots"),
+ ("warning", "Warnings"),
+ ("bubble-user", "Behind your own messages"),
+ ("code-bg", "Behind code"),
+)
+
+THEME_TOKEN_NAMES = tuple(name for name, _ in THEME_TOKENS)
+
+# A colour, and nothing else. Values reach a stylesheet, so a `}` in one would
+# end the rule and silently break every rule after it -- and `url(…)` in a
+# colour slot is a request to a third party from every page. Anything that does
+# not match is dropped rather than corrected: a colour nobody can read is a
+# setting that did not take, and that is visible, while a mangled one is not.
+_COLOUR = re.compile(
+ r"^(#[0-9a-fA-F]{3,8}"
+ r"|rgba?\([0-9,.\s%/]+\)"
+ r"|hsla?\([0-9,.\s%/deg]+\)"
+ r"|[a-z]{3,20})$"
+)
+
+# An id that can be an attribute value and a CSS selector without quoting.
+_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,23}$")
+
+
+@dataclass(frozen=True)
+class Theme:
+ """One theme somebody can choose."""
+
+ id: str
+ label: str
+ scheme: str
+ # Which built-in it starts from. A custom theme sets a handful of tokens and
+ # inherits the rest, and that inheritance is a CSS fact: tokens.css matches
+ # `[data-base="shire"]` as well as `[data-theme="shire"]`, so a custom light
+ # theme carries `data-base="shire"` and gets the whole parchment palette
+ # underneath its own four colours. Without it a light custom theme would be
+ # four light colours on Moria's near-black surfaces.
+ base: str = "moria"
+ tokens: dict[str, str] = field(default_factory=dict)
+ colour: str = ""
+ built_in: bool = False
+
+
+@dataclass(frozen=True)
+class Branding:
+ """Everything a page needs to know about whose instance this is."""
+
+ name: str = DEFAULT_NAME
+ tagline: str = ""
+ logo_path: str = ""
+ favicon_path: str = ""
+ icon_paths: dict[str, str] = field(default_factory=dict)
+ custom_css: str = ""
+ text: dict[str, str] = field(default_factory=dict)
+ themes: tuple[Theme, ...] = ()
+
+ @property
+ def theme_ids(self) -> tuple[str, ...]:
+ return tuple(theme.id for theme in self.themes)
+
+ @property
+ def theme_list(self) -> str:
+ """`id:base` pairs, space separated, for the `data-themes` attribute.
+
+ One attribute rather than a JSON island, because two things in the
+ browser need it — `/theme` validating a name, and `applyTheme` setting
+ `data-base` alongside `data-theme` — and both want a list they can split
+ rather than a document they have to parse.
+ """
+ return " ".join(f"{theme.id}:{theme.base}" for theme in self.themes)
+
+ def theme(self, theme_id: str) -> Theme:
+ for theme in self.themes:
+ if theme.id == theme_id:
+ return theme
+ return self.themes[0]
+
+ @property
+ def revision(self) -> str:
+ """A short hash of everything `/branding.css` is built from.
+
+ It goes in that link's query string, so the URL changes exactly when the
+ stylesheet does. Without it the browser's cache is the thing deciding
+ when a rebrand takes effect, which is the failure this codebase keeps
+ cataloguing: a save that looks like it worked and did nothing.
+ """
+ material = repr((self.custom_css, [(t.id, t.base, sorted(t.tokens.items())) for t in
+ self.themes]))
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12]
+
+
+# --- Reading ------------------------------------------------------------------
+def defaults() -> dict[str, Any]:
+ return {
+ "instance_name": "",
+ "tagline": "",
+ "logo_path": "",
+ "favicon_path": "",
+ # Derived from the logo at save time, so a launcher gets real PNGs at
+ # the sizes it asks for rather than one image the browser is told to
+ # scale. Empty means the shipped artwork is used.
+ "icon_paths": {},
+ "custom_css": "",
+ "themes": [],
+ **{f"text_{key}": "" for key in FLAVOUR},
+ }
+
+
+def _theme_from(raw: dict[str, Any]) -> Theme | None:
+ """One stored custom theme, or None if it is not usable.
+
+ Every field is validated on read rather than trusted from the row: a theme
+ stored by an earlier version, or written straight into the settings table,
+ still has to produce a stylesheet that parses.
+ """
+ theme_id = str(raw.get("id") or "").strip().lower()
+ if not _THEME_ID.match(theme_id) or theme_id in {name for name, _, _ in BUILT_IN}:
+ return None
+ base = str(raw.get("base") or "moria")
+ if base not in {name for name, _, _ in BUILT_IN}:
+ base = "moria"
+ tokens = {
+ name: value
+ for name, value in (raw.get("tokens") or {}).items()
+ if name in THEME_TOKEN_NAMES and _COLOUR.match(str(value).strip())
+ }
+ scheme = next(s for name, s, _ in BUILT_IN if name == base)
+ return Theme(
+ id=theme_id,
+ label=str(raw.get("label") or theme_id).strip()[:60] or theme_id,
+ scheme=scheme,
+ base=base,
+ tokens=tokens,
+ colour=tokens.get("bg", ""),
+ )
+
+
+def build(values: dict[str, Any]) -> Branding:
+ """A snapshot from a settings group. Pure, so it can be tested without a
+ database and used by the preview on the admin page."""
+ text = {
+ key: str(values.get(f"text_{key}") or "").strip() or default
+ for key, (_, _, default) in FLAVOUR.items()
+ }
+ themes = [
+ Theme(
+ id=theme_id,
+ label=text[f"theme_{theme_id}"],
+ scheme=scheme,
+ base=theme_id,
+ colour=colour,
+ built_in=True,
+ )
+ for theme_id, scheme, colour in BUILT_IN
+ ]
+ seen = {theme.id for theme in themes}
+ for raw in values.get("themes") or []:
+ if not isinstance(raw, dict):
+ continue
+ theme = _theme_from(raw)
+ if theme is not None and theme.id not in seen:
+ seen.add(theme.id)
+ themes.append(theme)
+ return Branding(
+ name=str(values.get("instance_name") or "").strip() or DEFAULT_NAME,
+ tagline=str(values.get("tagline") or "").strip(),
+ logo_path=str(values.get("logo_path") or ""),
+ favicon_path=str(values.get("favicon_path") or ""),
+ icon_paths=dict(values.get("icon_paths") or {}),
+ custom_css=str(values.get("custom_css") or ""),
+ text=text,
+ themes=tuple(themes),
+ )
+
+
+_CACHE: Branding | None = None
+
+
+def snapshot() -> Branding:
+ """The current branding, from a process-level cache.
+
+ Never raises. An error page that cannot render because branding could not be
+ read is a failure that hides the failure it was about to report, so a
+ database that is not there yet answers with the defaults.
+ """
+ global _CACHE
+ if _CACHE is not None:
+ return _CACHE
+ try:
+ from lembas.db.session import session_scope
+
+ with session_scope() as db:
+ _CACHE = _read(db)
+ except Exception: # noqa: BLE001 - defaults are a usable answer, an exception is not
+ log.debug("could not read branding; using defaults", exc_info=True)
+ return build(defaults())
+ return _CACHE
+
+
+def _read(db) -> Branding:
+ """The two groups this is assembled from.
+
+ `instance_name` lived in the general group before there was a branding one,
+ and an upgrade must not quietly rename somebody's instance back to LLeMbas.
+ So the stored general value is a **seed**, and the test for it is whether the
+ branding row has said anything about the name at all -- `key in row`, not
+ `row[key] is truthy`. An empty stored name is somebody clearing the box,
+ which has to mean the default; a *missing* one is an instance that has never
+ seen this page. Reading the two the same way would resurrect the old name
+ underneath a cleared one, which is the failure a cleared reasoning effort
+ already documents.
+
+ That is why this reads the raw row rather than `get_group`, which fills in
+ defaults and so cannot tell absent from empty.
+ """
+ from lembas.db.models import Setting
+
+ values = settings_store.get_group(db, BRANDING)
+ row = db.get(Setting, BRANDING)
+ said = isinstance(row, Setting) and isinstance(row.value, dict) and "instance_name" in row.value
+ if not said:
+ legacy = settings_store.get_group(db, settings_store.GENERAL).get("instance_name")
+ if legacy:
+ values = {**values, "instance_name": legacy}
+ return build(values)
+
+
+def forget() -> None:
+ """Drop the cache. Called by the admin page's save, and by tests."""
+ global _CACHE
+ _CACHE = None
+
+
+def for_db(db) -> Branding:
+ """The snapshot, seeded from a session the caller already has open.
+
+ Same value as `snapshot()`; this only spares the extra session on the first
+ render after a restart, where one is already in hand.
+ """
+ global _CACHE
+ if _CACHE is None:
+ _CACHE = _read(db)
+ return _CACHE
+
+
+# --- Writing ------------------------------------------------------------------
+def stored_only(values: dict[str, Any]) -> dict[str, Any]:
+ """Blank anything equal to its shipped wording, so it is not an override.
+
+ The prompt-fragment rule, and the reason it is a **blank rather than a
+ dropped key**: `settings_store.update` merges, so omitting a key leaves
+ whatever was stored last time. Dropping one would make "I typed the default
+ back in" and "I changed nothing" store different things, and make clearing a
+ box do nothing at all.
+
+ Empty is the not-overridden marker because `build` reads `stored or
+ default`. That is deliberately *not* the fragment convention, where an empty
+ override means the fragment is off: a fragment being off is a state somebody
+ wants, and a heading with no words is not.
+ """
+ return {
+ key: ("" if _is_shipped(key, value) else value) for key, value in values.items()
+ }
+
+
+def _is_shipped(key: str, value: Any) -> bool:
+ if key.startswith("text_"):
+ entry = FLAVOUR.get(key[len("text_") :])
+ return entry is not None and value == entry[2]
+ return value == defaults().get(key)
+
+
+# --- The stylesheet -----------------------------------------------------------
+def _soft(colour: str, alpha: str = "0.14") -> str:
+ """A colour at low opacity, for the `*-soft` tokens.
+
+ Derived rather than asked for: they are the same colour at 14%, and an
+ administrator who set an accent without them would get focus rings and
+ selected states in the old hue -- which reads as the setting half-working
+ rather than as a field they missed.
+
+ Only hex is understood. Anything else answers "" and the base theme's own
+ soft value stands, which is the right failure: a wrong soft colour is worse
+ than an unchanged one.
+ """
+ value = colour.strip()
+ if not value.startswith("#"):
+ return ""
+ digits = value[1:]
+ if len(digits) == 3:
+ digits = "".join(c * 2 for c in digits)
+ if len(digits) not in (6, 8):
+ return ""
+ try:
+ r, g, b = (int(digits[i : i + 2], 16) for i in (0, 2, 4))
+ except ValueError:
+ return ""
+ return f"rgba({r}, {g}, {b}, {alpha})"
+
+
+def theme_css(theme: Theme) -> str:
+ """One custom theme as a rule.
+
+ Two selectors' worth of work in one: the block sets what was chosen, and the
+ `data-base` attribute on is what brings the rest of the base theme's
+ palette with it. Written here and served from `/branding.css`, which loads
+ after `tokens.css`, so these win on order at equal specificity.
+ """
+ if not theme.tokens:
+ return ""
+ lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
+ for name, alpha in (("accent", "0.14"), ("leaf", "0.14"), ("danger", "0.14")):
+ soft = _soft(theme.tokens.get(name, ""), alpha)
+ if soft:
+ lines.append(f" --{name}-soft: {soft};")
+ return f':root[data-theme="{theme.id}"] {{\n' + "\n".join(lines) + "\n}\n"
+
+
+def stylesheet(brand: Branding) -> str:
+ """Everything `/branding.css` serves.
+
+ A route rather than an inline `` away from being a script.
+ """
+ parts = [
+ "/* Generated by LLeMbas from the customization settings. */",
+ *(theme_css(theme) for theme in brand.themes if not theme.built_in),
+ ]
+ if brand.custom_css.strip():
+ parts += ["/* Custom CSS. */", brand.custom_css.strip(), ""]
+ return "\n".join(part for part in parts if part)
+
+
+__all__ = [
+ "BRANDING",
+ "BUILT_IN",
+ "DEFAULT_NAME",
+ "FLAVOUR",
+ "THEME_TOKENS",
+ "THEME_TOKEN_NAMES",
+ "Branding",
+ "Theme",
+ "build",
+ "defaults",
+ "for_db",
+ "forget",
+ "snapshot",
+ "stored_only",
+ "stylesheet",
+ "theme_css",
+]
diff --git a/src/lembas/services/harness.py b/src/lembas/services/harness.py
index c8df96b..819101f 100644
--- a/src/lembas/services/harness.py
+++ b/src/lembas/services/harness.py
@@ -39,7 +39,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession
from lembas.db.models import KIND_TASK, User
-from lembas.services import prompts, settings_store
+from lembas.services import branding, prompts, settings_store
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
from lembas.services.schedule import clock
@@ -192,7 +192,7 @@ def context_variables(
# nobody has chosen one, which drops the line rather than printing the
# server's zone as though it were a decision.
"timezone": clock.name_for(user),
- "instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
+ "instance_name": branding.for_db(db).name,
"user_name": (user.name or "") if user is not None else "",
"model_name": "",
# What this request will actually allow, so the model is not told a
diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py
index ec148a8..4f4c6f5 100644
--- a/src/lembas/services/settings_store.py
+++ b/src/lembas/services/settings_store.py
@@ -32,6 +32,7 @@ AGENTS = "agents"
IMAGES = "images"
SCHEDULES = "schedules"
SUBAGENTS = "subagents"
+BRANDING = "branding"
def _general_defaults() -> dict[str, Any]:
@@ -40,7 +41,12 @@ def _general_defaults() -> dict[str, Any]:
# When on, new accounts land in the `pending` role and cannot sign in
# until an administrator approves them. Reserved for the users pass.
"require_approval": False,
- "instance_name": "LLeMbas",
+ # `instance_name` used to be here and now lives in the BRANDING group,
+ # with the rest of what makes an instance somebody else's. The key is
+ # deliberately not listed any more: an upgraded instance still has it in
+ # its stored general row, and `branding.snapshot` reads that once as a
+ # seed. Leaving a default here as well would give the name two sources
+ # and no answer to which one wins.
# Applied to every chat that has no model or chat prompt of its
# own. See services.chat.effective_system_prompt.
"system_prompt": "",
@@ -382,9 +388,22 @@ _DEFAULTS: dict[str, Any] = {
IMAGES: _images_defaults,
SCHEDULES: _schedules_defaults,
SUBAGENTS: _subagents_defaults,
+ # Whose instance this is. The defaults live in `services/branding.py`
+ # beside the code that reads them, because every one of them is paired with
+ # a label and a hint for the admin page and splitting the three across two
+ # modules is how one of them goes stale.
+ BRANDING: lambda: _branding_defaults(),
}
+def _branding_defaults() -> dict[str, Any]:
+ """Imported inside the call: `services/branding.py` imports this module for
+ the group key, so a top-level import back is a cycle."""
+ from lembas.services import branding
+
+ return branding.defaults()
+
+
def defaults(key: str = GENERAL) -> dict[str, Any]:
"""The built-in values for a settings group, with nothing stored applied."""
factory = _DEFAULTS.get(key)
diff --git a/src/lembas/services/uploads.py b/src/lembas/services/uploads.py
index d684bbe..3e5cdde 100644
--- a/src/lembas/services/uploads.py
+++ b/src/lembas/services/uploads.py
@@ -98,6 +98,151 @@ def delete_model_image(filename: str) -> None:
path.unlink(missing_ok=True)
+# --- Branding assets -----------------------------------------------------------
+# The logo, the favicon and the launcher icons derived from them. A separate
+# directory from the model avatars because the *route* differs: these are served
+# unauthenticated, since the sign-in page and the web app manifest both need
+# them and neither has a session to check.
+#
+# SVG stays excluded, and this is where somebody will most want it. Every entry
+# in ALLOWED_TYPES is a format that cannot execute in an `
`; an SVG can, and
+# these are the one set of files served to somebody who is not signed in.
+
+# What a launcher asks for, and what `` wants.
+# Generated from the uploaded logo rather than asked for separately: an
+# administrator who has a logo has said everything they need to say, and five
+# upload fields to fill in by hand is how three of them end up wrong.
+ICON_SIZES: dict[str, int] = {
+ "icon-192": 192,
+ "icon-512": 512,
+ "apple-touch": 180,
+ "favicon": 32,
+}
+
+# The maskable icon has to survive being cropped to a circle, so the artwork
+# sits inside the safe zone with the background showing around it. 80% is the
+# standard's own guidance and is what the shipped icon already uses.
+MASKABLE_SIZE = 512
+MASKABLE_INSET = 0.8
+
+
+def branding_dir() -> Path:
+ path = settings.uploads_dir / "branding"
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+def save_branding_image(payload: bytes, declared_type: str) -> str:
+ """Validate and store a logo or favicon. Returns the stored filename."""
+ if not payload:
+ raise UploadError("The file was empty.")
+ if len(payload) > MAX_BYTES:
+ raise UploadError(f"Images must be under {MAX_BYTES // (1024 * 1024)} MB.")
+ actual = _detect(payload)
+ if actual is None:
+ raise UploadError(
+ "That does not look like a PNG, JPEG, WEBP or GIF image. "
+ "SVG is deliberately not accepted: these files are served to people "
+ "who are not signed in, and an SVG can carry a script."
+ )
+ if declared_type and declared_type.split(";")[0].strip() != actual:
+ log.info("upload declared %s but is actually %s", declared_type, actual)
+
+ filename = f"{secrets.token_hex(16)}{ALLOWED_TYPES[actual]}"
+ (branding_dir() / filename).write_bytes(payload)
+ return filename
+
+
+def derive_icons(payload: bytes) -> dict[str, str]:
+ """Launcher icons from an uploaded logo, at the sizes a browser asks for.
+
+ Best-effort: an instance whose logo cannot be resized keeps the shipped
+ icons, which is a worse launcher tile and not a broken install. Pillow is
+ already a dependency (`services/files.py` uses it for attachments), so this
+ adds nothing to install.
+
+ Every output is PNG regardless of what came in, because that is what a
+ manifest icon has to be, and RGBA so a logo with a transparent background
+ stays one.
+ """
+ try:
+ import io
+
+ from PIL import Image
+ except Exception: # noqa: BLE001 - Pillow missing is not a failed save
+ log.info("Pillow unavailable; keeping the shipped launcher icons")
+ return {}
+
+ try:
+ with Image.open(io.BytesIO(payload)) as source:
+ source.load()
+ image = source.convert("RGBA")
+ except Exception: # noqa: BLE001 - a file we stored but cannot read
+ log.info("could not read the uploaded logo for icons", exc_info=True)
+ return {}
+
+ paths: dict[str, str] = {}
+ for name, size in ICON_SIZES.items():
+ paths[name] = _write_png(_fitted(image, size, size), f"{name}")
+ # Cropped to a circle on Android, so the artwork is inset and the corners
+ # are filled rather than transparent -- a transparent maskable icon is
+ # rendered as a black square by some launchers.
+ canvas = _new_canvas(MASKABLE_SIZE, image)
+ inner = _fitted(image, int(MASKABLE_SIZE * MASKABLE_INSET), int(MASKABLE_SIZE * MASKABLE_INSET))
+ offset = (MASKABLE_SIZE - inner.width) // 2, (MASKABLE_SIZE - inner.height) // 2
+ canvas.alpha_composite(inner, offset)
+ paths["maskable"] = _write_png(canvas, "maskable")
+ return paths
+
+
+def _fitted(image, width: int, height: int):
+ from PIL import Image
+
+ return image.copy().resize((width, height), Image.LANCZOS)
+
+
+def _new_canvas(size: int, source):
+ """A square the colour of the logo's top-left pixel, or transparent.
+
+ Sampling one pixel rather than averaging: a logo on a flat background gets
+ that background, which is what the inset needs, and a logo on a transparent
+ one gets transparency, which the composite below then fills.
+ """
+ from PIL import Image
+
+ corner = source.getpixel((0, 0))
+ fill = corner if isinstance(corner, tuple) and len(corner) == 4 else (0, 0, 0, 0)
+ return Image.new("RGBA", (size, size), fill)
+
+
+def _write_png(image, label: str) -> str:
+ import io
+
+ buffer = io.BytesIO()
+ image.save(buffer, format="PNG", optimize=True)
+ filename = f"{label}-{secrets.token_hex(8)}.png"
+ (branding_dir() / filename).write_bytes(buffer.getvalue())
+ return filename
+
+
+def branding_image_path(filename: str) -> Path | None:
+ """Resolve a stored branding filename, refusing anything outside the dir."""
+ if not filename or "/" in filename or "\\" in filename or filename.startswith("."):
+ return None
+ path = (branding_dir() / filename).resolve()
+ try:
+ path.relative_to(branding_dir().resolve())
+ except ValueError:
+ return None
+ return path if path.is_file() else None
+
+
+def delete_branding_image(filename: str) -> None:
+ path = branding_image_path(filename)
+ if path is not None:
+ path.unlink(missing_ok=True)
+
+
def media_type_for(filename: str) -> str:
suffix = Path(filename).suffix.lower()
for media_type, extension in ALLOWED_TYPES.items():
diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css
index ca51a41..de707c5 100644
--- a/src/lembas/web/static/css/tokens.css
+++ b/src/lembas/web/static/css/tokens.css
@@ -238,7 +238,17 @@
reads as clinical rather than as paper.
---------------------------------------------------------------------------
*/
-:root[data-theme="shire"] {
+/*
+ `[data-base="shire"]` as well as `[data-theme="shire"]`, and that second
+ selector is what makes a custom theme possible. A custom theme is a handful of
+ token overrides served from /branding.css; everything it does not set has to
+ come from somewhere, and Moria's block above matches bare `:root` so it always
+ applies. Without this a custom *light* theme would be four light colours on
+ near-black surfaces. `` carries both attributes -- see base.html and
+ app.js:applyTheme.
+*/
+:root[data-theme="shire"],
+:root[data-base="shire"] {
color-scheme: light;
--bg: #F6F1E4;
diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js
index 9cd6722..0b0d12b 100644
--- a/src/lembas/web/static/js/app.js
+++ b/src/lembas/web/static/js/app.js
@@ -9,19 +9,44 @@
"use strict";
var THEME_KEY = "lembas-theme";
- var THEMES = ["moria", "shire"];
/* --- Theme -------------------------------------------------------------
Stored locally so the choice applies instantly and survives being signed
out, and mirrored to the server so it follows the user to another device.
- The server call is best-effort: a failure must not undo the local switch. */
+ The server call is best-effort: a failure must not undo the local switch.
+
+ The list used to be a literal pair here, and in four other places. It comes
+ from `data-themes` on now -- "id:base" pairs, space separated --
+ because an administrator can define one, and a hard-coded pair would refuse
+ it silently: applyTheme would return, the button would do nothing, and
+ nothing anywhere would say why. */
+ function themes() {
+ var raw = document.documentElement.dataset.themes || "moria:moria shire:shire";
+ var map = {};
+ raw.split(/\s+/).forEach(function (entry) {
+ var parts = entry.split(":");
+ if (parts[0]) map[parts[0]] = parts[1] || "moria";
+ });
+ return map;
+ }
+
+ function themeNames() {
+ return Object.keys(themes());
+ }
+
function currentTheme() {
- return document.documentElement.dataset.theme || THEMES[0];
+ return document.documentElement.dataset.theme || themeNames()[0];
}
function applyTheme(name) {
- if (THEMES.indexOf(name) === -1) return;
+ var known = themes();
+ if (!Object.prototype.hasOwnProperty.call(known, name)) return;
document.documentElement.dataset.theme = name;
+ /* Both attributes, always. `data-base` is what makes a custom theme inherit
+ its built-in palette -- tokens.css matches it as well as `data-theme` --
+ so setting only the first leaves a custom light theme's four colours on
+ Moria's near-black surfaces. */
+ document.documentElement.dataset.base = known[name];
try {
localStorage.setItem(THEME_KEY, name);
} catch (e) { /* private mode */ }
@@ -36,9 +61,13 @@
if (bg) meta.setAttribute("content", bg);
}
+ /* The toggle names where it is going, not where it is. With more than two
+ themes "the next one" is the honest description, because naming it would
+ mean carrying every label into the browser for a label nobody reads
+ twice. */
document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
- el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
- : "Switch to Moria (dark)");
+ el.setAttribute("aria-label", known[name] === "moria" ? "Switch to the light theme"
+ : "Switch to the dark theme");
});
/* For anything holding colours as values rather than reading them from a
@@ -56,8 +85,13 @@
}
}
+ /* Round the list rather than between two names. With only the built-in pair
+ this is exactly what it always did; with a third defined it reaches it,
+ which a hard-coded flip never could. */
function toggleTheme() {
- applyTheme(currentTheme() === "moria" ? "shire" : "moria");
+ var names = themeNames();
+ var at = names.indexOf(currentTheme());
+ applyTheme(names[(at + 1) % names.length] || names[0]);
}
/* --- Textarea autosize -------------------------------------------------
diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js
index 2694ad7..c63acf7 100644
--- a/src/lembas/web/static/js/commands.js
+++ b/src/lembas/web/static/js/commands.js
@@ -161,10 +161,18 @@
{
name: "theme",
summary: "Switch theme",
- argument: "moria | shire",
+ /* Read from the document rather than written here, for the reason
+ app.js's own list is: an administrator can define a theme, and a
+ literal pair would leave `/theme dusk` silently toggling instead. */
+ argument: function () {
+ return (document.documentElement.dataset.themes || "moria:moria shire:shire")
+ .split(/\s+/).map(function (entry) { return entry.split(":")[0]; }).join(" | ");
+ },
run: function (rest) {
var wanted = (rest || "").trim().toLowerCase();
- if (wanted === "moria" || wanted === "shire") window.lembas.applyTheme(wanted);
+ var known = (document.documentElement.dataset.themes || "").split(/\s+/)
+ .map(function (entry) { return entry.split(":")[0]; });
+ if (wanted && known.indexOf(wanted) !== -1) window.lembas.applyTheme(wanted);
else window.lembas.toggleTheme();
}
},
@@ -411,7 +419,8 @@
body.className = "picker__option-body";
var name = document.createElement("span");
name.className = "picker__option-name";
- name.textContent = "/" + command.name + (command.argument ? " " + command.argument : "");
+ var hint = argumentOf(command);
+ name.textContent = "/" + command.name + (hint ? " " + hint : "");
var summary = document.createElement("span");
summary.className = "picker__option-note";
summary.textContent = command.summary;
@@ -482,11 +491,20 @@
dialog.showModal();
}
+ /* A command's argument hint, which is usually a literal and is sometimes
+ worked out from the page -- `/theme` lists the themes that exist, and those
+ are an administrator's to define. Resolved in the two places that render
+ it, so a command may be either without either caring. */
+ function argumentOf(command) {
+ var value = command.argument;
+ return typeof value === "function" ? value() : (value || "");
+ }
+
function helpSheet() {
var rows = available().map(function (command) {
return (
"