From 28390095a9dbafc74986f4938084926cb8fe1af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Fri, 25 Sep 2026 13:39:35 +0000 Subject: [PATCH] A phone, and how much of this could not be used on one The sidebar was a 280px panel laid over the page below the phone breakpoint, opened from first paint, with the only control that closed it underneath it -- and that control existed on /chat and on none of the seven other pages carrying a sidebar, Settings included. It starts closed at that width now, slides, dims the page behind it, and closes by tapping beside it, by Escape, or by its own button, which is inside the drawer where it can be reached. Everything a finger has to hit was 36px, or 28 for renaming a chat, every action on a message and every panel's close button. Raising --control-h under a coarse pointer is the only fix that reaches all forty of them, which is what that token is for. The row and message actions were also hover-only, so on a phone they did not exist at all. Installing: the splash and the browser chrome follow the instance's theme rather than always being Moria's near-black; there are screenshots, so the install offer is a dialog rather than a one-line bar; a new release no longer takes over a page somebody is reading; the notification badge is a silhouette rather than a grey square; and a browser rotating its own subscription no longer ends notifications for good. Every request now says it is happening -- nothing did before, so anything slower than a few milliseconds looked like a click that had not registered. A chat can be archived. The column has been filtered on in four places since folders arrived and written by nothing, which is what made it look built. chat.css may contain media queries. The ban protected the composer toolbar from being "fixed" with a breakpoint; that guarantee is asserted directly now, and the old test would have passed a version of the file that wrapped the toolbar without one. scripts/shoot.py is the instrument all of this was found with: it renders a page through TestClient into a real headless browser at a real size and refuses to run if an asset URL was left pointing at testserver. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 91 +++++ assets/badge-72.png | Bin 0 -> 654 bytes scripts/build_artwork.py | 24 ++ scripts/shoot.py | 372 ++++++++++++++++++ src/lembas/__init__.py | 2 +- src/lembas/api/admin.py | 28 ++ src/lembas/api/chats.py | 33 ++ src/lembas/api/pages.py | 82 +++- src/lembas/services/harness.py | 4 + src/lembas/services/prompts.py | 49 +++ src/lembas/web/static/css/admin.css | 74 +++- src/lembas/web/static/css/app.css | 344 ++++++++++++++-- src/lembas/web/static/css/chat.css | 139 +++++-- src/lembas/web/static/css/tokens.css | 123 +++++- src/lembas/web/static/img/badge-72.png | Bin 0 -> 654 bytes .../web/static/img/screenshot-narrow.png | Bin 0 -> 35789 bytes src/lembas/web/static/img/screenshot-wide.png | Bin 0 -> 58030 bytes src/lembas/web/static/js/app.js | 187 ++++++++- src/lembas/web/static/js/sw.js | 85 +++- src/lembas/web/static/js/ui.js | 20 +- .../web/templates/admin/_connection_row.html | 16 +- src/lembas/web/templates/admin/prompts.html | 2 +- .../web/templates/admin/suggestions.html | 3 +- src/lembas/web/templates/agents/_layout.html | 1 + src/lembas/web/templates/base.html | 38 +- src/lembas/web/templates/chat/index.html | 5 +- src/lembas/web/templates/folders/edit.html | 1 + src/lembas/web/templates/library/_layout.html | 1 + src/lembas/web/templates/library/_share.html | 10 +- .../web/templates/messages/_history.html | 6 +- src/lembas/web/templates/messages/index.html | 7 +- .../web/templates/partials/_chat_link.html | 14 + .../templates/partials/_sidebar_toggle.html | 19 + .../web/templates/partials/_sidebar_tree.html | 22 ++ .../web/templates/partials/sidebar.html | 47 +++ src/lembas/web/templates/reports/_layout.html | 1 + .../web/templates/schedules/_layout.html | 1 + src/lembas/web/templates/settings.html | 23 +- tests/test_archive.py | 110 ++++++ tests/test_chat.py | 95 ++++- tests/test_connection_headers.py | 85 ++++ tests/test_layout_bounds.py | 47 +++ tests/test_pwa.py | 99 +++++ tests/test_sidebar_drawer.py | 115 ++++++ 44 files changed, 2290 insertions(+), 135 deletions(-) create mode 100644 assets/badge-72.png create mode 100644 scripts/shoot.py create mode 100644 src/lembas/web/static/img/badge-72.png create mode 100644 src/lembas/web/static/img/screenshot-narrow.png create mode 100644 src/lembas/web/static/img/screenshot-wide.png create mode 100644 src/lembas/web/templates/partials/_sidebar_toggle.html create mode 100644 tests/test_archive.py create mode 100644 tests/test_connection_headers.py create mode 100644 tests/test_sidebar_drawer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f9e14..eb4838e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,97 @@ for 1.0.0 have something to be assembled from. ## Unreleased +## 1.1.0 + +Mostly about using this on a phone, where it turns out a good deal of it could +not be used at all. + +### The sidebar on a phone + +- Fixed: **the sidebar opened over the page on every phone, and the button that + closes it was underneath it.** Below a phone width the sidebar is a 280px + panel laid over the page; nothing ever closed it, and the only control that + could was in the bar behind it. It now starts closed at that width, slides in + when you ask for it, dims the page behind it, and closes by tapping beside it, + by Escape, or by its own button — which is inside the drawer, where you can + reach it. +- Fixed: **seven of the eight pages with a sidebar had no way to show or hide it + at all.** Only the chat page ever had that button. Settings, Messages, + Reports, Scheduled, Library, Connections and a folder's own page did not — + which on a phone meant arriving at a page already covered by a panel with + nothing to do about it. Settings is where the Install and Notifications + buttons live, so this was also why they were hard to reach. +- The toggle no longer claims the sidebar is open when it is not, which matters + to anyone using a screen reader. + +### Anything you tap + +- **Every control is now at least 44px on a touch screen**, instead of 36px — + or 28px for the small ones, which included renaming and deleting a chat, all + seven actions on a message, and every panel's close button. The dismiss button + on a notification had no size of its own at all and was about 18 by 7 pixels. +- Fixed: **renaming or deleting a chat, and copying, editing, regenerating or + reading aloud a message, were impossible on a phone.** All of them appeared on + hover, and there is no hover on a phone; tapping the row simply opened it. +- Fixed: **the settings tabs scrolled sideways with nothing to say so**, hiding + Appearance, Memory and Security off the right-hand edge of a phone screen. + There is a fade at the edge now, and a flick lands on a tab. +- Installed on an iPhone, the page ran underneath the clock and the home + indicator. It no longer does. + +### Installing it + +- The install prompt now offers the richer dialog rather than the terse bar, and + a long press on the icon offers New chat, Messages and Scheduled. +- Fixed: **a light-themed instance installed to a phone showed a near-black + splash screen and then opened parchment**, and every page load flashed dark + browser chrome before the stylesheet had run. Both follow the theme now. +- Fixed: **a new version used to take over pages you were reading**, swapping + the stylesheets under an open tab while it emptied the cache they came from. + It waits and offers you a reload instead. +- Fixed: the small mark beside a notification on Android was a solid grey + square, because the icon it used has no transparency to be cut from. +- Fixed: notifications silently stopped working for good if the browser ever + replaced its own subscription, which browsers do. +- Pages start loading a little sooner, and the two icons a launcher actually + crops are now kept for offline use. + +### Things that move + +- **Every request the application makes now says it is happening**, with a thin + bar across the top of the window. Nothing did before, so anything slower than + a few milliseconds looked like a click that had not registered. +- The thinking indicator turns rather than fading, so a model that is working + and one that has stopped no longer look alike. +- Dialogs, the drawer and the panels arrive and leave rather than appearing; + buttons answer a press; cards lift under the pointer. All of it stops if you + have asked your system for reduced motion. + +### Archiving + +- **A chat can be archived** — out of the list, into a group at the bottom of the + sidebar, and back again whenever you like. The setting behind this has existed + and been honoured since folders arrived; nothing had ever been able to switch + it on. + +### Smaller things + +- **Extra headers can be set on a connection.** They were sent with every + request already and no form could write them, so OpenRouter's attribution + headers were documented and unreachable. +- A model is no longer told that it will hear when a background job finishes on + instances where that notification is switched off. +- The guidance for asking you a question can now be edited like every other + piece of the prompt. It was the only one that could not be. +- Several controls that a screen reader announced as nothing now have names, and + two lists that claimed to be tab strips now describe themselves honestly. +- Borders resolve through a token like every other value, so a theme can change + one. They were a literal `1px` in about ninety places, which was the largest + patch of hard-coded value left in the stylesheets. +- `chat.css` may now contain media queries. It was forbidden them, for a good + reason that had stopped applying: what the ban protected is asserted directly + now, which is both narrower and stronger. + ## 1.0.4 Six things that looked like they worked. Five of them were found by reading the diff --git a/assets/badge-72.png b/assets/badge-72.png new file mode 100644 index 0000000000000000000000000000000000000000..ac82cbef7e96e2959374514c2756398ed70788de GIT binary patch literal 654 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!Y)RhkE)4%caKYZ?lNlJ8oIPC} zLn`LHy=9v%k|=WYWASxW77-m24MA5&lU9yf9A-`WbJs4F68OW^G%aau9fPmnCDym0 zOlE?Y*jPlBngm$lIi#39C-l~H8%q^LZoXOl{N|&7r&2A8=N~rSY<$>DbLo^I1Yx`8 z3xiyPpyzXyt!LgizO3wGW^3p^U}11RKHU3!7|#WUb4&Uhj|8h-c_hz)+m}AKGx0H> z-yY!ewC<&83*$P*)3#HX3s!xxy2IFf;ILdbV+DK5-4AAqo2(hXc~mLAT;hC3+4!*H za(f2$M$7iO!Ip7VzIhGK2iT7n#krrq(_!b(dcU91UzOMTn4eYJRA!S#$>Ts(QB}Em z4YwJeo|zIiojXB)gHXVQsaDU{Kb9(SzO<+u5=h##HX56fC$cw(nI=A3l^yHH3mYWy9XzH_k-aGT=oZ>Y1ME`@Y z#b1Pa-kzg0T}9S#bH88ZY@waaWhd9mc bytes: return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180) +def build_badge() -> bytes: + """The small mark beside a notification in the Android status bar. + + A badge is used as a *mask*: the device keeps the alpha channel and throws + every colour away. So this is the leaf as a solid silhouette on nothing -- + no gradients, no rim, no veins, none of which would survive, and a plate + behind it least of all. The application used `icon-192.png` here, which is + opaque to its edges, so what Android drew was a grey square. + + 72px because that is the size Android asks for, and small enough that the + blade alone is the only part that still reads. + """ + return _rasterise( + f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" + role="img" aria-label="LLeMbas"> + + +""", + 72, + ) + + def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: """One jagged ridge line spanning the full width.""" rng = random.Random(seed) @@ -564,6 +587,7 @@ BUILDERS = { "icon-512.png": build_icon_512, "icon-maskable-512.png": build_icon_maskable, "apple-touch-icon-180.png": build_apple_touch_icon, + "badge-72.png": build_badge, } diff --git a/scripts/shoot.py b/scripts/shoot.py new file mode 100644 index 0000000..1aa41b5 --- /dev/null +++ b/scripts/shoot.py @@ -0,0 +1,372 @@ +"""Render LLeMbas pages in a real browser, at a real size. + +Run it: + + python scripts/shoot.py OUTDIR [/chat,/settings] # measure + capture + python scripts/shoot.py OUTDIR --manifest-screenshots # the two the + # manifest wants + +Needs a `chromium` on PATH and the development dependencies installed. It is a +development instrument, like the Node DOM stub the JavaScript is driven under +and like `fetch_vendor.py` -- it is not imported by the application and nothing +in `src/` knows it exists. + +Not a test runner: an instrument. It renders a page through TestClient, rewrites +every asset URL to a file:// path, and refuses to continue if even one is left +pointing at `testserver` -- because the last harness that did this silently +measured an unstyled document and reported all five tab panels visible at once. +A dramatic finding that was entirely an artefact of a rewrite matching nothing. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SRC = REPO / "src" +sys.path.insert(0, str(SRC)) + +STATIC = SRC / "lembas/web/static" +CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser") + +# Routes that are served by the app rather than mounted, so the rewrite has to +# fetch them rather than point at a file that does not exist. +ROUTE_ASSETS = {"/branding.css": "branding.css", "/sw.js": "sw.js"} + +MEASURE = """ + +""" + + +def build_client(): + import lembas.config as config_mod + + tmp = Path(tempfile.mkdtemp(prefix="lembas-shoot-")) + config_mod.settings.data_dir = tmp + config_mod.settings.secret_key = "x" * 43 + + from fastapi.testclient import TestClient + + from lembas.db.session import init_db, session_scope + from lembas.main import create_app + + init_db() + app = create_app() + client = TestClient(app) + client.post( + "/auth/register", + data={"name": "Frodo", "email": "f@example.com", "password": "mellonmellon"}, + follow_redirects=False, + ) + + from lembas.db.models import Connection, Model + + with session_scope() as db: + connection = Connection( + name="local", base_url="http://127.0.0.1:1", api_key_encrypted="" + ) + db.add(connection) + db.flush() + for name in ("gemma4-moe", "qwen3-coder"): + db.add(Model(connection_id=connection.id, model_id=name, display_name=name)) + return client + + +def rewrite(html: str, client, assets: Path) -> str: + """Point every asset at a file on disk, and prove none was missed.""" + for route, name in ROUTE_ASSETS.items(): + response = client.get(route) + if response.status_code == 200: + (assets / name).write_text(response.text) + + html = re.sub( + r'(?:http://testserver)?/static/([^"\'?\s>]+)(\?[^"\'\s>]*)?', + lambda m: f"file://{STATIC}/{m.group(1)}", + html, + ) + html = re.sub( + r'(?:http://testserver)?/branding\.css(\?[^"\'\s>]*)?', + f"file://{assets}/branding.css", + html, + ) + + # Fail loudly, and only about things that decide how the page LOOKS: every + # `src`, and `href` on a . An `href` on an anchor is a destination, + # not an asset -- flagging those makes the guard cry wolf on every page and + # a guard nobody believes is worse than none. + leftovers = re.findall(r']*\bhref="([^"]+)"', html) + leftovers += re.findall(r'\bsrc="([^"]+)"', html) + blocking = [ + url + for url in leftovers + if url.startswith(("/", "http://testserver")) + and not url.startswith(("/branding/", "/manifest", "/sw.js")) + ] + if blocking: + raise SystemExit( + "UNREWRITTEN ASSET URLS -- this would measure an unstyled document: " + f"{sorted(set(blocking))[:8]}" + ) + + # The one-time notifications offer is a modal over the very page we came + # to measure, and it is gated on a localStorage key. Set it in the head, so + # it runs before the deferred script that reads it. + quiet = ( + "" + ) + return html.replace("", quiet + MEASURE + "", 1) + + +def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict: + """One page, at one size, in one theme. + + The page is rendered inside an ' + "
" + "" + ) + + shot = outdir / f"{slug}.png" + common = [ + CHROMIUM, "--headless", "--no-sandbox", "--disable-gpu", + "--allow-file-access-from-files", "--hide-scrollbars", + "--force-device-scale-factor=1", + f"--window-size={max(width, 520)},{height + 40}", + "--virtual-time-budget=4000", + ] + subprocess.run(common + [f"--screenshot={shot}", f"file://{frame}"], + capture_output=True, timeout=120) + dom = subprocess.run(common + ["--dump-dom", f"file://{frame}"], + capture_output=True, text=True, timeout=120).stdout + + match = re.search(r'id="__measurements">(.*?)', dom, re.S) + if not match or not match.group(1).strip(): + raise SystemExit(f"no measurements for {slug} -- the frame did not report") + data = json.loads(match.group(1)) + if "error" in data: + raise SystemExit(f"{slug}: {data['error']}") + data["page"] = slug + if data["innerW"] != width: + raise SystemExit( + f"{slug}: measured a {data['innerW']}px viewport, asked for {width}px" + ) + return data + + +# The two the manifest asks for. Without them Chrome on Android falls back to +# the one-line mini-infobar instead of the install dialog with a name, an icon +# and a picture in it -- which is the difference between an install somebody +# chooses and one they dismiss without reading. +MANIFEST_SHOTS = ( + ("screenshot-narrow.png", 390, 844, "narrow"), + ("screenshot-wide.png", 1280, 800, "wide"), +) + + +def manifest_screenshots(client, outdir: Path) -> None: + """Capture the two, straight into static/img/ where the manifest names them. + + A browser capture rather than something `build_artwork.py` draws: the point + of a screenshot is that it is what the application actually looks like, and + an illustration of what it looks like is the one thing it must not be. + """ + try: + from PIL import Image + except ImportError: # pragma: no cover - design-time tool + raise SystemExit("pillow is needed to crop the frame off a screenshot") from None + + for name, width, height, _form in MANIFEST_SHOTS: + shoot(client, "/chat", width, height, "moria", outdir) + slug = f"chat-moria-{width}x{height}.png" + target = STATIC / "img" / name + # Cropped to the iframe, which sits at the origin of a zero-margin + # wrapper. The capture is of the *outer* document, so without this the + # screenshot carries the harness's own readout along its bottom edge + # and a strip of grey beside it -- and a manifest screenshot is the one + # picture of this application most people will ever see. + with Image.open(outdir / slug) as shot: + shot.crop((0, 0, width, height)).save(target) + print(f"wrote {target.relative_to(REPO)}") + + +def main() -> None: + if not CHROMIUM: + raise SystemExit("no chromium") + + if "--manifest-screenshots" in sys.argv: + outdir = Path(sys.argv[1]) if len(sys.argv) > 2 else Path(tempfile.mkdtemp()) + outdir.mkdir(parents=True, exist_ok=True) + manifest_screenshots(build_client(), outdir) + return + outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/lembas-shoot/out") + outdir.mkdir(parents=True, exist_ok=True) + + paths = sys.argv[2].split(",") if len(sys.argv) > 2 else ["/chat", "/settings"] + sizes = [(390, 844), (360, 640), (1280, 800)] + themes = ["moria", "shire"] + + client = build_client() + results = [] + for path in paths: + for width, height in sizes: + for theme in themes: + results.append(shoot(client, path, width, height, theme, outdir)) + + (outdir / "results.json").write_text(json.dumps(results, indent=2)) + for r in results: + flags = [] + if r["documentScrolls"]: + flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})") + if r["scrollsSideways"]: + flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})") + if r["overflowCount"]: + flags.append(f"overflow:{r['overflowCount']}") + if r["smallCount"]: + flags.append(f"small-targets:{r['smallCount']}") + print(f"{r['page']:44} {' '.join(flags) or 'clean'}") + + +if __name__ == "__main__": + main() diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index ca22f10..6dddad1 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__ = "1.0.4" +__version__ = "1.1.0" diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py index 7bd227c..993a5cd 100644 --- a/src/lembas/api/admin.py +++ b/src/lembas/api/admin.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from datetime import UTC, datetime from fastapi import APIRouter, Form, HTTPException, Request, Response, status @@ -103,6 +104,25 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s ) +# Header names are a narrow set on purpose: a newline would let one field write +# a second header, and a colon in a name splits it. Anything outside it is +# dropped rather than repaired -- a header nobody can see the effect of is worse +# than one that is visibly missing. +_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$") + + +def _parse_headers(raw: str) -> dict[str, str]: + """`Name: value` per line, into the dict the client sends verbatim.""" + headers: dict[str, str] = {} + for line in (raw or "").splitlines()[:20]: + name, _, value = line.partition(":") + name = name.strip() + value = value.strip()[:500] + if name and value and _HEADER_NAME.match(name): + headers[name] = value + return headers + + @router.post("/connections") async def create_connection( db: Db, @@ -145,6 +165,7 @@ async def update_connection( enabled: bool = Form(False), unload_url: str = Form(""), unload_method: str = Form("POST"), + extra_headers: str = Form(""), ) -> Response: connection = _connection(db, connection_id) connection.name = name.strip()[:120] or connection.name @@ -157,6 +178,13 @@ async def update_connection( method = unload_method.strip().upper() connection.unload_method = method if method in ("GET", "POST") else "POST" + # `extra_headers_json` has been sent with every request to this endpoint + # since it was added and written by no form in the application, so its one + # documented use -- OpenRouter wants an `HTTP-Referer` and an `X-Title` -- + # was unreachable. One `Name: value` per line, because a JSON textarea asks + # somebody to get braces right in a settings screen. + connection.extra_headers_json = _parse_headers(extra_headers) + submitted = api_key.strip() if submitted and submitted != UNCHANGED_SENTINEL: connection.api_key_encrypted = encrypt(submitted) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 127185f..5f66422 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -1987,6 +1987,21 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str folder = db.get(Folder, wanted) if wanted else None chat.folder_id = folder.id if folder is not None and folder.user_id == user.id else None + # Out of the way, and reversible. + # + # `Chat.archived` has been filtered on in four places since folders arrived + # and written by nothing anywhere -- so the hiding worked, the archiving + # did not, and the column read as a built feature to anyone who grepped for + # it. Here rather than as its own endpoint because it is a property of the + # chat, exactly like its title and its folder, and `update_chat` already + # reads the raw form for the reason this field needs too: absent must mean + # "leave it alone" and "0" must mean "put it back". + archived_changed = False + if "archived" in form: + wanted = str(form["archived"]).strip() not in ("", "0", "false") + archived_changed = wanted != chat.archived + chat.archived = wanted + # The mode is the one agent field that changes mid-chat: it decides what # gets asked about, not what the conversation is. if "agent_mode" in form: @@ -2100,6 +2115,24 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str return HTMLResponse( templates.get_template("chat/_title_oob.html").render({"chat": chat}) ) + + # Archiving moves a row out of one group and into another, so the sidebar + # has to be re-rendered -- and it cannot be, from a 204. htmx's own config + # is `{code: "204", swap: false}`, so a control aimed at `#sidebar-tree` + # with this endpoint's usual answer sets the column and then does visibly + # nothing at all, which is this codebase's signature failure rather than a + # new one. The same fragment and the same `oob` the sidebar switch returns, + # for the same reason: New chat lives above the tree and comes along out of + # band. + if archived_changed: + from lembas.api.pages import sidebar_context + + return templates.TemplateResponse( + request, + "partials/_sidebar_tree.html", + {"chat": None, "user": user, "oob": True, **sidebar_context(db, user)}, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 20cd27d..efbdce4 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -10,6 +10,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser +from lembas.config import settings from lembas.db.models import ( KIND_CHAT, KIND_MESSAGES, @@ -42,6 +43,17 @@ router = APIRouter(tags=["pages"]) THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"} +def _instance_colour(brand) -> str: + """The background this instance paints before anything has loaded. + + A custom theme sets `bg` itself; otherwise the built-in it inherits from + decides, which is what `data-base` means everywhere else. Falls back to + Moria rather than raising -- a splash screen is not worth a 500. + """ + theme = brand.theme(settings.default_theme) + return theme.tokens.get("bg") or THEME_COLOUR.get(theme.base, THEME_COLOUR["moria"]) + + def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: """Model lists and permissions every chat page needs. @@ -388,9 +400,29 @@ def sidebar_context(db: DBSession, user: User) -> dict: unfiled = list( db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc())) ) + # The same query with the one filter inverted, and no `pinned` in the order: + # a pinned chat that somebody archived is one they have said two opposite + # things about, and the more recent instruction is the one to honour. + archived = list( + db.scalars( + select(Chat) + .where( + Chat.user_id == user.id, + Chat.archived.is_(True), + Chat.temporary.is_(False), + Chat.kind.in_((kind,) if kind else KINDS), + ) + .order_by(Chat.updated_at.desc()) + ) + ) return { "folders": folders, "unfiled_chats": unfiled, + # Archived chats are NOT narrowed to unfiled ones: a chat inside a + # folder disappears from that folder when it is archived (the folder's + # own listing has always filtered them out), so without this it would + # have left one list and joined none. + "archived_chats": archived, # The shortcuts at the top of the sidebar. Here rather than in # `_chat_context`, where they used to be, for two reasons: they are # sidebar content and the fragment route that re-renders the sidebar has @@ -472,17 +504,61 @@ async def manifest(db: Db) -> Response: """ brand = branding_service.for_db(db) icons = brand.icon_paths + colour = _instance_colour(brand) return JSONResponse( { - "id": "/", + # Matches `start_url`. An id is only an identity key and need not be + # navigable, but "/" named a path that serves nothing but a redirect + # while the app started somewhere else, which reads as a mistake to + # anyone comparing the two. + "id": "/chat", "name": brand.name, "short_name": brand.name[:12], "description": brand.tagline or "A web UI for your language models.", + "lang": "en", + "dir": "ltr", "start_url": "/chat", "scope": "/", "display": "standalone", - "background_color": THEME_COLOUR["moria"], - "theme_color": THEME_COLOUR["moria"], + # Ordered best-first: a browser takes the first it understands and + # falls through to `display` if it understands none of them. + "display_override": ["standalone", "minimal-ui"], + "orientation": "any", + "categories": ["productivity", "utilities"], + # Opening a link belonging to this scope focuses the window that is + # already open rather than making a second one. + "launch_handler": {"client_mode": "navigate-existing"}, + # The launcher's long-press menu. Three destinations rather than + # ten: a menu nobody can read at a glance is a menu nobody opens. + "shortcuts": [ + {"name": "New chat", "url": "/chat"}, + {"name": "Messages", "url": "/messages"}, + {"name": "Scheduled", "url": "/scheduled"}, + ], + # Both follow whatever theme this instance is set up in. They were + # Moria's near-black regardless, so a parchment instance installed + # to a phone flashed a dark splash screen and then opened light -- + # and `THEME_COLOUR["shire"]` sat beside them, defined and read by + # nothing. The *instance* default and not the reader's own theme: + # a manifest is fetched without credentials unless the link asks + # otherwise, so there is nobody to ask. + "background_color": colour, + "theme_color": colour, + # Without these, Chrome on Android offers the one-line mini-infobar + # rather than the install dialog that carries a name, an icon and a + # picture -- which is the difference between an install somebody + # chooses and one they swipe away without reading. Captured from the + # running application by `scripts/shoot.py --manifest-screenshots`, + # because the one thing a screenshot must not be is a drawing of + # what the application looks like. + "screenshots": [ + {"src": "/static/img/screenshot-narrow.png", "sizes": "390x844", + "type": "image/png", "form_factor": "narrow", + "label": "A conversation on a phone"}, + {"src": "/static/img/screenshot-wide.png", "sizes": "1280x800", + "type": "image/png", "form_factor": "wide", + "label": "A conversation, with the sidebar beside it"}, + ], # 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 diff --git a/src/lembas/services/harness.py b/src/lembas/services/harness.py index 4389331..9a300f9 100644 --- a/src/lembas/services/harness.py +++ b/src/lembas/services/harness.py @@ -250,6 +250,7 @@ def context_variables( "agent_mode": "", "agent_rewound": "", "background": "", + "background_notify": "", "project_files": "", "agent_instructions": "", "agent_instructions_file": "", @@ -347,6 +348,9 @@ def _agent_values(db: DBSession, chat, user) -> dict[str, str]: # Non-empty only when commands may run in the background, which is what # gates the fragment telling the model so. "background": "on" if context.background else "", + # Its own gate, because the runner branches on it and the guidance + # above says a turn will arrive. See `tool.background_notify`. + "background_notify": "on" if context.background_notify else "", "max_rounds": str(context.limits.steps), # Blanked, which is what makes `core.rounds` vanish here: `steps` is a # runaway backstop and telling a model it has a budget of two hundred diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py index 09c47c6..d8ed04b 100644 --- a/src/lembas/services/prompts.py +++ b/src/lembas/services/prompts.py @@ -214,6 +214,14 @@ VARIABLES: tuple[Variable, ...] = ( "Non-empty when a command may run detached. Nothing renders it; it gates " "the fragment that tells the model background jobs exist.", ), + Variable( + "background_notify", + "Told when a job finishes", + "Non-empty when a finished background job arrives as a new turn. Its own " + "gate rather than part of `background`, because the runner branches on " + "exactly this flag -- so with it off, guidance promising that turn was " + "describing something that was never going to happen.", + ), Variable( "plan", "The current plan", @@ -1489,12 +1497,53 @@ BUILTIN: tuple[Fragment, ...] = ( "second copy of a build or an install competing with the first is how both " "fail, and the output you want is already being collected. Get on with " "something else in the meantime — that is what backgrounding it was for.\n" + "- Check on a job with job_output when you want to know where it got to." + ), + ), + Fragment( + key="tool.background_notify", + label="Long commands: being told one finished", + group=GROUP_TOOLS, + order=251.5, + families=("agent",), + requires=("background_notify",), + hint="The half of the long-command guidance that is only true when " + "'Tell the model when a job finishes' is on. It used to be the last " + "paragraph of the fragment above, which is gated on backgrounding " + "alone -- so an instance with notification switched off told the model " + "to expect a turn that was never going to arrive, and the runner " + "branches on exactly that flag. One fragment, two behaviours.", + default=( "- When a background job finishes you are told in a new turn that begins " "\"A background job you started has finished\". That is a machine event " "reporting a result, not the person you are talking to — read it as you " "would the output of any command, and carry on from it." ), ), + Fragment( + key="tool.ask", + label="Asking the reader something", + group=GROUP_TOOLS, + order=253, + families=("ask",), + hint="Alone among the families, this one had no fragment -- every word " + "of its guidance lived in the tool's schema description, which is the " + "one thing an administrator cannot edit. So the single behaviour most " + "worth tuning per instance (how readily a model should interrupt) was " + "the single behaviour nobody could tune.", + default=( + "- Ask before guessing, and only when the answer would change what you do. " + "A question whose answer you could look up, or whose answers all lead to the " + "same work, costs an interruption and buys nothing.\n" + "- Ask everything you need in ONE ask_user call. Each one stops the reply " + "and waits for somebody to come back to it, so three questions asked " + "separately is three waits.\n" + "- Always give options. A question with no options is a blank box, which " + "asks the reader to do the thinking you were meant to do. Say whether they " + "are alternatives or a set. Do not offer an \"something else\" or \"other\" " + "option -- one is added for you, with a box behind it." + ), + ), Fragment( key="tool.agent_edits", label="Changing a file", diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index 1e31391..b4bbf09 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -22,7 +22,10 @@ .page, .admin-page { - max-width: 48rem; + /* The same measure as the transcript, and the same token: a settings page and + a conversation are both prose, and having them differ by a rounding is the + kind of thing nobody reports and everybody notices. */ + max-width: var(--thread-max-width); margin: 0 auto; padding: var(--sp-6) var(--sp-5) var(--sp-12); } @@ -71,7 +74,7 @@ align-items: center; gap: var(--sp-1); padding: 0 var(--sp-5); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); background: var(--bg); flex: none; overflow-x: auto; @@ -87,7 +90,7 @@ contexts and would otherwise paint over it. */ position: sticky; top: 0; - z-index: 1; + z-index: var(--z-raised); } .tabs__tab { @@ -96,7 +99,7 @@ gap: var(--sp-2); height: var(--control-h-lg); padding: 0 var(--sp-4); - border-bottom: 2px solid transparent; + border-bottom: var(--border-w-thick) solid transparent; color: var(--ink-muted); font-size: var(--text-sm); font-weight: 500; @@ -146,7 +149,40 @@ a.tabs__tab { text-decoration: none; } .tabs__bar:has(input:nth-of-type(8):checked) ~ .tabs__body .tabs__panel:nth-of-type(8) { display: block; } -.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; } +.tabs__tab:has(:focus-visible) { outline: var(--outline-w) solid var(--accent); outline-offset: -2px; } + +/* + The bar scrolls sideways when the tabs do not fit, and said nothing about it. + + `scrollbar-width: none` is right -- a scrollbar under a row of tabs is ugly + and, on a touch device, invisible anyway -- but with nothing in its place the + overflow is undetectable. On a 390px phone the six tabs on /settings overflow + by about 190px, and the two that fall off the end are Memory and Security, + with Appearance only just reachable. Appearance is where both the Install and + the Notifications buttons live, so the effect was an install prompt nobody + could find on the device it exists for. + + A fade at the edge that is only painted when there is something behind it: + `scroll-driven` would be nicer and is not universal, so this is two gradients + pinned to the scrollport with `background-attachment: local`, which is the old + trick and works everywhere -- the `local` layers scroll with the content and + cover the `scroll` ones exactly when there is nothing more to see. +*/ +.tabs__bar { + background-image: + linear-gradient(to right, var(--bg) 40%, transparent), + linear-gradient(to left, var(--bg) 40%, transparent), + linear-gradient(to right, var(--scrim), transparent 1.5rem), + linear-gradient(to left, var(--scrim), transparent 1.5rem); + background-position: left center, right center, left center, right center; + background-repeat: no-repeat; + background-size: 1.5rem 100%; + background-attachment: local, local, scroll, scroll; + /* A tab is a destination, so a flick should land on one rather than between + two. */ + scroll-snap-type: x proximity; +} +.tabs__tab { scroll-snap-align: start; } /* A form's action row, and the space after the form it closes. @@ -173,7 +209,7 @@ a.tabs__tab { text-decoration: none; } /* --- Cards ----------------------------------------------------------------- */ .card { background: var(--surface); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-5); margin-bottom: var(--sp-4); @@ -199,7 +235,7 @@ a.tabs__tab { text-decoration: none; } gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4); - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); flex-wrap: wrap; } .card__header { @@ -235,7 +271,7 @@ a.tabs__tab { text-decoration: none; } gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; } .connection__footer { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4); - border-top: 1px solid var(--border); flex-wrap: wrap; } + border-top: var(--border-w) solid var(--border); flex-wrap: wrap; } .field--actions { margin-top: var(--sp-5); margin-bottom: 0; } /* --- Definition lists ------------------------------------------------------ */ @@ -273,7 +309,7 @@ a.tabs__tab { text-decoration: none; } display: flex; gap: var(--sp-1); flex-wrap: wrap; - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .filter-tab { display: inline-flex; @@ -281,7 +317,7 @@ a.tabs__tab { text-decoration: none; } gap: var(--sp-2); height: var(--control-h); padding: 0 var(--sp-3); - border-bottom: 2px solid transparent; + border-bottom: var(--border-w-thick) solid transparent; color: var(--ink-muted); font-size: var(--text-sm); font-weight: 500; @@ -315,7 +351,7 @@ a.tabs__tab { text-decoration: none; } gap: var(--sp-2); flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-bottom: 0; background: var(--bg-sunken); @@ -323,7 +359,7 @@ a.tabs__tab { text-decoration: none; } .bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); } .model-rows { - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: 0 0 var(--radius-lg) var(--radius-lg); overflow: hidden; background: var(--surface); @@ -333,7 +369,7 @@ a.tabs__tab { text-decoration: none; } align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .model-row:last-child { border-bottom: 0; } .model-row:hover { background: var(--surface-hover); } @@ -408,7 +444,7 @@ a.tabs__tab { text-decoration: none; } justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) 0; - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .model-list__item:first-child { padding-top: 0; } .model-list__item:last-child { border-bottom: 0; padding-bottom: 0; } @@ -425,7 +461,7 @@ a.tabs__tab { text-decoration: none; } .perm-row { align-items: flex-start; padding: var(--sp-3) 0; - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .perm-row:last-child { border-bottom: 0; } .perm-row input { margin-top: 0.15rem; } @@ -470,7 +506,7 @@ a.tabs__tab { text-decoration: none; } gap: var(--sp-3); align-items: baseline; padding: var(--sp-2) 0; - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); font-size: var(--text-sm); line-height: var(--leading-normal); } @@ -496,7 +532,7 @@ a.tabs__tab { text-decoration: none; } white-space: pre-wrap; overflow-wrap: anywhere; background: var(--code-bg); - border: 1px solid var(--code-border); + border: var(--border-w) solid var(--code-border); border-radius: var(--radius); font-family: var(--font-mono); font-size: var(--text-xs); @@ -523,7 +559,7 @@ a.tabs__tab { text-decoration: none; } padding: 0.05em 0.3em; border-radius: var(--radius-sm); background: var(--code-bg); - border: 1px solid var(--code-border); + border: var(--border-w) solid var(--code-border); } /* --- The permission modes, explained on the agents page ------------------- */ @@ -552,7 +588,7 @@ a.tabs__tab { text-decoration: none; } correct: `_rule_from_form` reads only the keys the chosen repeat mode uses. */ .schedule-repeat { - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-md); padding: var(--sp-4); margin-bottom: var(--sp-4); diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index afbbc43..fb73fc0 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -60,6 +60,11 @@ body:has(> .shell) { body { margin: 0; + /* The browser's own grey flash on tap is a rectangle around whatever box the + control happens to be, drawn in a colour no theme here chose. Removed in + favour of the `:active` states below, which are the application's own -- + removed *with* a replacement, never on its own. */ + -webkit-tap-highlight-color: transparent; font-family: var(--font-body); font-size: var(--text-base); line-height: var(--leading-normal); @@ -93,7 +98,7 @@ button, input, textarea, select { /* A single, consistent focus ring. Never remove it without a replacement. */ :focus-visible { - outline: 2px solid var(--accent); + outline: var(--outline-w) solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); } @@ -136,7 +141,7 @@ button, input, textarea, select { gap: var(--sp-2); height: var(--control-h); padding: 0 var(--control-px); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface-raised); color: var(--ink); @@ -249,7 +254,7 @@ button, input, textarea, select { width: 100%; height: var(--control-h); padding: 0 var(--control-px); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--bg-sunken); color: var(--ink); @@ -259,7 +264,7 @@ button, input, textarea, select { .textarea { width: 100%; padding: var(--sp-2) var(--control-px); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--bg-sunken); color: var(--ink); @@ -275,7 +280,7 @@ button, input, textarea, select { .select:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + box-shadow: var(--ring); } .input::placeholder, .textarea::placeholder { color: var(--ink-faint); } @@ -306,7 +311,7 @@ button, input, textarea, select { margin: 0 var(--sp-3) 0 0; padding: 0 var(--control-px); border: 0; - border-right: 1px solid var(--border); + border-right: var(--border-w) solid var(--border); background: var(--surface-hover); color: var(--ink); font: inherit; @@ -365,8 +370,8 @@ button, input, textarea, select { display: flex; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); - border: 1px solid var(--border); - border-left-width: 3px; + border: var(--border-w) solid var(--border); + border-left-width: var(--border-w-accent); border-radius: var(--radius); font-size: var(--text-sm); margin-bottom: var(--sp-4); @@ -436,7 +441,7 @@ button, input, textarea, select { display: flex; flex-direction: column; background: var(--bg-sunken); - border-right: 1px solid var(--border); + border-right: var(--border-w) solid var(--border); min-height: 0; } .sidebar[hidden] { display: none; } @@ -511,7 +516,7 @@ button, input, textarea, select { flex-direction: column; min-height: 0; background: var(--bg-sunken); - border-left: 1px solid var(--border); + border-left: var(--border-w) solid var(--border); } /* @@ -524,10 +529,14 @@ button, input, textarea, select { display: flex; align-items: center; gap: var(--sp-2); - height: var(--header-height); + /* The same sum as `.topbar`, for the same reason and so the three still line + up across the shell -- which is the whole point of this element. */ + height: calc(var(--header-height) + var(--safe-top)); + padding-top: var(--safe-top); flex: none; - padding: 0 var(--sp-3); - border-bottom: 1px solid var(--border); + padding-right: var(--sp-3); + padding-left: var(--sp-3); + border-bottom: var(--border-w) solid var(--border); } .panel-head__title { display: flex; @@ -578,7 +587,7 @@ button, input, textarea, select { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--code-bg); - border: 1px solid var(--code-border); + border: var(--border-w) solid var(--code-border); border-radius: var(--radius); font-family: var(--font-mono); font-size: var(--text-xs); @@ -610,7 +619,7 @@ button, input, textarea, select { /* So the resize handle can sit on the edge. */ position: relative; background: var(--bg-sunken); - border-left: 1px solid var(--border); + border-left: var(--border-w) solid var(--border); } /* The drag handle on a panel's left edge. Wider than it looks -- a one-pixel @@ -680,7 +689,7 @@ body.is-resizing .canvas__body { pointer-events: none; } align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); font-size: var(--text-xs); color: var(--ink-faint); line-height: var(--leading-normal); @@ -717,7 +726,7 @@ body.is-resizing .canvas__body { pointer-events: none; } min-height: 0; position: relative; background: var(--bg-sunken); - border-left: 1px solid var(--border); + border-left: var(--border-w) solid var(--border); } .canvas__inner { display: flex; @@ -760,7 +769,7 @@ body.is-resizing .canvas__body { pointer-events: none; } overflow-x: auto; scrollbar-width: thin; background: var(--bg-sunken); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .canvas__tab { display: inline-flex; @@ -769,7 +778,7 @@ body.is-resizing .canvas__body { pointer-events: none; } max-width: 14rem; /* Square at the bottom: a tab is attached to what it opens. */ border-radius: var(--radius-sm) var(--radius-sm) 0 0; - border: 1px solid transparent; + border: var(--border-w) solid transparent; border-bottom: 0; /* The strip's own bottom border is 1px; this covers it for the active tab without moving anything, so the row does not shift by a pixel on switch. */ @@ -831,7 +840,7 @@ body.is-resizing .canvas__body { pointer-events: none; } gap: var(--sp-2); flex: none; padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); } .canvas__body { @@ -866,7 +875,7 @@ body.is-resizing .canvas__body { pointer-events: none; } width: 100%; min-height: 24rem; padding: var(--sp-3); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); background: var(--code-bg); color: var(--ink); @@ -896,10 +905,15 @@ body.is-resizing .canvas__body { pointer-events: none; } display: flex; align-items: center; gap: var(--sp-3); - height: var(--header-height); + /* The bar is `--header-height` of *content* and whatever the device puts + above it. Installed on a phone the page runs under the status bar, so + without this the title and the sidebar toggle sit beneath the clock. */ + height: calc(var(--header-height) + var(--safe-top)); + padding-top: var(--safe-top); flex: none; - padding: 0 var(--sp-4); - border-bottom: 1px solid var(--border); + padding-right: max(var(--sp-4), var(--safe-right)); + padding-left: max(var(--sp-4), var(--safe-left)); + border-bottom: var(--border-w) solid var(--border); background: var(--bg); } .topbar__title { @@ -959,7 +973,7 @@ body.is-resizing .canvas__body { pointer-events: none; } gap: var(--sp-2); height: var(--control-h); padding: 0 var(--sp-1) 0 var(--sp-2); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; @@ -968,14 +982,14 @@ body.is-resizing .canvas__body { pointer-events: none; } .model-select:hover { border-color: var(--border-strong); } .model-select:focus-within { border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + box-shadow: var(--ring); } .model-select__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); } /* Collapsible settings panel, shared by chat settings and anything like it. */ .panel { flex: none; - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); background: var(--bg-sunken); max-height: 60vh; overflow-y: auto; @@ -1052,6 +1066,40 @@ body.is-resizing .canvas__body { pointer-events: none; } } .nav-item:hover .nav-item__actions, .nav-item:focus-within .nav-item__actions { opacity: 1; } +/* + There is no hover on a phone, and the row's own tap target is the link -- so + tapping a chat navigated to it and these never appeared at all. Renaming or + deleting a chat from a phone was not difficult, it was impossible. + + `hover: none` rather than a width: a touchscreen laptop at 1440px has the same + problem, and a narrow desktop window does not. +*/ +@media (hover: none) { + .nav-item__actions { opacity: 1; } +} + +/* The archived group. A `` is a real control, so it takes the row + treatment rather than the label's -- it is something you press. */ +.nav-group--archived > summary { + display: flex; + align-items: center; + gap: var(--sp-2); + cursor: pointer; + border-radius: var(--radius); + list-style: none; +} +.nav-group--archived > summary::-webkit-details-marker { display: none; } +.nav-group--archived > summary:hover { background: var(--surface-hover); color: var(--ink-muted); } +.nav-group__count { + margin-left: auto; + font-variant-numeric: tabular-nums; + color: var(--ink-faint); +} +/* Archived rows read as put away rather than as unavailable: dimmed until + they are looked at, never greyed out -- every action on them still works. */ +.nav-group--archived .nav-item { opacity: 0.72; } +.nav-group--archived .nav-item:hover, +.nav-group--archived .nav-item:focus-within { opacity: 1; } .nav-empty { padding: var(--sp-2); @@ -1075,7 +1123,7 @@ body.is-resizing .canvas__body { pointer-events: none; } width: 100%; max-width: 25rem; background: var(--surface); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-xl); padding: var(--sp-8); box-shadow: var(--shadow-lg); @@ -1096,7 +1144,7 @@ body.is-resizing .canvas__body { pointer-events: none; } .auth__footer { margin-top: var(--sp-5); padding-top: var(--sp-4); - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); text-align: center; font-size: var(--text-sm); color: var(--ink-muted); @@ -1133,16 +1181,102 @@ body.is-resizing .canvas__body { pointer-events: none; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* --- Small screens -------------------------------------------------------- */ +/* + --- The sidebar, and the two different things "closed" means --------------- + + Above the breakpoint the sidebar is a column and closed means "give the space + to the conversation". Below it the sidebar is an overlay and closed is the + *resting* state -- 280px of opaque drawer over a 390px screen is not a + navigation aid, it is the page gone. + + The `hidden` attribute cannot express that, because it is one value for both + widths: it was absent, so the drawer was open on every phone, on every page, + from the first paint -- with its own toggle underneath it. So the state is an + attribute on with three values, and the third is the one that matters: + + data-sidebar="open" shown at every width + data-sidebar="closed" hidden at every width + (absent) follow the width -- open wide, closed narrow + + Absent is what the server renders, because the server does not know how wide + the window is. See `setSidebar` in app.js, which is also why this panel does + not go through `setPanel` like the three on the other side. +*/ +:root[data-sidebar="closed"] .sidebar { + display: none; +} + +/* Above the breakpoint the drawer's own furniture has no job. Declared BEFORE + the media query that turns it back on: both rules are one class deep, so + source order is what decides, and this one written afterwards made the close + button `display: none` at every width -- including inside the open drawer, + which is the only place it exists for. */ +.sidebar__close { + display: none; +} + @media (max-width: 48rem) { .sidebar { position: fixed; inset: 0 auto 0 0; + /* Never the full width, and never wider than the screen: a drawer with no + page showing beside it gives nothing to tap to dismiss it, and reads as + a navigation *page* you have arrived at rather than a layer over the one + you were on. */ + width: min(var(--sidebar-width), 84vw); z-index: var(--z-panel); box-shadow: var(--shadow-lg); + /* Off-screen rather than `display: none`, so opening it is a movement the + eye can follow from the button that caused it. `visibility` is what + takes it out of the tab order while it is away -- `transform` alone + leaves every control in it focusable, just somewhere nobody can see. */ + transform: translateX(-100%); + visibility: hidden; + transition: transform var(--dur-3) var(--ease-out), + visibility var(--dur-3) var(--ease-out); } - /* Hiding it is the `hidden` attribute, forced to win at the top of this - file. There used to be a `[data-collapsed="true"]` rule here that nothing - ever set. */ + :root:not([data-sidebar="closed"]) .sidebar { + /* `display` must not be the thing that hides it here, or there is nothing + to animate. The attribute rule above is reversed for this width. */ + display: flex; + } + :root[data-sidebar="open"] .sidebar { + transform: none; + visibility: visible; + } + + /* Its own edges, once it is the thing against the side of the screen. */ + .sidebar__header, + .sidebar__actions, + .sidebar__scroll { + padding-left: max(var(--sp-3), var(--safe-left)); + } + .sidebar__footer { + padding-bottom: max(var(--sp-2), var(--safe-bottom)); + } + + .sidebar__close { + display: inline-flex; + margin-left: auto; + } + + /* Dismissible by tapping beside it. Without this the only way out is a + button, and a drawer you can only leave deliberately is one people close + by reloading. */ + :root[data-sidebar="open"] .sidebar-scrim { + opacity: 1; + pointer-events: auto; + } +} + +.sidebar-scrim { + position: fixed; + inset: 0; + z-index: calc(var(--z-panel) - 1); + background: var(--scrim); + opacity: 0; + pointer-events: none; + transition: opacity var(--dur-3) var(--ease-out); } /* --- Toasts ---------------------------------------------------------------- @@ -1166,8 +1300,8 @@ body.is-resizing .canvas__body { pointer-events: none; } align-items: flex-start; gap: var(--sp-3); padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4); - border: 1px solid var(--border); - border-left: 3px solid var(--accent); + border: var(--border-w) solid var(--border); + border-left: var(--border-w-accent) solid var(--accent); border-radius: var(--radius); background: var(--surface-raised); box-shadow: var(--shadow-lg); @@ -1184,21 +1318,31 @@ body.is-resizing .canvas__body { pointer-events: none; } .toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; } .toast__close { flex: none; + /* It had no height at all -- `font-size` and 0.15rem of side padding, which + is about 18x7px. The smallest target in the application, on the one control + somebody reaches for when they are already mildly annoyed. */ + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--control-h-sm); + height: var(--control-h-sm); border: 0; + border-radius: var(--radius-sm); background: none; color: var(--ink-faint); cursor: pointer; font-size: var(--text-lg); line-height: 1; - padding: 0 0.15rem; + padding: 0; } .toast__close:hover { color: var(--ink); } +.toast__action { flex: none; align-self: center; } /* --- Dialogs ---------------------------------------------------------------- gives focus trapping, Escape and page inertness for free. */ .dialog { - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-xl); background: var(--surface); color: var(--ink); @@ -1251,7 +1395,7 @@ body.is-resizing .canvas__body { pointer-events: none; } height: var(--control-h); max-width: 16rem; padding: 0 var(--sp-2); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--ink); @@ -1262,7 +1406,7 @@ body.is-resizing .canvas__body { pointer-events: none; } .picker__button:hover { border-color: var(--border-strong); } .picker__button[aria-expanded="true"] { border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + box-shadow: var(--ring); } .picker__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; } .picker__label { @@ -1280,7 +1424,7 @@ body.is-resizing .canvas__body { pointer-events: none; } right: 0; z-index: var(--z-dropdown); width: min(24rem, calc(100vw - var(--sp-8))); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: var(--shadow-lg); @@ -1336,7 +1480,7 @@ body.is-resizing .canvas__body { pointer-events: none; } margin: 0; min-width: 0; padding: var(--sp-2) var(--sp-3); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-w) solid var(--border); background: var(--bg-sunken); font-size: var(--text-xs); color: var(--ink-muted); @@ -1351,12 +1495,12 @@ body.is-resizing .canvas__body { pointer-events: none; } max-height: min(24rem, 50vh); overflow-y: auto; scrollbar-width: thin; - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); } .dialog__results .picker__list { max-height: none; } -.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); } +.picker__search { padding: var(--sp-2); border-bottom: var(--border-w) solid var(--border); } /* Small controls, declared here because this is the file every page loads. @@ -1433,3 +1577,119 @@ body.is-resizing .canvas__body { pointer-events: none; } font-size: var(--text-sm); color: var(--ink-faint); } + +/* --- Saying that something is happening ------------------------------------ + A three-pixel bar across the top of the window, above everything including + the panels, because it describes the whole page rather than any part of it. + + It never claims to know how far along it is. A request whose length is + unknown and a bar that fills at a constant rate is a lie that gets found out + on every slow request -- so this one travels, and stops when the answer + lands. `transform` only, so it costs no layout on a page that may be + streaming a reply at twelve frames a second underneath it. +*/ +.progress { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--border-w-accent); + z-index: var(--z-toast); + pointer-events: none; + opacity: 0; + transition: opacity var(--dur-2) var(--ease-out); +} +.progress.is-busy { opacity: 1; } +.progress span { + display: block; + height: 100%; + width: 40%; + border-radius: var(--radius-full); + background: linear-gradient(90deg, transparent, var(--leaf), transparent); + transform: translateX(-100%); +} +.progress.is-busy span { animation: progress-sweep var(--dur-slow) var(--ease-in-out) infinite; } +@keyframes progress-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + +/* --- Content that has not arrived yet --------------------------------------- + A shape where the thing will be, rather than a blank. Used with `aria-hidden` + on whatever is waiting, so a screen reader is not read a paragraph of + nothing. + + The shimmer is a moving gradient rather than an opacity pulse, because a list + of eight pulsing blocks all at the same phase reads as a fault. */ +.skeleton { + border-radius: var(--radius); + background: linear-gradient( + 90deg, + var(--surface) 0%, + var(--surface-hover) 50%, + var(--surface) 100% + ); + background-size: 200% 100%; + animation: skeleton-sweep var(--dur-slow) var(--ease-in-out) infinite; +} +.skeleton--row { height: var(--control-h); margin-bottom: var(--sp-1); } +.skeleton--line { height: var(--text-base); margin-bottom: var(--sp-2); } +.skeleton--short { width: 60%; } +@keyframes skeleton-sweep { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + +/* --- Press ---------------------------------------------------------------- + The tap highlight was removed in the reset, so something has to take its + place: a control that moves under the finger is the cheapest possible + confirmation that the tap landed, and the only one that works before the + request it started has answered. Kept small -- this is feedback, not an + animation somebody has to sit through. */ +.btn:active:not(:disabled), +.nav-item:active, +.tabs__tab:active { + transform: translateY(1px); +} +.btn { transition: background var(--transition-fast), border-color var(--transition-fast), + color var(--transition-fast), transform var(--dur-1) var(--ease-out); } + +/* --- Arrival --------------------------------------------------------------- + `@starting-style` plus `allow-discrete` is what lets a `display: none` + element animate in with no JavaScript at all and no class to add and remove. + Where it is unsupported the element simply appears, which is what it did + before. */ +.dialog { + opacity: 0; + transform: scale(0.97); + transition: opacity var(--dur-2) var(--ease-out), + transform var(--dur-2) var(--ease-spring), + overlay var(--dur-2) allow-discrete, + display var(--dur-2) allow-discrete; +} +.dialog[open] { opacity: 1; transform: none; } +@starting-style { + .dialog[open] { opacity: 0; transform: scale(0.97); } +} +.dialog::backdrop { + opacity: 0; + transition: opacity var(--dur-2) var(--ease-out), + overlay var(--dur-2) allow-discrete, + display var(--dur-2) allow-discrete; +} +.dialog[open]::backdrop { opacity: 1; } +@starting-style { + .dialog[open]::backdrop { opacity: 0; } +} + +/* A card lifts a little under the pointer -- only where there is a pointer, and + only where the card is something you can act on. */ +@media (hover: hover) { + a.card:hover, + .card--action:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + } +} +a.card, .card--action { transition: transform var(--dur-2) var(--ease-out), + box-shadow var(--dur-2) var(--ease-out); } diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 745931d..fd80060 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -22,11 +22,22 @@ gap: var(--sp-6); } +/* + The new-chat screen. + + Deliberately the only thing in the transcript that animates on arrival. + A message bubble must not: the steps container is replaced with `innerHTML` + up to twelve times a second while a reply streams, and the `done` frame + replaces the whole article -- so an entry animation on a bubble re-triggers + on every swap and what it produces is not an arrival, it is a flicker at + twelve hertz. This element renders once and is never swapped. +*/ .thread__intro { display: grid; place-items: center; gap: var(--sp-3); text-align: center; + animation: intro-rise var(--dur-3) var(--ease-out) both; padding: var(--sp-12) 0 var(--sp-6); } @@ -153,7 +164,7 @@ /* --- Reasoning ------------------------------------------------------------ */ .reasoning { margin: 0 0 var(--sp-3); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 70%, transparent); font-size: var(--text-sm); @@ -268,7 +279,7 @@ flex-direction: column; gap: var(--sp-1); padding: var(--sp-3) var(--sp-4); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink); @@ -277,7 +288,7 @@ transition: background var(--transition-fast), border-color var(--transition-fast); } .suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); } -.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.suggestion:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: 2px; } .suggestion__name { font-weight: 600; font-size: var(--text-sm); } .suggestion__note { @@ -347,7 +358,7 @@ .reasoning__body { padding: 0 var(--sp-3) var(--sp-3); margin-left: var(--sp-2); - border-left: 2px solid var(--border-strong); + border-left: var(--border-w-thick) solid var(--border-strong); padding-left: var(--sp-3); white-space: pre-wrap; color: var(--ink-muted); @@ -358,8 +369,48 @@ scrollbar-width: thin; } -/* Gentle pulse on the icon while thinking is still streaming. */ -.reasoning--live .reasoning__icon { animation: think-pulse 1.6s ease-in-out infinite; } +/* + While a model is thinking. + + This was an opacity fade on the icon, which at a glance is indistinguishable + from an icon that is simply a bit faint -- and "is it working or has it + stopped?" is the one question this element exists to answer. So it now turns + as well as breathes, and carries a ring that sweeps: rotation is the thing the + eye reads as *ongoing* rather than as decoration, and it is the difference + between a reply that is being written and one that has quietly died. + + Two animations on two elements rather than one compound transform, because the + icon is a `` of a shared sprite and the ring is a pseudo-element -- and + because `prefers-reduced-motion` should be able to stop the spin while leaving + the colour, which two separate declarations allow and one does not. + + No timer, no class to add or remove, nothing to clean up: it stops existing + when the element does, which is the same reason the animated ellipsis is a + `content` keyframe. +*/ +.reasoning--live .reasoning__icon { + animation: think-pulse var(--dur-slow) var(--ease-in-out) infinite, + think-turn calc(var(--dur-slow) * 2.5) linear infinite; + transform-origin: 50% 50%; +} +.reasoning--live .reasoning__label { position: relative; } +.reasoning--live .reasoning__label::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -2px; + height: var(--border-w); + background: linear-gradient(90deg, transparent, var(--leaf), transparent); + background-size: 50% 100%; + background-repeat: no-repeat; + animation: think-sweep calc(var(--dur-slow) * 1.5) var(--ease-in-out) infinite; +} +@keyframes think-turn { to { transform: rotate(360deg); } } +@keyframes think-sweep { + 0% { background-position: -60% 0; } + 100% { background-position: 160% 0; } +} @keyframes think-pulse { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } @@ -373,7 +424,7 @@ .tool-activity { margin: 0 0 var(--sp-3); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 70%, transparent); font-size: var(--text-sm); @@ -419,7 +470,7 @@ flex-direction: column; gap: 2px; padding-left: var(--sp-3); - border-left: 2px solid var(--border-strong); + border-left: var(--border-w-thick) solid var(--border-strong); min-width: 0; } .tool-result__title { @@ -511,7 +562,7 @@ gap: var(--sp-3); margin: var(--sp-3) 0; padding: var(--sp-4); - border: 1px solid var(--accent); + border: var(--border-w) solid var(--accent); border-radius: var(--radius-md); background: var(--surface); } @@ -538,7 +589,7 @@ } .interaction__question + .interaction__question { padding-top: var(--sp-4); - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); } .interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; } /* Stacked, one per line. A row of chips was fine while an option was two words @@ -556,7 +607,7 @@ align-items: flex-start; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); - border: 1px solid var(--border-strong); + border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); cursor: pointer; @@ -577,7 +628,7 @@ background: var(--surface-active); } .interaction__option:has(input:focus-visible) { - outline: 2px solid var(--accent); + outline: var(--outline-w) solid var(--accent); outline-offset: 2px; } @@ -618,7 +669,7 @@ align-items: center; min-height: var(--control-h); padding: 0 var(--sp-3); - border: 1px solid var(--border-strong); + border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); color: var(--ink-muted); @@ -630,7 +681,7 @@ background: var(--surface-active); color: var(--ink); } -.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } +.chip input:focus-visible + span { outline: var(--outline-w) solid var(--accent); outline-offset: 2px; } .interaction__detail { margin: 0; padding: var(--sp-3); @@ -773,6 +824,11 @@ } .msg:hover .msg__actions, .msg:focus-within .msg__actions { opacity: 1; } +/* Copy, regenerate, edit and read-aloud were hover-only, which on a phone means + they did not exist. See the same rule on `.nav-item__actions` in app.css. */ +@media (hover: none) { + .msg__actions { opacity: 1; } +} .msg__actions .is-copied { color: var(--success); } /* --- A turn nobody typed --------------------------------------------------- @@ -792,7 +848,7 @@ .msg--machine .msg__author { color: var(--ink-muted); font-weight: 500; } .msg--user.msg--machine .msg__body--plain { background: var(--bg-sunken); - border-inline-start: 2px solid var(--border-strong); + border-inline-start: var(--border-w-thick) solid var(--border-strong); border-start-start-radius: var(--radius-sm); border-end-start-radius: var(--radius-sm); color: var(--ink-muted); @@ -835,12 +891,12 @@ .msg__body blockquote { margin: 0 0 var(--sp-4); padding: var(--sp-1) var(--sp-4); - border-left: 3px solid var(--border-strong); + border-left: var(--border-w-accent) solid var(--border-strong); color: var(--ink-muted); font-style: italic; } -.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; } +.msg__body hr { border: 0; border-top: var(--border-w) solid var(--border); margin: var(--sp-5) 0; } .msg__body :not(pre) > code { font-family: var(--font-mono); @@ -848,7 +904,7 @@ padding: 0.13em 0.36em; border-radius: var(--radius-sm); background: var(--code-bg); - border: 1px solid var(--code-border); + border: var(--border-w) solid var(--code-border); } .msg__body table { @@ -860,7 +916,7 @@ overflow-x: auto; } .msg__body th, .msg__body td { - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); padding: var(--sp-2) var(--sp-3); text-align: left; } @@ -871,7 +927,7 @@ /* --- Code blocks ---------------------------------------------------------- */ .code-block { margin: 0 0 var(--sp-4); - border: 1px solid var(--code-border); + border: var(--border-w) solid var(--code-border); border-radius: var(--radius); background: var(--code-bg); overflow: hidden; @@ -881,7 +937,7 @@ font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-faint); - border-bottom: 1px solid var(--code-border); + border-bottom: var(--border-w) solid var(--code-border); background: color-mix(in srgb, var(--code-bg) 60%, var(--surface)); } .code-block__pre { @@ -947,7 +1003,7 @@ flex-direction: column; gap: var(--sp-1); padding: var(--sp-2); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-xl); background: var(--surface); transition: border-color var(--transition-fast), box-shadow var(--transition-fast); @@ -1157,7 +1213,7 @@ while the header over it sat --sp-3 in. The padding goes inside the row and the border stays on it, so the divider is still full-bleed -- which is what makes a stack of rows read as a list rather than as paragraphs. */ -.jobs__row { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); } +.jobs__row { padding: var(--sp-2) var(--sp-3); border-bottom: var(--border-w) solid var(--border); } .jobs__row:last-child { border-bottom: 0; } /* Which row's log is on screen. An inset shadow rather than a @@ -1275,7 +1331,7 @@ max-height: min(20rem, 45vh); overflow-y: auto; scrollbar-width: thin; - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: var(--shadow-lg); @@ -1286,7 +1342,7 @@ position: sticky; bottom: 0; padding: var(--sp-1) var(--sp-3); - border-top: 1px solid var(--border); + border-top: var(--border-w) solid var(--border); background: var(--surface-raised); color: var(--ink-faint); font-size: var(--text-xs); @@ -1321,7 +1377,7 @@ .sheet { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } .sheet td { padding: var(--sp-1) var(--sp-2); vertical-align: top; } .sheet td:first-child { white-space: nowrap; color: var(--ink-muted); width: 1%; } -.sheet tr + tr td { border-top: 1px solid var(--border); } +.sheet tr + tr td { border-top: var(--border-w) solid var(--border); } /* --- Folders -------------------------------------------------------------- */ .folder__row { padding-right: var(--sp-1); } @@ -1377,7 +1433,7 @@ align-items: center; gap: var(--sp-2); padding: var(--sp-2); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface); max-width: 20rem; @@ -1452,7 +1508,7 @@ align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface); font-size: var(--text-sm); @@ -1531,7 +1587,7 @@ display: inline-flex; flex: none; padding: 2px; - border: 1px solid var(--border); + border: var(--border-w) solid var(--border); border-radius: var(--radius-full); background: var(--bg-sunken); } @@ -1563,7 +1619,7 @@ box-shadow: var(--shadow-sm); } .segmented__option input:focus-visible + span { - outline: 2px solid var(--accent); + outline: var(--outline-w) solid var(--accent); outline-offset: 1px; } /* The sidebar's copy fills its column rather than sitting at its content @@ -1576,8 +1632,8 @@ .plan { margin: var(--sp-3) 0; padding: var(--sp-4); - border: 1px solid var(--border-strong); - border-left: 3px solid var(--accent); + border: var(--border-w) solid var(--border-strong); + border-left: var(--border-w-accent) solid var(--accent); border-radius: var(--radius-md); background: var(--surface); } @@ -1651,3 +1707,22 @@ padding: var(--sp-4) 0; min-height: 2.5rem; } + +/* The shape of the turns being fetched, at the width they will arrive in. */ +.history-sentinel__shape { + width: 100%; + max-width: var(--thread-max-width); + margin: 0 auto; + padding: 0 var(--sp-5); +} + +/* The mark first, then the question, then the line under it -- a tenth of a + second apart, which is enough to read as one movement rather than three + things appearing at once. */ +@keyframes intro-rise { + from { opacity: 0; transform: translateY(var(--sp-2)); } + to { opacity: 1; transform: none; } +} +.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; } +.thread__intro > *:nth-child(2) { animation-delay: 60ms; } +.thread__intro > *:nth-child(3) { animation-delay: 120ms; } diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css index d389b3c..08699a7 100644 --- a/src/lembas/web/static/css/tokens.css +++ b/src/lembas/web/static/css/tokens.css @@ -26,7 +26,6 @@ --text-lg: 1.125rem; --text-xl: 1.375rem; --text-2xl: 1.75rem; - --text-3xl: 2.25rem; --leading-tight: 1.25; --leading-normal: 1.6; @@ -42,7 +41,6 @@ --sp-8: 2rem; --sp-10: 2.5rem; --sp-12: 3rem; - --sp-16: 4rem; /* --- Radius & shadow -------------------------------------------------- */ --radius-sm: 4px; @@ -119,12 +117,88 @@ --z-handle: 10; --z-dropdown: 30; --z-panel: 40; - --z-overlay: 50; --z-toast: 60; --transition-fast: 120ms ease; --transition: 200ms ease; + /* --- Borders ----------------------------------------------------------- + A hairline was a literal `1px` in about ninety places, which made it the + largest category of hard-coded value left in the codebase -- and the one + thing a theme cannot currently change. */ + --border-w: 1px; + --border-w-thick: 2px; + --border-w-accent: 3px; + + /* The focus outline's own width. Not `--border-w-thick`, though they are the + same number today: an outline is drawn outside the box and takes no space, + a border is part of the box and does. Making one of them follow the other + means a theme that wants a heavier border gets a heavier focus ring too, + which is two decisions tied together by a coincidence. */ + --outline-w: 2px; + + /* --- Touch -------------------------------------------------------------- + A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is + 36 -- comfortable with a pointer and under every published minimum for a + finger -- so the coarse-pointer block at the foot of this file raises the + control tokens to this rather than patching components one at a time. + Raising the token is the only version that reaches all of them, and it is + what `--control-h` exists for. */ + --tap-min: 2.75rem; + + /* --- The window's own edges --------------------------------------------- + Installed on a phone, the page runs under the notch and the home + indicator: base.html asks iOS for `black-translucent`, which is what puts + it there, and `viewport-fit=cover` is what lets these resolve to anything + but zero. Declared here so no component spells `env()` out -- and so a + desktop browser, where all four are 0, costs nothing. */ + --safe-top: env(safe-area-inset-top, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + + /* --- Breakpoints -------------------------------------------------------- + A media query cannot read a custom property, so these cannot be *used* + here. They are declared anyway so the numbers have one home and a grep for + one lands somewhere that says what it means -- and + `tests/test_layout_bounds.py` refuses a width in any stylesheet that is not + declared here, so a fourth breakpoint invented in passing fails the suite + rather than joining the set unannounced. + + --bp-admin 44rem 704px a two-column reference row stacks + --bp-narrow 48rem 768px the sidebar becomes a drawer, and controls + grow to a thumb's size + --bp-wide 64rem 1024px the right-hand panels become overlays */ + --bp-admin: 44rem; + --bp-narrow: 48rem; + --bp-wide: 64rem; + + /* --- Motion ------------------------------------------------------------- + Durations and curves, so the `prefers-reduced-motion` block at the foot of + this file keeps covering everything by construction: a literal `1.6s` in a + component is a value that block can still neutralise, but one nobody can + tune. `--ease-out` is the one to reach for -- something arriving should + decelerate; `--ease-spring` overshoots slightly and belongs on a thing + that appears, never on a thing that moves under the pointer. */ + --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --dur-1: 120ms; + --dur-2: 200ms; + --dur-3: 320ms; + --dur-slow: 1.6s; + + /* --- Panel minimums ----------------------------------------------------- + `api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored + against an account and only two of them -- the two with a drag handle -- + had a `-min` token or a `min-width` to clamp with. The other two are not + draggable, so nothing in the interface could produce a bad value; but the + endpoint takes one from anybody signed in, `base.html` applies stored + widths to before first paint, and with no clamp a stored 800px + sidebar is one nothing in the application can drag back. */ + --sidebar-width-min: 12.5rem; + --inspector-width-min: 17.5rem; + /* The focus treatment, written once. Three components spelled it out. It resolves --accent-soft at the point of use, so it follows the theme even though it is declared above them. */ @@ -322,6 +396,37 @@ --ansi-bright-white: #453A2A; } +/* + --- Touch ----------------------------------------------------------------- + A pointer is precise and a finger is about 9mm across, so the same control + cannot be the right size for both. `--control-h` is 36px, which is comfortable + with a mouse and under every published minimum for a thumb; `--control-h-sm` + is 28px, which is a target most people miss. + + Raised here rather than patched per component, because there are upwards of + forty of them and the next one added would be 36px again. `--control-h` is + what every button, input and select resolves its height from, so one block + moves all of them -- which is the reason that token exists. + + Two conditions, either of which is enough. + + `(pointer: coarse)` is the honest one: it is the input device that decides how + big a target has to be, and a touchscreen laptop at 1440px has the same thumb + as a phone. But a layout below the phone breakpoint is a one-column, drawer- + navigated layout whatever is pointing at it -- there is room for bigger + controls and every reason to use it -- and that half is also the half a + headless browser can be made to prove, which is not nothing: a rule that can + only be checked by holding a phone is a rule that quietly rots. +*/ +@media (pointer: coarse), (max-width: 48rem) { + :root { + --control-h: var(--tap-min); + --control-h-sm: 2.25rem; + --control-px: var(--sp-4); + --control-px-sm: var(--sp-3); + } +} + /* Respect a stated preference for reduced motion everywhere, at once. */ @media (prefers-reduced-motion: reduce) { *, @@ -332,4 +437,16 @@ transition-duration: 0.01ms !important; scroll-behavior: auto !important; } + /* The motion tokens too, for anything that composes a duration rather than + declaring one -- a `transition: transform var(--dur-3)` is neutralised by + the rule above, but an `animation-delay` built from one is not. */ + :root { + --dur-1: 0.01ms; + --dur-2: 0.01ms; + --dur-3: 0.01ms; + --dur-slow: 0.01ms; + --transition-fast: 0.01ms; + --transition: 0.01ms; + --transition-slow: 0.01ms; + } } diff --git a/src/lembas/web/static/img/badge-72.png b/src/lembas/web/static/img/badge-72.png new file mode 100644 index 0000000000000000000000000000000000000000..ac82cbef7e96e2959374514c2756398ed70788de GIT binary patch literal 654 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 z

+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 literal 0 HcmV?d00001 diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 0b0d12b..41a4eb0 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -54,11 +54,20 @@ /* Installed, the browser's own chrome is the application's chrome, so it has to follow the theme too. Read from the stylesheet rather than repeating the hex here: tokens.css is the one place colours live. */ - var meta = document.querySelector('meta[name="theme-color"]'); - if (meta) { - var bg = getComputedStyle(document.documentElement) - .getPropertyValue("--bg").trim(); - if (bg) meta.setAttribute("content", bg); + var metas = document.querySelectorAll('meta[name="theme-color"]'); + var bg = getComputedStyle(document.documentElement) + .getPropertyValue("--bg").trim(); + if (bg) { + metas.forEach(function (meta) { + /* There are two of them, scoped by `prefers-color-scheme`, so that a + light instance is not painted dark before this file has run. Once it + has, the reader's *chosen* theme is the answer and the system's + preference is not -- somebody on the parchment theme inside a dark + desktop wants parchment. Dropping the `media` attribute is what makes + the choice win; leaving it would let the unchosen one apply. */ + meta.removeAttribute("media"); + meta.setAttribute("content", bg); + }); } /* The toggle names where it is going, not where it is. With more than two @@ -668,7 +677,51 @@ } } + /* --- The sidebar ------------------------------------------------------- + Its own pair of functions rather than a branch inside `setPanel`, because + it is the one panel whose *default* depends on the width of the window: + open beside the conversation on a desktop, closed over it on a phone. The + `hidden` attribute the other three use is a single value for both, which + is how the drawer came to be open on every phone with its own toggle + underneath it. + + `data-sidebar` on has a third state -- absent -- meaning "follow + the width", and absent is what the server renders, because the server + cannot know the width. Everything downstream is unchanged: `syncToggles` + still writes `aria-expanded` on every control pointing here, and the panel + still gets `lembas:toggle`. */ + var NARROW = "(max-width: 48rem)"; + + function sidebarOpen() { + var state = document.documentElement.dataset.sidebar; + if (state === "open") return true; + if (state === "closed") return false; + return !window.matchMedia(NARROW).matches; + } + + function setSidebar(open) { + var panel = document.querySelector("#sidebar"); + document.documentElement.dataset.sidebar = open ? "open" : "closed"; + syncToggles("#sidebar", open); + + /* Nothing behind an open drawer may be reached by the keyboard -- but only + while it *is* a drawer. Cleared whenever the query stops matching, and + cleared unconditionally when it closes: an `inert` left behind on a + window somebody widened is a page that has stopped responding, which is + a far worse bug than the one it is here to fix. */ + var main = document.querySelector(".shell > .main"); + if (main) main.toggleAttribute("inert", open && window.matchMedia(NARROW).matches); + + if (panel) { + panel.dispatchEvent( + new CustomEvent("lembas:toggle", { bubbles: true, detail: { open: open } }) + ); + } + } + function setPanel(selector, open, group) { + if (selector === "#sidebar") return setSidebar(open); + var panel = document.querySelector(selector); if (!panel) return; @@ -855,6 +908,10 @@ var toggle = event.target.closest("[data-toggle]"); if (toggle) { event.preventDefault(); + if (toggle.dataset.toggle === "#sidebar") { + setSidebar(!sidebarOpen()); + return; + } var panel = document.querySelector(toggle.dataset.toggle); if (!panel) return; setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup); @@ -900,12 +957,132 @@ applyTheme(currentTheme()); setupDropzone(); setupResize(); + + /* The toggle used to render `aria-expanded="true"` in the template, which + is a claim nobody checked and which was false on every phone. The + stylesheet decides whether the drawer is showing; this is the one place + that can ask it and say so. */ + syncToggles("#sidebar", sidebarOpen()); + }); + + /* A drawer that is dismissed by tapping beside it should be dismissed by + Escape too -- and only while it *is* a drawer, or Escape would collapse the + sidebar on a desktop, where nobody asked it to. */ + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + if (!window.matchMedia(NARROW).matches || !sidebarOpen()) return; + if (document.querySelector("dialog[open]")) return; + setSidebar(false); + }); + + /* Widening the window past the breakpoint must not leave `inert` on the page + behind a drawer that is no longer a drawer. Recomputed rather than cleared, + so narrowing it again while the drawer is open puts the guard back. */ + window.matchMedia(NARROW).addEventListener("change", function () { + var main = document.querySelector(".shell > .main"); + if (main) { + main.toggleAttribute( + "inert", sidebarOpen() && window.matchMedia(NARROW).matches + ); + } + syncToggles("#sidebar", sidebarOpen()); }); /* Before first paint rather than on DOMContentLoaded, so a panel that was dragged wider does not open at its default and jump. */ applyWidths(); + /* --- Saying that something is happening -------------------------------- + A count, not a flag: several requests overlap constantly here -- the + unread poll every ten seconds, the transcript tail, whatever somebody just + clicked -- and a flag means the first of them to finish switches the bar + off while the others are still running. + + The poll and the tail are excluded. They are the two requests nobody + started and nobody is waiting for, and a bar that sweeps every ten seconds + on an idle page is not information, it is a tic. */ + var pending = 0; + + function quiet(event) { + var el = event.detail && event.detail.elt; + if (!el || !el.getAttribute) return false; + var url = (event.detail.pathInfo && event.detail.pathInfo.requestPath) || ""; + return url.indexOf("/unread") !== -1 || url.indexOf("/tail") !== -1; + } + + function showProgress(on) { + var bar = document.querySelector("[data-progress]"); + if (bar) bar.classList.toggle("is-busy", on); + } + + document.body.addEventListener("htmx:beforeRequest", function (event) { + if (quiet(event)) return; + pending += 1; + showProgress(true); + }); + + ["htmx:afterRequest", "htmx:sendError", "htmx:timeout", "htmx:abort"].forEach( + function (name) { + document.body.addEventListener(name, function (event) { + if (quiet(event)) return; + pending = Math.max(0, pending - 1); + if (!pending) showProgress(false); + }); + } + ); + + /* --- A release that arrived while you were reading ---------------------- + The worker no longer takes over open pages on its own -- see sw.js -- so + something has to say that one is waiting, and the reader decides. A toast + rather than a reload: an application with a reply streaming into it must + not be navigated out from under somebody. */ + function watchForUpdate(registration) { + function offer(worker) { + if (!worker || !navigator.serviceWorker.controller) return; + worker.addEventListener("statechange", function () { + if (worker.state !== "installed") return; + window.lembas.notify( + "A new version is ready. Reload to use it.", + { kind: "info", action: { label: "Reload", run: function () { + worker.postMessage({ type: "SKIP_WAITING" }); + } } } + ); + }); + } + if (registration.waiting && navigator.serviceWorker.controller) { + window.lembas.notify( + "A new version is ready. Reload to use it.", + { kind: "info", action: { label: "Reload", run: function () { + registration.waiting.postMessage({ type: "SKIP_WAITING" }); + } } } + ); + } + registration.addEventListener("updatefound", function () { + offer(registration.installing); + }); + } + + /* The new worker calling skipWaiting() is what fires this, and reloading is + the right answer to it -- the page is now being served by a worker whose + cache it did not start from. + + Two guards, and the second is the one that is easy to miss. A flag, because + `controllerchange` can fire more than once. And `hadController`, because on + a *first* visit there is no worker at all: the one that installs then calls + `clients.claim()`, which fires this event for the first time -- so without + it, the very first page anybody loads reloads itself in front of them for + no reason they could possibly work out. */ + var reloading = false; + if ("serviceWorker" in navigator) { + var hadController = !!navigator.serviceWorker.controller; + navigator.serviceWorker.addEventListener("controllerchange", function () { + if (reloading || !hadController) return; + reloading = true; + window.location.reload(); + }); + navigator.serviceWorker.ready.then(watchForUpdate).catch(function () {}); + } + /* After any htmx swap: re-measure the composer and follow new content. */ document.body.addEventListener("htmx:afterSwap", function () { document.querySelectorAll("[data-autosize]").forEach(autosize); diff --git a/src/lembas/web/static/js/sw.js b/src/lembas/web/static/js/sw.js index 2be0096..1d2e78c 100644 --- a/src/lembas/web/static/js/sw.js +++ b/src/lembas/web/static/js/sw.js @@ -42,6 +42,12 @@ var SHELL = [ "/static/img/logo-mark.svg", "/static/img/icon-192.png", "/static/img/icon-512.png", + // The two a device reaches for when the network is not there: the maskable + // one is what every Android launcher crops, and the Apple one is the home + // screen. Both were absent from this list while the two nothing crops were + // in it. + "/static/img/icon-maskable-512.png", + "/static/img/apple-touch-icon-180.png", ]; self.addEventListener("install", function (event) { @@ -54,13 +60,40 @@ self.addEventListener("install", function (event) { return cache.add(new Request(path, { cache: "reload" })).catch(function () {}); }) ); - }).then(function () { return self.skipWaiting(); }) + }) ); + /* Deliberately NOT skipWaiting() here. + + It used to, unconditionally, together with clients.claim() below -- so a + release took over every open tab the moment it was installed, while the + cache those tabs were reading from was being emptied underneath them. A + page could end up drawing itself from two releases at once, and nothing + said so. + + The new worker waits instead, the page is told, and the reader decides. + `messages/SKIP_WAITING` below is how they say yes. A worker that is never + activated costs a few hundred kilobytes and is replaced by the next one. */ +}); + +/* The page asking to be taken over now. The only message this worker answers, + and it does exactly one thing, because a message channel into a service + worker is a thing any script on the origin can post to. */ +self.addEventListener("message", function (event) { + if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting(); }); self.addEventListener("activate", function (event) { event.waitUntil( - caches.keys().then(function (names) { + /* Without this, every navigation waits for this worker to start before its + request is even made -- which on a cold phone is the difference between + a page and a pause. The navigate branch below is a plain fetch, so the + preloaded response is used simply by preferring it when it exists. */ + (self.registration.navigationPreload + ? self.registration.navigationPreload.enable().catch(function () {}) + : Promise.resolve() + ).then(function () { + return caches.keys(); + }).then(function (names) { return Promise.all( names.map(function (name) { if (name !== CACHE && name.indexOf("lembas-") === 0) return caches.delete(name); @@ -97,9 +130,9 @@ self.addEventListener("fetch", function (event) { if (request.mode === "navigate") { event.respondWith( - fetch(request).catch(function () { - return caches.match("/offline"); - }) + Promise.resolve(event.preloadResponse) + .then(function (preloaded) { return preloaded || fetch(request); }) + .catch(function () { return caches.match("/offline"); }) ); return; } @@ -158,13 +191,53 @@ self.addEventListener("push", function (event) { tag: "lembas-" + (payload.kind || "unread"), renotify: true, icon: "/static/img/icon-192.png", - badge: "/static/img/icon-192.png", + /* A badge is drawn as a *mask* in the status bar -- the device keeps + the alpha and throws the colour away. The full-colour 192 is opaque + to its edges, so what Android rendered was a solid grey square. The + leaf has transparency, so it survives being masked. */ + badge: "/static/img/badge-72.png", data: { url: payload.url || "/" }, }); }) ); }); +/* + A browser may replace a subscription on its own -- a push service expiring a + key, a browser upgrade. When it does, the endpoint this server holds stops + working and nothing anywhere says so: notifications simply stop. The event + fires exactly once, at the moment of the swap, and it is the only chance to + hear about it. + + Re-subscribing needs the server's public key, which this worker does not hold, + so it asks the same endpoint the page does. +*/ +self.addEventListener("pushsubscriptionchange", function (event) { + event.waitUntil( + fetch("/api/push/key") + .then(function (response) { return response.ok ? response.json() : null; }) + .then(function (data) { + if (!data || !data.key) return null; + return self.registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: Uint8Array.from( + atob(data.key.replace(/-/g, "+").replace(/_/g, "/")), + function (c) { return c.charCodeAt(0); } + ), + }); + }) + .then(function (subscription) { + if (!subscription) return null; + return fetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscription.toJSON()), + }); + }) + .catch(function () { /* Nothing here can ask a person for help. */ }) + ); +}); + /* Clicking one. diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index a30a528..dec9a35 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -47,6 +47,20 @@ var toast = el("div", "toast toast--" + (options.kind || "info")); toast.appendChild(el("span", "toast__text", message)); + /* Some news is worth acting on where it is read: "a new version is ready" + with no way to take it is a sentence that sends somebody looking for a + menu. One action, never two -- a toast is not a dialog, and anything + needing a choice should be one. */ + if (options.action && options.action.label) { + var act = el("button", "btn btn--sm toast__action", options.action.label); + act.type = "button"; + act.addEventListener("click", function () { + dismiss(toast); + if (options.action.run) options.action.run(); + }); + toast.appendChild(act); + } + var close = el("button", "toast__close"); close.type = "button"; close.setAttribute("aria-label", "Dismiss"); @@ -58,7 +72,11 @@ // Next frame, so the entry transition has a state to move from. requestAnimationFrame(function () { toast.classList.add("is-in"); }); - var timeout = options.timeout == null ? TOAST_MS : options.timeout; + /* A toast offering an action must not take it away while it is being read. + Anything with a button stays until it is answered or dismissed. */ + var timeout = options.timeout == null + ? (options.action ? 0 : TOAST_MS) + : options.timeout; if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout); return toast; } diff --git a/src/lembas/web/templates/admin/_connection_row.html b/src/lembas/web/templates/admin/_connection_row.html index 66e8669..0cf2658 100644 --- a/src/lembas/web/templates/admin/_connection_row.html +++ b/src/lembas/web/templates/admin/_connection_row.html @@ -78,7 +78,7 @@ - @@ -91,6 +91,20 @@

+
+ + +

+ One Name: value per line, sent with every request to this + endpoint. OpenRouter reads HTTP-Referer and + X-Title and attributes your usage with them. Leave it empty + unless an endpoint has asked for something. +

+
+
+ + {# + Archived chats, closed, and absent entirely when there are none. + + A `
` rather than a page of their own: archiving is for getting a + conversation out of the way, not for filing it somewhere, and a second + screen to visit would make putting one back a journey. Closed by default + because that is the whole point, and the browser keeps the open state + across the out-of-band swaps the unread poll makes -- the same property the + folder tree relies on. + #} + {% if archived_chats %} + + {% endif %} diff --git a/src/lembas/web/templates/partials/sidebar.html b/src/lembas/web/templates/partials/sidebar.html index e292bdc..15834b9 100644 --- a/src/lembas/web/templates/partials/sidebar.html +++ b/src/lembas/web/templates/partials/sidebar.html @@ -9,6 +9,22 @@ + +{# + The scrim behind the open drawer. It carries the same `data-toggle` as every + other control that closes it, so tapping beside the drawer closes it through + exactly one code path rather than a second one written for touch. + + Rendered always and shown by CSS: it exists only below the breakpoint and only + while the drawer is open, which is a question about width and state that the + server cannot answer and the stylesheet can. +#} + diff --git a/src/lembas/web/templates/reports/_layout.html b/src/lembas/web/templates/reports/_layout.html index 010d5a4..f2fc7f4 100644 --- a/src/lembas/web/templates/reports/_layout.html +++ b/src/lembas/web/templates/reports/_layout.html @@ -27,6 +27,7 @@
+ {% include "partials/_sidebar_toggle.html" %}

{% block heading %}Reports{% endblock %}

{% block actions %}{% endblock %}
diff --git a/src/lembas/web/templates/schedules/_layout.html b/src/lembas/web/templates/schedules/_layout.html index c71bba4..679549a 100644 --- a/src/lembas/web/templates/schedules/_layout.html +++ b/src/lembas/web/templates/schedules/_layout.html @@ -20,6 +20,7 @@
+ {% include "partials/_sidebar_toggle.html" %}

{% block heading %}Scheduled{% endblock %}

{% block actions %}{% endblock %}
diff --git a/src/lembas/web/templates/settings.html b/src/lembas/web/templates/settings.html index f08a93d..d85ba3c 100644 --- a/src/lembas/web/templates/settings.html +++ b/src/lembas/web/templates/settings.html @@ -16,6 +16,7 @@
+ {% include "partials/_sidebar_toggle.html" %}

Your settings

@@ -25,7 +26,18 @@ state. Each panel is a real fragment of the page, not a fetch. #}
-
+ {# + Deliberately no `role="tablist"`. + + It had one, and the children are `` and `