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 0000000..ac82cbe Binary files /dev/null and b/assets/badge-72.png differ diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py index 42fa934..8c40368 100755 --- a/scripts/build_artwork.py +++ b/scripts/build_artwork.py @@ -53,6 +53,7 @@ SERVED_BY_APP = ( "icon-512.png", "icon-maskable-512.png", "apple-touch-icon-180.png", + "badge-72.png", ) FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") @@ -423,6 +424,28 @@ def build_apple_touch_icon() -> 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 0000000..ac82cbe Binary files /dev/null and b/src/lembas/web/static/img/badge-72.png differ diff --git a/src/lembas/web/static/img/screenshot-narrow.png b/src/lembas/web/static/img/screenshot-narrow.png new file mode 100644 index 0000000..6f6f7fa Binary files /dev/null and b/src/lembas/web/static/img/screenshot-narrow.png differ diff --git a/src/lembas/web/static/img/screenshot-wide.png b/src/lembas/web/static/img/screenshot-wide.png new file mode 100644 index 0000000..049fec1 Binary files /dev/null and b/src/lembas/web/static/img/screenshot-wide.png differ 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 `