Files
LLeMbas/src/lembas/web/templating.py
T
HomerandClaude Opus 5 201281d616 New markup over an old stylesheet
Reported from a desktop browser: a stray close button beside the logo, badly
drawn, and a page that would not scroll. None of it was in the code that was
running -- it was the code the browser had not fetched.

The worker caches /static/ under a cache named for the release while the files
in it carried no version, and a page is fetched network-first. That only ever
worked because the worker used to seize every open tab the instant it installed
and wipe the old cache. 1.1.0 stopped it doing that, rightly -- it was swapping
stylesheets out from under a streaming reply -- and a momentary mismatch became
a permanent one: new markup over the previous release's CSS for as long as the
old worker lived. `.sidebar__close` had no rule there, so `.btn--icon` made it
inline-flex: visible everywhere, placed by nothing.

Every /static/ URL carries the release now, written by `templating.asset` and
precached by `sw.js:versioned` -- both halves, because caches.match compares the
query too and precaching the bare path would cache entries nothing requests.
Self-correcting: updating is enough.

The header was also a brand with a button appended and margin-left:auto doing
the placing, which holds exactly while that button is last. Two slots now: a
brand that shrinks and truncates, and a rail on the trailing edge.

Verified before changing anything: with the current stylesheet the button is
display:none at 1280 and, with thirty chats and forty messages, both scrollers
scroll. The first measurement said the thread did not -- that was
scroll-behavior: smooth reporting where it started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:16:24 +00:00

195 lines
7.6 KiB
Python

"""Jinja environment and the context every template receives."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
from lembas.services import metrics as metrics_service
from lembas.services import steps as steps_service
from lembas.services import tool_labels
from lembas.services.markdown import highlight_tokens
from lembas.services.reasoning import format_duration
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
# {{ message.reasoning_ms | duration }} -> "8 seconds"
templates.env.filters["duration"] = format_duration
# {{ message.usage_json | metrics }} -> a Metrics, so the finished bubble reads
# its numbers through the same object the live frames are built from.
templates.env.filters["metrics"] = metrics_service.from_message
def stable_hue(value: str) -> int:
"""A deterministic 0-359 hue for a string.
Used for generated model avatars so each model gets its own colour without
anyone choosing one, and the same model looks the same on every page and
after every restart. Python's hash() is salted per process, hence md5.
"""
import hashlib
digest = hashlib.md5(value.encode("utf-8"), usedforsecurity=False).digest()
return int.from_bytes(digest[:2], "big") % 360
templates.env.filters["stable_hue"] = stable_hue
# A user's own message: escaped here and marked up, so `@mentions` read as
# references rather than as punctuation. A filter rather than a context value
# because the message templates are included from four different handlers and
# every one of them would otherwise have to remember to pass it.
templates.env.filters["tokens"] = highlight_tokens
# What a tool call is called and what it looks like. Globals rather than
# context values because a message bubble is rendered from four different
# handlers -- pages, post_message, regenerate and the SSE follower -- and every
# one of them would otherwise have to remember to pass them. That is the exact
# trap `audio_service.template_flags` fell into.
templates.env.globals["tool_label"] = tool_labels.label_for
templates.env.globals["tool_icon"] = tool_labels.icon_for
def asset(path: str) -> str:
"""A static asset's URL, with the release stamped into it.
🚨 This is not cache politeness, it is what stops a release drawing itself
from two versions at once.
The service worker caches `/static/...` under a cache named for the
release, and a *page* is fetched network-first while its assets come from
that cache. So the moment the worker stops taking over open tabs the
instant it installs -- which it must, or it swaps the stylesheets under
somebody mid-reply -- the new HTML and the old CSS are served together and
the interface is subtly wrong until the worker is replaced. That shipped in
1.1.0: a close button intended for a phone drawer appeared, unstyled, on
every desktop, because the markup knew about it and the stylesheet did not.
A version in the URL settles it without anybody having to be careful: the
new HTML asks for a URL the old cache has never heard of, so it goes to the
network. The two can no longer disagree, whichever worker is in charge.
Not a hash of the file: `__version__` is the one thing that already moves
with every release, and a hash would mean reading every asset on every
render or a build step, and there is deliberately no build step here.
"""
return f"/static/{path.lstrip('/')}?v={__version__}"
templates.env.globals["asset"] = asset
# A finished reply as the sequence of steps it was. A global for exactly the
# reason the two above are, and it is why turning the bubble into a sequence
# needed no change in `pages.py`, `post_message`, `regenerate` or the `done`
# frame -- all four of which render `chat/_message.html`.
templates.env.globals["message_steps"] = steps_service.for_message
class _Brand:
"""Whose instance this is, as a Jinja global.
A **global** and not a context value, because `render()` has no database
session and four render paths never reach it at all -- the login page, the
error pages, the offline page and the SSE fragments. Threading it through
every one of those would still leave the ones that bypass `render()`.
A proxy rather than the snapshot itself, because a global is bound once at
import and the snapshot changes when an administrator saves. Every attribute
goes through `branding.snapshot()`, which is a process-level cache: one
query per process, and one after each save.
"""
def __getattr__(self, name: str):
from lembas.services import branding
return getattr(branding.snapshot(), name)
templates.env.globals["brand"] = _Brand()
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
Only ever a first guess: the inline script in base.html corrects it from
localStorage before first paint. Getting it close server-side is what stops
a signed-in user seeing a flash of the wrong theme on every navigation.
Validated against the themes that actually exist rather than against a
hard-coded pair, or a custom theme would be stored on the account, refused
here, and rendered as Moria on every page load until localStorage corrected
it -- a flash on every navigation, which is what this function exists to
prevent.
"""
from lembas.services import branding
if user is not None:
chosen = (user.settings_json or {}).get("theme")
if chosen in branding.snapshot().theme_ids:
return chosen
return settings.default_theme
def resolve_layout(user: User | None) -> str:
"""Stored panel widths as a `style` value for <html>, or "".
Same shape as the theme and for the same reason: a first guess, corrected
from localStorage before first paint. This is what carries a dragged width
to a second browser, where localStorage has nothing to say.
Re-clamped on the way out rather than trusted from the column. The bounds
could have tightened since it was stored, and a width outside them is a
panel somebody cannot see well enough to drag back.
"""
if user is None:
return ""
from lembas.api.preferences import LAYOUT_BOUNDS
parts = []
for name, raw in ((user.settings_json or {}).get("layout") or {}).items():
bounds = LAYOUT_BOUNDS.get(str(name))
if bounds is None:
continue
try:
value = min(max(int(float(raw)), bounds[0]), bounds[1])
except (TypeError, ValueError):
continue
parts.append(f"{name}:{value}px")
return ";".join(parts)
def render(
request: Request,
template: str,
context: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Render a template with the globals every page expects.
Using this instead of templates.TemplateResponse directly is what
guarantees `user` and `theme` are always defined, so templates never need
to guard against a missing variable.
"""
user = getattr(request.state, "user", None)
payload: dict[str, Any] = {
"request": request,
"user": user,
"theme": resolve_theme(user),
"layout": resolve_layout(user),
"version": __version__,
"allow_signup": settings.allow_signup,
}
payload.update(context or {})
return templates.TemplateResponse(request, template, payload, **kwargs)