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
+
+
+
+
+ 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 %}
+
+
+ {{ icon("archive", "icon--sm") }} Archived
+ {{ archived_chats|length }}
+
+ {% for chat_item in archived_chats %}
+ {% include "partials/_chat_link.html" %}
+ {% endfor %}
+
+ {% 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 `