A phone, and how much of this could not be used on one

The sidebar was a 280px panel laid over the page below the phone breakpoint,
opened from first paint, with the only control that closed it underneath it --
and that control existed on /chat and on none of the seven other pages carrying
a sidebar, Settings included. It starts closed at that width now, slides, dims
the page behind it, and closes by tapping beside it, by Escape, or by its own
button, which is inside the drawer where it can be reached.

Everything a finger has to hit was 36px, or 28 for renaming a chat, every action
on a message and every panel's close button. Raising --control-h under a coarse
pointer is the only fix that reaches all forty of them, which is what that token
is for. The row and message actions were also hover-only, so on a phone they did
not exist at all.

Installing: the splash and the browser chrome follow the instance's theme rather
than always being Moria's near-black; there are screenshots, so the install
offer is a dialog rather than a one-line bar; a new release no longer takes over
a page somebody is reading; the notification badge is a silhouette rather than a
grey square; and a browser rotating its own subscription no longer ends
notifications for good.

Every request now says it is happening -- nothing did before, so anything slower
than a few milliseconds looked like a click that had not registered.

A chat can be archived. The column has been filtered on in four places since
folders arrived and written by nothing, which is what made it look built.

chat.css may contain media queries. The ban protected the composer toolbar from
being "fixed" with a breakpoint; that guarantee is asserted directly now, and
the old test would have passed a version of the file that wrapped the toolbar
without one.

scripts/shoot.py is the instrument all of this was found with: it renders a page
through TestClient into a real headless browser at a real size and refuses to
run if an asset URL was left pointing at testserver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:39:35 +00:00
co-authored by Claude Opus 5
parent 92070d7879
commit db12ee8047
44 changed files with 2290 additions and 135 deletions
+91
View File
@@ -16,6 +16,97 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## 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 ## 1.0.4
Six things that looked like they worked. Five of them were found by reading the Six things that looked like they worked. Five of them were found by reading the
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

+24
View File
@@ -53,6 +53,7 @@ SERVED_BY_APP = (
"icon-512.png", "icon-512.png",
"icon-maskable-512.png", "icon-maskable-512.png",
"apple-touch-icon-180.png", "apple-touch-icon-180.png",
"badge-72.png",
) )
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") 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) 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">
<path d="{LEAF_BLADE}" fill="#FFFFFF"/>
</svg>
""",
72,
)
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
"""One jagged ridge line spanning the full width.""" """One jagged ridge line spanning the full width."""
rng = random.Random(seed) rng = random.Random(seed)
@@ -564,6 +587,7 @@ BUILDERS = {
"icon-512.png": build_icon_512, "icon-512.png": build_icon_512,
"icon-maskable-512.png": build_icon_maskable, "icon-maskable-512.png": build_icon_maskable,
"apple-touch-icon-180.png": build_apple_touch_icon, "apple-touch-icon-180.png": build_apple_touch_icon,
"badge-72.png": build_badge,
} }
+372
View File
@@ -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 = """
<script>
window.__measure = function () {
var de = document.scrollingElement || document.documentElement;
var small = [];
document.querySelectorAll(
'button, a.btn, a.nav-item, .tabs__tab, input, select, [role=tab]'
).forEach(function (el) {
var r = el.getBoundingClientRect();
if (!r.width || !r.height) return; /* hidden */
if (el.closest('[hidden]')) return;
/* A `.visually-hidden` radio is 1x1 on purpose -- the <label> beside it is
the target, and that one is measured. Counting the input reports five
failures on a settings page whose tabs are all 44px. */
if (el.classList.contains('visually-hidden')) return;
/* Inline text inside a sentence is not a tap target in the sense this is
checking; it is a word you can also click. */
if (getComputedStyle(el).display === 'inline') return;
if (r.height < 40 || r.width < 40) {
small.push({
tag: el.tagName.toLowerCase(),
cls: el.className && el.className.toString().slice(0, 60),
label: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30),
w: Math.round(r.width), h: Math.round(r.height)
});
}
});
var wide = [];
document.querySelectorAll('body *').forEach(function (el) {
var r = el.getBoundingClientRect();
if (r.right > window.innerWidth + 1 || r.left < -1) {
wide.push({
tag: el.tagName.toLowerCase(),
cls: el.className && el.className.toString().slice(0, 60),
left: Math.round(r.left), right: Math.round(r.right)
});
}
});
/* Which element is actually making the document bigger than the window.
"the page over-scrolls" is not actionable; "`.shell` is 1756px tall in an
844px window" is. Reported for both axes, deepest first, because the
outermost offender is usually just the ancestor of the real one. */
/* Content taller than the window inside something built to scroll is not
overflow, it is the point. So an element counts only when nothing between
it and the root can scroll in that axis -- otherwise every long settings
page reports its own cards as a bug and the signal is lost in them. */
function contained(el, axis) {
var prop = axis === 'y' ? 'overflowY' : 'overflowX';
for (var n = el.parentElement; n && n !== document.documentElement; n = n.parentElement) {
var o = getComputedStyle(n)[prop];
if (o === 'auto' || o === 'scroll' || o === 'hidden') return true;
}
return false;
}
function culprits(axis) {
var found = [];
document.querySelectorAll('body, body *').forEach(function (el) {
if (contained(el, axis)) return;
var r = el.getBoundingClientRect();
var over = axis === 'y'
? r.bottom - window.innerHeight
: r.right - window.innerWidth;
if (over > 1) {
found.push({
tag: el.tagName.toLowerCase(),
cls: (el.className && el.className.toString().slice(0, 50)) || '',
over: Math.round(over),
size: Math.round(axis === 'y' ? r.height : r.width),
pos: getComputedStyle(el).position,
id: el.id || '',
parent: el.parentElement ? (el.parentElement.tagName.toLowerCase() + '.' +
(el.parentElement.className || '').toString().slice(0, 30)) : '',
html: el.outerHTML.slice(0, 120)
});
}
});
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
}
var shell = document.querySelector('.shell');
return {
docScrollH: de.scrollHeight,
innerH: window.innerHeight,
docScrollW: de.scrollWidth,
innerW: window.innerWidth,
bodyScrollH: document.body.scrollHeight,
shellH: shell ? Math.round(shell.getBoundingClientRect().height) : null,
shellW: shell ? Math.round(shell.getBoundingClientRect().width) : null,
tallCulprits: culprits('y'),
wideCulprits: culprits('x'),
/* The invariant: the application shell fills the window and the DOCUMENT
never scrolls. A document taller than the window is the /settings bug. */
documentScrolls: de.scrollHeight > window.innerHeight + 1,
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
smallTargets: small.slice(0, 40),
smallCount: small.length,
overflowing: wide.slice(0, 20),
overflowCount: wide.length
};
};
/* Nothing is appended to the page itself. The first version of this harness
did exactly that, and the div it added was 960px tall -- so the very first
run reported that /chat over-scrolled by 960px on a phone, which was a
finding entirely about the instrument. The frame outside reads __measure()
across the boundary instead, and the page is left exactly as served. */
</script>
"""
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 <link>. 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'<link\b[^>]*\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 = (
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
"catch(e){}</script>"
)
return html.replace("</head>", quiet + MEASURE + "</head>", 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 <iframe> of exactly the target size rather
than into a window of it, because headless Chromium refuses to make a window
narrower than about 500px -- ask for 390 and you get 500, and every
measurement is then of a layout no phone will ever produce. A media query
inside an iframe evaluates against the iframe's own viewport, so this is the
real thing: `width: 390px` on the frame is a 390px viewport inside it.
"""
response = client.get(path)
if response.status_code != 200:
raise SystemExit(f"{path} -> HTTP {response.status_code}")
assets = outdir / "assets"
assets.mkdir(parents=True, exist_ok=True)
html = response.text.replace('data-theme="moria"', f'data-theme="{theme}"')
html = rewrite(html, client, assets)
slug = f"{path.strip('/').replace('/', '-') or 'root'}-{theme}-{width}x{height}"
page = outdir / f"{slug}.html"
page.write_text(html)
frame = outdir / f"{slug}-frame.html"
frame.write_text(
"<!doctype html><meta charset=utf-8>"
"<style>html,body{margin:0;background:#888}"
f"iframe{{width:{width}px;height:{height}px;border:0;display:block}}</style>"
f'<iframe id="f" src="{page.name}"></iframe>'
"<div id=\"__measurements\"></div>"
"<script>"
"window.addEventListener('load',function(){setTimeout(function(){"
"var w=document.getElementById('f').contentWindow;"
"document.getElementById('__measurements').textContent="
"JSON.stringify(w.__measure?w.__measure():{error:'no __measure -- the page did not load'});"
"},600);});"
"</script>"
)
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">(.*?)</div>', 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()
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.0.4" __version__ = "1.1.0"
+28
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, Form, HTTPException, Request, Response, status 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") @router.post("/connections")
async def create_connection( async def create_connection(
db: Db, db: Db,
@@ -145,6 +165,7 @@ async def update_connection(
enabled: bool = Form(False), enabled: bool = Form(False),
unload_url: str = Form(""), unload_url: str = Form(""),
unload_method: str = Form("POST"), unload_method: str = Form("POST"),
extra_headers: str = Form(""),
) -> Response: ) -> Response:
connection = _connection(db, connection_id) connection = _connection(db, connection_id)
connection.name = name.strip()[:120] or connection.name connection.name = name.strip()[:120] or connection.name
@@ -157,6 +178,13 @@ async def update_connection(
method = unload_method.strip().upper() method = unload_method.strip().upper()
connection.unload_method = method if method in ("GET", "POST") else "POST" 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() submitted = api_key.strip()
if submitted and submitted != UNCHANGED_SENTINEL: if submitted and submitted != UNCHANGED_SENTINEL:
connection.api_key_encrypted = encrypt(submitted) connection.api_key_encrypted = encrypt(submitted)
+33
View File
@@ -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 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 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 # The mode is the one agent field that changes mid-chat: it decides what
# gets asked about, not what the conversation is. # gets asked about, not what the conversation is.
if "agent_mode" in form: if "agent_mode" in form:
@@ -2100,6 +2115,24 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
return HTMLResponse( return HTMLResponse(
templates.get_template("chat/_title_oob.html").render({"chat": chat}) 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) return Response(status_code=status.HTTP_204_NO_CONTENT)
+79 -3
View File
@@ -10,6 +10,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser from lembas.api.deps import Db, RequiredUser
from lembas.config import settings
from lembas.db.models import ( from lembas.db.models import (
KIND_CHAT, KIND_CHAT,
KIND_MESSAGES, KIND_MESSAGES,
@@ -42,6 +43,17 @@ router = APIRouter(tags=["pages"])
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"} 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: def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""Model lists and permissions every chat page needs. """Model lists and permissions every chat page needs.
@@ -388,9 +400,29 @@ def sidebar_context(db: DBSession, user: User) -> dict:
unfiled = list( unfiled = list(
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc())) 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 { return {
"folders": folders, "folders": folders,
"unfiled_chats": unfiled, "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 # The shortcuts at the top of the sidebar. Here rather than in
# `_chat_context`, where they used to be, for two reasons: they are # `_chat_context`, where they used to be, for two reasons: they are
# sidebar content and the fragment route that re-renders the sidebar has # 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) brand = branding_service.for_db(db)
icons = brand.icon_paths icons = brand.icon_paths
colour = _instance_colour(brand)
return JSONResponse( 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, "name": brand.name,
"short_name": brand.name[:12], "short_name": brand.name[:12],
"description": brand.tagline or "A web UI for your language models.", "description": brand.tagline or "A web UI for your language models.",
"lang": "en",
"dir": "ltr",
"start_url": "/chat", "start_url": "/chat",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": THEME_COLOUR["moria"], # Ordered best-first: a browser takes the first it understands and
"theme_color": THEME_COLOUR["moria"], # 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 # An uploaded logo's derived icons, or the shipped ones. Whole-set
# rather than per size: a manifest listing two custom icons and one # rather than per size: a manifest listing two custom icons and one
# shipped is a launcher tile that changes when the device picks a # shipped is a launcher tile that changes when the device picks a
+4
View File
@@ -250,6 +250,7 @@ def context_variables(
"agent_mode": "", "agent_mode": "",
"agent_rewound": "", "agent_rewound": "",
"background": "", "background": "",
"background_notify": "",
"project_files": "", "project_files": "",
"agent_instructions": "", "agent_instructions": "",
"agent_instructions_file": "", "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 # Non-empty only when commands may run in the background, which is what
# gates the fragment telling the model so. # gates the fragment telling the model so.
"background": "on" if context.background else "", "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), "max_rounds": str(context.limits.steps),
# Blanked, which is what makes `core.rounds` vanish here: `steps` is a # 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 # runaway backstop and telling a model it has a budget of two hundred
+49
View File
@@ -214,6 +214,14 @@ VARIABLES: tuple[Variable, ...] = (
"Non-empty when a command may run detached. Nothing renders it; it gates " "Non-empty when a command may run detached. Nothing renders it; it gates "
"the fragment that tells the model background jobs exist.", "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( Variable(
"plan", "plan",
"The current 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 " "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 " "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" "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 " "- 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 " "\"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 " "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." "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( Fragment(
key="tool.agent_edits", key="tool.agent_edits",
label="Changing a file", label="Changing a file",
+55 -19
View File
@@ -22,7 +22,10 @@
.page, .page,
.admin-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; margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-12); padding: var(--sp-6) var(--sp-5) var(--sp-12);
} }
@@ -71,7 +74,7 @@
align-items: center; align-items: center;
gap: var(--sp-1); gap: var(--sp-1);
padding: 0 var(--sp-5); padding: 0 var(--sp-5);
border-bottom: 1px solid var(--border); border-bottom: var(--border-w) solid var(--border);
background: var(--bg); background: var(--bg);
flex: none; flex: none;
overflow-x: auto; overflow-x: auto;
@@ -87,7 +90,7 @@
contexts and would otherwise paint over it. */ contexts and would otherwise paint over it. */
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: var(--z-raised);
} }
.tabs__tab { .tabs__tab {
@@ -96,7 +99,7 @@
gap: var(--sp-2); gap: var(--sp-2);
height: var(--control-h-lg); height: var(--control-h-lg);
padding: 0 var(--sp-4); padding: 0 var(--sp-4);
border-bottom: 2px solid transparent; border-bottom: var(--border-w-thick) solid transparent;
color: var(--ink-muted); color: var(--ink-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 500; 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) { .tabs__bar:has(input:nth-of-type(8):checked) ~ .tabs__body .tabs__panel:nth-of-type(8) {
display: block; 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. A form's action row, and the space after the form it closes.
@@ -173,7 +209,7 @@ a.tabs__tab { text-decoration: none; }
/* --- Cards ----------------------------------------------------------------- */ /* --- Cards ----------------------------------------------------------------- */
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--sp-5); padding: var(--sp-5);
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
@@ -199,7 +235,7 @@ a.tabs__tab { text-decoration: none; }
gap: var(--sp-3); gap: var(--sp-3);
margin-top: var(--sp-5); margin-top: var(--sp-5);
padding-top: var(--sp-4); padding-top: var(--sp-4);
border-top: 1px solid var(--border); border-top: var(--border-w) solid var(--border);
flex-wrap: wrap; flex-wrap: wrap;
} }
.card__header { .card__header {
@@ -235,7 +271,7 @@ a.tabs__tab { text-decoration: none; }
gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; } gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
.connection__footer { display: flex; align-items: center; justify-content: space-between; .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); 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; } .field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
/* --- Definition lists ------------------------------------------------------ */ /* --- Definition lists ------------------------------------------------------ */
@@ -273,7 +309,7 @@ a.tabs__tab { text-decoration: none; }
display: flex; display: flex;
gap: var(--sp-1); gap: var(--sp-1);
flex-wrap: wrap; flex-wrap: wrap;
border-bottom: 1px solid var(--border); border-bottom: var(--border-w) solid var(--border);
} }
.filter-tab { .filter-tab {
display: inline-flex; display: inline-flex;
@@ -281,7 +317,7 @@ a.tabs__tab { text-decoration: none; }
gap: var(--sp-2); gap: var(--sp-2);
height: var(--control-h); height: var(--control-h);
padding: 0 var(--sp-3); padding: 0 var(--sp-3);
border-bottom: 2px solid transparent; border-bottom: var(--border-w-thick) solid transparent;
color: var(--ink-muted); color: var(--ink-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
@@ -315,7 +351,7 @@ a.tabs__tab { text-decoration: none; }
gap: var(--sp-2); gap: var(--sp-2);
flex-wrap: wrap; flex-wrap: wrap;
padding: var(--sp-2) var(--sp-3); 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-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-bottom: 0; border-bottom: 0;
background: var(--bg-sunken); 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); } .bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); }
.model-rows { .model-rows {
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-radius: 0 0 var(--radius-lg) var(--radius-lg);
overflow: hidden; overflow: hidden;
background: var(--surface); background: var(--surface);
@@ -333,7 +369,7 @@ a.tabs__tab { text-decoration: none; }
align-items: center; align-items: center;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-2) 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:last-child { border-bottom: 0; }
.model-row:hover { background: var(--surface-hover); } .model-row:hover { background: var(--surface-hover); }
@@ -408,7 +444,7 @@ a.tabs__tab { text-decoration: none; }
justify-content: space-between; justify-content: space-between;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-3) 0; 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:first-child { padding-top: 0; }
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; } .model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
@@ -425,7 +461,7 @@ a.tabs__tab { text-decoration: none; }
.perm-row { .perm-row {
align-items: flex-start; align-items: flex-start;
padding: var(--sp-3) 0; 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:last-child { border-bottom: 0; }
.perm-row input { margin-top: 0.15rem; } .perm-row input { margin-top: 0.15rem; }
@@ -470,7 +506,7 @@ a.tabs__tab { text-decoration: none; }
gap: var(--sp-3); gap: var(--sp-3);
align-items: baseline; align-items: baseline;
padding: var(--sp-2) 0; padding: var(--sp-2) 0;
border-top: 1px solid var(--border); border-top: var(--border-w) solid var(--border);
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: var(--leading-normal); line-height: var(--leading-normal);
} }
@@ -496,7 +532,7 @@ a.tabs__tab { text-decoration: none; }
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--code-border); border: var(--border-w) solid var(--code-border);
border-radius: var(--radius); border-radius: var(--radius);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -523,7 +559,7 @@ a.tabs__tab { text-decoration: none; }
padding: 0.05em 0.3em; padding: 0.05em 0.3em;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--code-bg); 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 ------------------- */ /* --- 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. correct: `_rule_from_form` reads only the keys the chosen repeat mode uses.
*/ */
.schedule-repeat { .schedule-repeat {
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--sp-4); padding: var(--sp-4);
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
+302 -42
View File
@@ -60,6 +60,11 @@ body:has(> .shell) {
body { body {
margin: 0; 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-family: var(--font-body);
font-size: var(--text-base); font-size: var(--text-base);
line-height: var(--leading-normal); line-height: var(--leading-normal);
@@ -93,7 +98,7 @@ button, input, textarea, select {
/* A single, consistent focus ring. Never remove it without a replacement. */ /* A single, consistent focus ring. Never remove it without a replacement. */
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: var(--outline-w) solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@@ -136,7 +141,7 @@ button, input, textarea, select {
gap: var(--sp-2); gap: var(--sp-2);
height: var(--control-h); height: var(--control-h);
padding: 0 var(--control-px); padding: 0 var(--control-px);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-raised); background: var(--surface-raised);
color: var(--ink); color: var(--ink);
@@ -249,7 +254,7 @@ button, input, textarea, select {
width: 100%; width: 100%;
height: var(--control-h); height: var(--control-h);
padding: 0 var(--control-px); padding: 0 var(--control-px);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-sunken); background: var(--bg-sunken);
color: var(--ink); color: var(--ink);
@@ -259,7 +264,7 @@ button, input, textarea, select {
.textarea { .textarea {
width: 100%; width: 100%;
padding: var(--sp-2) var(--control-px); padding: var(--sp-2) var(--control-px);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-sunken); background: var(--bg-sunken);
color: var(--ink); color: var(--ink);
@@ -275,7 +280,7 @@ button, input, textarea, select {
.select:focus { .select:focus {
outline: none; outline: none;
border-color: var(--accent); 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); } .input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
@@ -306,7 +311,7 @@ button, input, textarea, select {
margin: 0 var(--sp-3) 0 0; margin: 0 var(--sp-3) 0 0;
padding: 0 var(--control-px); padding: 0 var(--control-px);
border: 0; border: 0;
border-right: 1px solid var(--border); border-right: var(--border-w) solid var(--border);
background: var(--surface-hover); background: var(--surface-hover);
color: var(--ink); color: var(--ink);
font: inherit; font: inherit;
@@ -365,8 +370,8 @@ button, input, textarea, select {
display: flex; display: flex;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-left-width: 3px; border-left-width: var(--border-w-accent);
border-radius: var(--radius); border-radius: var(--radius);
font-size: var(--text-sm); font-size: var(--text-sm);
margin-bottom: var(--sp-4); margin-bottom: var(--sp-4);
@@ -436,7 +441,7 @@ button, input, textarea, select {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-sunken); background: var(--bg-sunken);
border-right: 1px solid var(--border); border-right: var(--border-w) solid var(--border);
min-height: 0; min-height: 0;
} }
.sidebar[hidden] { display: none; } .sidebar[hidden] { display: none; }
@@ -511,7 +516,7 @@ button, input, textarea, select {
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
background: var(--bg-sunken); 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; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2); 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; flex: none;
padding: 0 var(--sp-3); padding-right: var(--sp-3);
border-bottom: 1px solid var(--border); padding-left: var(--sp-3);
border-bottom: var(--border-w) solid var(--border);
} }
.panel-head__title { .panel-head__title {
display: flex; display: flex;
@@ -578,7 +587,7 @@ button, input, textarea, select {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--code-border); border: var(--border-w) solid var(--code-border);
border-radius: var(--radius); border-radius: var(--radius);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -610,7 +619,7 @@ button, input, textarea, select {
/* So the resize handle can sit on the edge. */ /* So the resize handle can sit on the edge. */
position: relative; position: relative;
background: var(--bg-sunken); 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 /* 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; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); 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); font-size: var(--text-xs);
color: var(--ink-faint); color: var(--ink-faint);
line-height: var(--leading-normal); line-height: var(--leading-normal);
@@ -717,7 +726,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
min-height: 0; min-height: 0;
position: relative; position: relative;
background: var(--bg-sunken); background: var(--bg-sunken);
border-left: 1px solid var(--border); border-left: var(--border-w) solid var(--border);
} }
.canvas__inner { .canvas__inner {
display: flex; display: flex;
@@ -760,7 +769,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
overflow-x: auto; overflow-x: auto;
scrollbar-width: thin; scrollbar-width: thin;
background: var(--bg-sunken); background: var(--bg-sunken);
border-bottom: 1px solid var(--border); border-bottom: var(--border-w) solid var(--border);
} }
.canvas__tab { .canvas__tab {
display: inline-flex; display: inline-flex;
@@ -769,7 +778,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
max-width: 14rem; max-width: 14rem;
/* Square at the bottom: a tab is attached to what it opens. */ /* Square at the bottom: a tab is attached to what it opens. */
border-radius: var(--radius-sm) var(--radius-sm) 0 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border: 1px solid transparent; border: var(--border-w) solid transparent;
border-bottom: 0; border-bottom: 0;
/* The strip's own bottom border is 1px; this covers it for the active tab /* 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. */ 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); gap: var(--sp-2);
flex: none; flex: none;
padding: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border); border-bottom: var(--border-w) solid var(--border);
} }
.canvas__body { .canvas__body {
@@ -866,7 +875,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
width: 100%; width: 100%;
min-height: 24rem; min-height: 24rem;
padding: var(--sp-3); padding: var(--sp-3);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--code-bg); background: var(--code-bg);
color: var(--ink); color: var(--ink);
@@ -896,10 +905,15 @@ body.is-resizing .canvas__body { pointer-events: none; }
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-3); 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; flex: none;
padding: 0 var(--sp-4); padding-right: max(var(--sp-4), var(--safe-right));
border-bottom: 1px solid var(--border); padding-left: max(var(--sp-4), var(--safe-left));
border-bottom: var(--border-w) solid var(--border);
background: var(--bg); background: var(--bg);
} }
.topbar__title { .topbar__title {
@@ -959,7 +973,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
gap: var(--sp-2); gap: var(--sp-2);
height: var(--control-h); height: var(--control-h);
padding: 0 var(--sp-1) 0 var(--sp-2); 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); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
cursor: pointer; cursor: pointer;
@@ -968,14 +982,14 @@ body.is-resizing .canvas__body { pointer-events: none; }
.model-select:hover { border-color: var(--border-strong); } .model-select:hover { border-color: var(--border-strong); }
.model-select:focus-within { .model-select:focus-within {
border-color: var(--accent); 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); } .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. */ /* Collapsible settings panel, shared by chat settings and anything like it. */
.panel { .panel {
flex: none; flex: none;
border-bottom: 1px solid var(--border); border-bottom: var(--border-w) solid var(--border);
background: var(--bg-sunken); background: var(--bg-sunken);
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
@@ -1052,6 +1066,40 @@ body.is-resizing .canvas__body { pointer-events: none; }
} }
.nav-item:hover .nav-item__actions, .nav-item:hover .nav-item__actions,
.nav-item:focus-within .nav-item__actions { opacity: 1; } .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 `<summary>` 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 { .nav-empty {
padding: var(--sp-2); padding: var(--sp-2);
@@ -1075,7 +1123,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
width: 100%; width: 100%;
max-width: 25rem; max-width: 25rem;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: var(--sp-8); padding: var(--sp-8);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@@ -1096,7 +1144,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
.auth__footer { .auth__footer {
margin-top: var(--sp-5); margin-top: var(--sp-5);
padding-top: var(--sp-4); padding-top: var(--sp-4);
border-top: 1px solid var(--border); border-top: var(--border-w) solid var(--border);
text-align: center; text-align: center;
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--ink-muted); 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; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --- Small screens -------------------------------------------------------- */ /* --- 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 <html> 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) { @media (max-width: 48rem) {
.sidebar { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; 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); z-index: var(--z-panel);
box-shadow: var(--shadow-lg); 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 :root:not([data-sidebar="closed"]) .sidebar {
file. There used to be a `[data-collapsed="true"]` rule here that nothing /* `display` must not be the thing that hides it here, or there is nothing
ever set. */ 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 ---------------------------------------------------------------- /* --- Toasts ----------------------------------------------------------------
@@ -1166,8 +1300,8 @@ body.is-resizing .canvas__body { pointer-events: none; }
align-items: flex-start; align-items: flex-start;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-left: 3px solid var(--accent); border-left: var(--border-w-accent) solid var(--accent);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-raised); background: var(--surface-raised);
box-shadow: var(--shadow-lg); 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__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast__close { .toast__close {
flex: none; 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: 0;
border-radius: var(--radius-sm);
background: none; background: none;
color: var(--ink-faint); color: var(--ink-faint);
cursor: pointer; cursor: pointer;
font-size: var(--text-lg); font-size: var(--text-lg);
line-height: 1; line-height: 1;
padding: 0 0.15rem; padding: 0;
} }
.toast__close:hover { color: var(--ink); } .toast__close:hover { color: var(--ink); }
.toast__action { flex: none; align-self: center; }
/* --- Dialogs ---------------------------------------------------------------- /* --- Dialogs ----------------------------------------------------------------
<dialog> gives focus trapping, Escape and page inertness for free. <dialog> gives focus trapping, Escape and page inertness for free.
*/ */
.dialog { .dialog {
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: var(--surface); background: var(--surface);
color: var(--ink); color: var(--ink);
@@ -1251,7 +1395,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
height: var(--control-h); height: var(--control-h);
max-width: 16rem; max-width: 16rem;
padding: 0 var(--sp-2); padding: 0 var(--sp-2);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
color: var(--ink); 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:hover { border-color: var(--border-strong); }
.picker__button[aria-expanded="true"] { .picker__button[aria-expanded="true"] {
border-color: var(--accent); 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__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
.picker__label { .picker__label {
@@ -1280,7 +1424,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
right: 0; right: 0;
z-index: var(--z-dropdown); z-index: var(--z-dropdown);
width: min(24rem, calc(100vw - var(--sp-8))); 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); border-radius: var(--radius-lg);
background: var(--surface-raised); background: var(--surface-raised);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@@ -1336,7 +1480,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
margin: 0; margin: 0;
min-width: 0; min-width: 0;
padding: var(--sp-2) var(--sp-3); 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); background: var(--bg-sunken);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--ink-muted); color: var(--ink-muted);
@@ -1351,12 +1495,12 @@ body.is-resizing .canvas__body { pointer-events: none; }
max-height: min(24rem, 50vh); max-height: min(24rem, 50vh);
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
} }
.dialog__results .picker__list { max-height: none; } .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. 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); font-size: var(--text-sm);
color: var(--ink-faint); 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); }
+107 -32
View File
@@ -22,11 +22,22 @@
gap: var(--sp-6); 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 { .thread__intro {
display: grid; display: grid;
place-items: center; place-items: center;
gap: var(--sp-3); gap: var(--sp-3);
text-align: center; text-align: center;
animation: intro-rise var(--dur-3) var(--ease-out) both;
padding: var(--sp-12) 0 var(--sp-6); padding: var(--sp-12) 0 var(--sp-6);
} }
@@ -153,7 +164,7 @@
/* --- Reasoning ------------------------------------------------------------ */ /* --- Reasoning ------------------------------------------------------------ */
.reasoning { .reasoning {
margin: 0 0 var(--sp-3); margin: 0 0 var(--sp-3);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 70%, transparent); background: color-mix(in srgb, var(--surface) 70%, transparent);
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -268,7 +279,7 @@
flex-direction: column; flex-direction: column;
gap: var(--sp-1); gap: var(--sp-1);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--surface); background: var(--surface);
color: var(--ink); color: var(--ink);
@@ -277,7 +288,7 @@
transition: background var(--transition-fast), border-color var(--transition-fast); transition: background var(--transition-fast), border-color var(--transition-fast);
} }
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); } .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__name { font-weight: 600; font-size: var(--text-sm); }
.suggestion__note { .suggestion__note {
@@ -347,7 +358,7 @@
.reasoning__body { .reasoning__body {
padding: 0 var(--sp-3) var(--sp-3); padding: 0 var(--sp-3) var(--sp-3);
margin-left: var(--sp-2); 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); padding-left: var(--sp-3);
white-space: pre-wrap; white-space: pre-wrap;
color: var(--ink-muted); color: var(--ink-muted);
@@ -358,8 +369,48 @@
scrollbar-width: thin; 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 `<use>` 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 { @keyframes think-pulse {
0%, 100% { opacity: 0.45; } 0%, 100% { opacity: 0.45; }
50% { opacity: 1; } 50% { opacity: 1; }
@@ -373,7 +424,7 @@
.tool-activity { .tool-activity {
margin: 0 0 var(--sp-3); margin: 0 0 var(--sp-3);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: color-mix(in srgb, var(--surface) 70%, transparent); background: color-mix(in srgb, var(--surface) 70%, transparent);
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -419,7 +470,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding-left: var(--sp-3); 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; min-width: 0;
} }
.tool-result__title { .tool-result__title {
@@ -511,7 +562,7 @@
gap: var(--sp-3); gap: var(--sp-3);
margin: var(--sp-3) 0; margin: var(--sp-3) 0;
padding: var(--sp-4); padding: var(--sp-4);
border: 1px solid var(--accent); border: var(--border-w) solid var(--accent);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
} }
@@ -538,7 +589,7 @@
} }
.interaction__question + .interaction__question { .interaction__question + .interaction__question {
padding-top: var(--sp-4); 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; } .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 /* Stacked, one per line. A row of chips was fine while an option was two words
@@ -556,7 +607,7 @@
align-items: flex-start; align-items: flex-start;
gap: var(--sp-3); gap: var(--sp-3);
padding: var(--sp-2) 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); border-radius: var(--radius-sm);
background: var(--surface-raised); background: var(--surface-raised);
cursor: pointer; cursor: pointer;
@@ -577,7 +628,7 @@
background: var(--surface-active); background: var(--surface-active);
} }
.interaction__option:has(input:focus-visible) { .interaction__option:has(input:focus-visible) {
outline: 2px solid var(--accent); outline: var(--outline-w) solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -618,7 +669,7 @@
align-items: center; align-items: center;
min-height: var(--control-h); min-height: var(--control-h);
padding: 0 var(--sp-3); padding: 0 var(--sp-3);
border: 1px solid var(--border-strong); border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-raised); background: var(--surface-raised);
color: var(--ink-muted); color: var(--ink-muted);
@@ -630,7 +681,7 @@
background: var(--surface-active); background: var(--surface-active);
color: var(--ink); 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 { .interaction__detail {
margin: 0; margin: 0;
padding: var(--sp-3); padding: var(--sp-3);
@@ -773,6 +824,11 @@
} }
.msg:hover .msg__actions, .msg:hover .msg__actions,
.msg:focus-within .msg__actions { opacity: 1; } .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); } .msg__actions .is-copied { color: var(--success); }
/* --- A turn nobody typed --------------------------------------------------- /* --- A turn nobody typed ---------------------------------------------------
@@ -792,7 +848,7 @@
.msg--machine .msg__author { color: var(--ink-muted); font-weight: 500; } .msg--machine .msg__author { color: var(--ink-muted); font-weight: 500; }
.msg--user.msg--machine .msg__body--plain { .msg--user.msg--machine .msg__body--plain {
background: var(--bg-sunken); 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-start-start-radius: var(--radius-sm);
border-end-start-radius: var(--radius-sm); border-end-start-radius: var(--radius-sm);
color: var(--ink-muted); color: var(--ink-muted);
@@ -835,12 +891,12 @@
.msg__body blockquote { .msg__body blockquote {
margin: 0 0 var(--sp-4); margin: 0 0 var(--sp-4);
padding: var(--sp-1) 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); color: var(--ink-muted);
font-style: italic; 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 { .msg__body :not(pre) > code {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -848,7 +904,7 @@
padding: 0.13em 0.36em; padding: 0.13em 0.36em;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--code-bg); background: var(--code-bg);
border: 1px solid var(--code-border); border: var(--border-w) solid var(--code-border);
} }
.msg__body table { .msg__body table {
@@ -860,7 +916,7 @@
overflow-x: auto; overflow-x: auto;
} }
.msg__body th, .msg__body td { .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); padding: var(--sp-2) var(--sp-3);
text-align: left; text-align: left;
} }
@@ -871,7 +927,7 @@
/* --- Code blocks ---------------------------------------------------------- */ /* --- Code blocks ---------------------------------------------------------- */
.code-block { .code-block {
margin: 0 0 var(--sp-4); margin: 0 0 var(--sp-4);
border: 1px solid var(--code-border); border: var(--border-w) solid var(--code-border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--code-bg); background: var(--code-bg);
overflow: hidden; overflow: hidden;
@@ -881,7 +937,7 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--ink-faint); 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)); background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
} }
.code-block__pre { .code-block__pre {
@@ -947,7 +1003,7 @@
flex-direction: column; flex-direction: column;
gap: var(--sp-1); gap: var(--sp-1);
padding: var(--sp-2); padding: var(--sp-2);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background: var(--surface); background: var(--surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 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 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 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. */ 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; } .jobs__row:last-child { border-bottom: 0; }
/* Which row's log is on screen. An inset shadow rather than a /* Which row's log is on screen. An inset shadow rather than a
@@ -1275,7 +1331,7 @@
max-height: min(20rem, 45vh); max-height: min(20rem, 45vh);
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--surface-raised); background: var(--surface-raised);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
@@ -1286,7 +1342,7 @@
position: sticky; position: sticky;
bottom: 0; bottom: 0;
padding: var(--sp-1) var(--sp-3); 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); background: var(--surface-raised);
color: var(--ink-faint); color: var(--ink-faint);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -1321,7 +1377,7 @@
.sheet { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } .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 { padding: var(--sp-1) var(--sp-2); vertical-align: top; }
.sheet td:first-child { white-space: nowrap; color: var(--ink-muted); width: 1%; } .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 -------------------------------------------------------------- */ /* --- Folders -------------------------------------------------------------- */
.folder__row { padding-right: var(--sp-1); } .folder__row { padding-right: var(--sp-1); }
@@ -1377,7 +1433,7 @@
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2); padding: var(--sp-2);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
max-width: 20rem; max-width: 20rem;
@@ -1452,7 +1508,7 @@
align-items: flex-start; align-items: flex-start;
gap: var(--sp-2); gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
font-size: var(--text-sm); font-size: var(--text-sm);
@@ -1531,7 +1587,7 @@
display: inline-flex; display: inline-flex;
flex: none; flex: none;
padding: 2px; padding: 2px;
border: 1px solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--bg-sunken); background: var(--bg-sunken);
} }
@@ -1563,7 +1619,7 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.segmented__option input:focus-visible + span { .segmented__option input:focus-visible + span {
outline: 2px solid var(--accent); outline: var(--outline-w) solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
/* The sidebar's copy fills its column rather than sitting at its content /* The sidebar's copy fills its column rather than sitting at its content
@@ -1576,8 +1632,8 @@
.plan { .plan {
margin: var(--sp-3) 0; margin: var(--sp-3) 0;
padding: var(--sp-4); padding: var(--sp-4);
border: 1px solid var(--border-strong); border: var(--border-w) solid var(--border-strong);
border-left: 3px solid var(--accent); border-left: var(--border-w-accent) solid var(--accent);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
} }
@@ -1651,3 +1707,22 @@
padding: var(--sp-4) 0; padding: var(--sp-4) 0;
min-height: 2.5rem; 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; }
+120 -3
View File
@@ -26,7 +26,6 @@
--text-lg: 1.125rem; --text-lg: 1.125rem;
--text-xl: 1.375rem; --text-xl: 1.375rem;
--text-2xl: 1.75rem; --text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--leading-tight: 1.25; --leading-tight: 1.25;
--leading-normal: 1.6; --leading-normal: 1.6;
@@ -42,7 +41,6 @@
--sp-8: 2rem; --sp-8: 2rem;
--sp-10: 2.5rem; --sp-10: 2.5rem;
--sp-12: 3rem; --sp-12: 3rem;
--sp-16: 4rem;
/* --- Radius & shadow -------------------------------------------------- */ /* --- Radius & shadow -------------------------------------------------- */
--radius-sm: 4px; --radius-sm: 4px;
@@ -119,12 +117,88 @@
--z-handle: 10; --z-handle: 10;
--z-dropdown: 30; --z-dropdown: 30;
--z-panel: 40; --z-panel: 40;
--z-overlay: 50;
--z-toast: 60; --z-toast: 60;
--transition-fast: 120ms ease; --transition-fast: 120ms ease;
--transition: 200ms 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 <html> 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 /* 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 resolves --accent-soft at the point of use, so it follows the theme even
though it is declared above them. */ though it is declared above them. */
@@ -322,6 +396,37 @@
--ansi-bright-white: #453A2A; --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. */ /* Respect a stated preference for reduced motion everywhere, at once. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
@@ -332,4 +437,16 @@
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
scroll-behavior: auto !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;
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+182 -5
View File
@@ -54,11 +54,20 @@
/* Installed, the browser's own chrome is the application's chrome, so it /* 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 has to follow the theme too. Read from the stylesheet rather than
repeating the hex here: tokens.css is the one place colours live. */ repeating the hex here: tokens.css is the one place colours live. */
var meta = document.querySelector('meta[name="theme-color"]'); var metas = document.querySelectorAll('meta[name="theme-color"]');
if (meta) { var bg = getComputedStyle(document.documentElement)
var bg = getComputedStyle(document.documentElement) .getPropertyValue("--bg").trim();
.getPropertyValue("--bg").trim(); if (bg) {
if (bg) meta.setAttribute("content", 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 /* 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 <html> 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) { function setPanel(selector, open, group) {
if (selector === "#sidebar") return setSidebar(open);
var panel = document.querySelector(selector); var panel = document.querySelector(selector);
if (!panel) return; if (!panel) return;
@@ -855,6 +908,10 @@
var toggle = event.target.closest("[data-toggle]"); var toggle = event.target.closest("[data-toggle]");
if (toggle) { if (toggle) {
event.preventDefault(); event.preventDefault();
if (toggle.dataset.toggle === "#sidebar") {
setSidebar(!sidebarOpen());
return;
}
var panel = document.querySelector(toggle.dataset.toggle); var panel = document.querySelector(toggle.dataset.toggle);
if (!panel) return; if (!panel) return;
setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup); setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup);
@@ -900,12 +957,132 @@
applyTheme(currentTheme()); applyTheme(currentTheme());
setupDropzone(); setupDropzone();
setupResize(); 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 /* Before first paint rather than on DOMContentLoaded, so a panel that was
dragged wider does not open at its default and jump. */ dragged wider does not open at its default and jump. */
applyWidths(); 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. */ /* After any htmx swap: re-measure the composer and follow new content. */
document.body.addEventListener("htmx:afterSwap", function () { document.body.addEventListener("htmx:afterSwap", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize); document.querySelectorAll("[data-autosize]").forEach(autosize);
+79 -6
View File
@@ -42,6 +42,12 @@ var SHELL = [
"/static/img/logo-mark.svg", "/static/img/logo-mark.svg",
"/static/img/icon-192.png", "/static/img/icon-192.png",
"/static/img/icon-512.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) { self.addEventListener("install", function (event) {
@@ -54,13 +60,40 @@ self.addEventListener("install", function (event) {
return cache.add(new Request(path, { cache: "reload" })).catch(function () {}); 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) { self.addEventListener("activate", function (event) {
event.waitUntil( 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( return Promise.all(
names.map(function (name) { names.map(function (name) {
if (name !== CACHE && name.indexOf("lembas-") === 0) return caches.delete(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") { if (request.mode === "navigate") {
event.respondWith( event.respondWith(
fetch(request).catch(function () { Promise.resolve(event.preloadResponse)
return caches.match("/offline"); .then(function (preloaded) { return preloaded || fetch(request); })
}) .catch(function () { return caches.match("/offline"); })
); );
return; return;
} }
@@ -158,13 +191,53 @@ self.addEventListener("push", function (event) {
tag: "lembas-" + (payload.kind || "unread"), tag: "lembas-" + (payload.kind || "unread"),
renotify: true, renotify: true,
icon: "/static/img/icon-192.png", 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 || "/" }, 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. Clicking one.
+19 -1
View File
@@ -47,6 +47,20 @@
var toast = el("div", "toast toast--" + (options.kind || "info")); var toast = el("div", "toast toast--" + (options.kind || "info"));
toast.appendChild(el("span", "toast__text", message)); 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"); var close = el("button", "toast__close");
close.type = "button"; close.type = "button";
close.setAttribute("aria-label", "Dismiss"); close.setAttribute("aria-label", "Dismiss");
@@ -58,7 +72,11 @@
// Next frame, so the entry transition has a state to move from. // Next frame, so the entry transition has a state to move from.
requestAnimationFrame(function () { toast.classList.add("is-in"); }); 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); if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout);
return toast; return toast;
} }
@@ -78,7 +78,7 @@
<input class="input input--mono" id="unload-{{ connection.id }}" name="unload_url" <input class="input input--mono" id="unload-{{ connection.id }}" name="unload_url"
value="{{ connection.unload_url }}" placeholder="No unload call" value="{{ connection.unload_url }}" placeholder="No unload call"
style="flex: 1; min-width: 0"> style="flex: 1; min-width: 0">
<select class="select" name="unload_method" style="flex: none"> <select class="select" name="unload_method" aria-label="How to ask it to unload" style="flex: none">
<option value="POST" {{ 'selected' if connection.unload_method != 'GET' }}>POST</option> <option value="POST" {{ 'selected' if connection.unload_method != 'GET' }}>POST</option>
<option value="GET" {{ 'selected' if connection.unload_method == 'GET' }}>GET</option> <option value="GET" {{ 'selected' if connection.unload_method == 'GET' }}>GET</option>
</select> </select>
@@ -91,6 +91,20 @@
</p> </p>
</div> </div>
<div class="field">
<label class="field__label" for="headers-{{ connection.id }}">Extra headers</label>
<textarea class="textarea input--mono" id="headers-{{ connection.id }}"
name="extra_headers" rows="2"
placeholder="HTTP-Referer: https://example.org">{% for name, value in (connection.extra_headers_json or {}).items() %}{{ name }}: {{ value }}
{% endfor %}</textarea>
<p class="field__hint">
One <code>Name: value</code> per line, sent with every request to this
endpoint. OpenRouter reads <code>HTTP-Referer</code> and
<code>X-Title</code> and attributes your usage with them. Leave it empty
unless an endpoint has asked for something.
</p>
</div>
<div class="field"> <div class="field">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="enabled" value="true" <input type="checkbox" name="enabled" value="true"
+1 -1
View File
@@ -39,7 +39,7 @@
#} #}
<form method="post" action="/admin/prompts" id="prompt-form"> <form method="post" action="/admin/prompts" id="prompt-form">
<div class="tabs"> <div class="tabs">
<div class="tabs__bar" role="tablist"> <div class="tabs__bar" role="radiogroup" aria-label="Prompt groups">
{% for key, label, fragments in groups %} {% for key, label, fragments in groups %}
<input class="visually-hidden" type="radio" name="prompts-tab" <input class="visually-hidden" type="radio" name="prompts-tab"
id="tab-{{ key }}" {{ 'checked' if loop.first }}> id="tab-{{ key }}" {{ 'checked' if loop.first }}>
@@ -32,7 +32,8 @@
<button class="btn btn--sm btn--danger" type="submit" <button class="btn btn--sm btn--danger" type="submit"
formaction="/admin/suggestions/{{ suggestion.id }}/delete" formaction="/admin/suggestions/{{ suggestion.id }}/delete"
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?" data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
data-confirm-title="Delete suggestion"> data-confirm-title="Delete suggestion"
aria-label="Delete suggestion" title="Delete suggestion">
{{ icon("trash", "icon--sm") }} {{ icon("trash", "icon--sm") }}
</button> </button>
</div> </div>
@@ -21,6 +21,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{% block heading %}Connections{% endblock %}</h1> <h1 class="topbar__title">{% block heading %}Connections{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div> <div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header> </header>
+36 -2
View File
@@ -13,7 +13,15 @@
data-themes="{{ brand.theme_list }}"{% if layout %} style="{{ layout }}"{% endif %}> data-themes="{{ brand.theme_list }}"{% if layout %} style="{{ layout }}"{% endif %}>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> {#
`viewport-fit=cover` is what lets `env(safe-area-inset-*)` resolve to
anything but zero, and without it the `black-translucent` status bar style
below is a promise with nothing behind it: iOS puts the page under the clock
and the notch and the tokens that would have paid for it stay at 0.
No `maximum-scale` and no `user-scalable=no` -- pinch-zoom is somebody's
accessibility setting, not a layout problem to be suppressed.
#}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{% block title %}{{ brand.name }}{% endblock %}</title> <title>{% block title %}{{ brand.name }}{% endblock %}</title>
<meta name="description" content="{{ brand.tagline or brand.name ~ ' — a web UI for your language models.' }}"> <meta name="description" content="{{ brand.tagline or brand.name ~ ' — a web UI for your language models.' }}">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
@@ -36,7 +44,16 @@
only what the browser paints with before the stylesheet has resolved. only what the browser paints with before the stylesheet has resolved.
#} #}
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#101317"> {#
Two, scoped by preference, so the browser has an answer before any of our CSS
or JavaScript has run. There used to be one and it was Moria's near-black, so
every reader of the light theme got a dark browser chrome on every page load
until `app.js` -- which is deferred -- corrected it. `applyTheme` still has
the last word, and still reads the value from `--bg` rather than repeating a
hex here; these two are only what is painted before it can.
#}
<meta name="theme-color" content="#101317" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F6F1E4" media="(prefers-color-scheme: light)">
{% if brand.icon_paths['apple-touch'] %} {% if brand.icon_paths['apple-touch'] %}
<link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}"> <link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}">
{% else %} {% else %}
@@ -92,6 +109,23 @@
<body{% block body_attrs %}{% endblock %}> <body{% block body_attrs %}{% endblock %}>
{% include "partials/icons.html" %} {% include "partials/icons.html" %}
{#
Every fetch this application makes, said out loud.
htmx has had `htmx-request` on the triggering element since the beginning and
nothing here has ever used it, so a click that saved a setting, opened a
panel or loaded a page of a list looked exactly like a click that did nothing
until the answer arrived. On a local endpoint that is a few milliseconds and
on anything else it is long enough to click again.
One bar for the whole page rather than a spinner per control: the interesting
question is "is the application busy", and an indicator on the control would
need adding to every control ever written, which is how the last one came to
be used nowhere. `aria-hidden` because the answer arriving is the thing worth
announcing, and htmx already moves focus for that.
#}
<div class="progress" data-progress aria-hidden="true"><span></span></div>
{% block body %}{% endblock %} {% block body %}{% endblock %}
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script> <script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
+1 -4
View File
@@ -18,10 +18,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar" {% include "partials/_sidebar_toggle.html" %}
aria-expanded="true" data-toggle="#sidebar">
{{ icon("sidebar") }}
</button>
<h1 class="topbar__title"> <h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span> <span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
@@ -29,6 +29,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title"> <h1 class="topbar__title">
{{ icon("folder", "icon--sm") }} {{ icon("folder", "icon--sm") }}
<span>{{ folder.name }}</span> <span>{{ folder.name }}</span>
@@ -21,6 +21,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{% block heading %}Library{% endblock %}</h1> <h1 class="topbar__title">{% block heading %}Library{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div> <div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header> </header>
+9 -1
View File
@@ -21,8 +21,16 @@
hx-trigger="load" hx-trigger="load"
hx-target="this" hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML">
{# The shape of what is coming, rather than an ellipsis that says only that
something is missing. `aria-hidden` and a `role="status"` label beside it,
because a paragraph of grey blocks is nothing to read aloud. #}
<section class="card"> <section class="card">
<h2 class="card__title">Shared with <span class="badge">…</span></h2> <h2 class="card__title">Shared with</h2>
<span class="visually-hidden" role="status">Loading who this is shared with</span>
<div aria-hidden="true">
<div class="skeleton skeleton--row"></div>
<div class="skeleton skeleton--row skeleton--short"></div>
</div>
</section> </section>
</div> </div>
{% elif not is_owner %} {% elif not is_owner %}
@@ -24,7 +24,11 @@
hx-target="this" hx-target="this"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-sync="this:drop"> hx-sync="this:drop">
<span class="text-xs faint">Loading earlier messages…</span> <span class="visually-hidden" role="status">Loading earlier messages</span>
<div class="history-sentinel__shape" aria-hidden="true">
<div class="skeleton skeleton--line"></div>
<div class="skeleton skeleton--line skeleton--short"></div>
</div>
</div> </div>
{% endif %} {% endif %}
+6 -1
View File
@@ -24,6 +24,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{{ icon("chat", "icon--sm") }} Messages</h1> <h1 class="topbar__title">{{ icon("chat", "icon--sm") }} Messages</h1>
<div class="topbar__actions"> <div class="topbar__actions">
{% if schedules %} {% if schedules %}
@@ -78,7 +79,11 @@
hx-target="this" hx-target="this"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-sync="this:drop"> hx-sync="this:drop">
<span class="text-xs faint">Loading earlier messages…</span> <span class="visually-hidden" role="status">Loading earlier messages</span>
<div class="history-sentinel__shape" aria-hidden="true">
<div class="skeleton skeleton--line"></div>
<div class="skeleton skeleton--line skeleton--short"></div>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -27,6 +27,20 @@
aria-label="Rename chat" title="Rename chat"> aria-label="Rename chat" title="Rename chat">
{{ icon("pencil", "icon--sm") }} {{ icon("pencil", "icon--sm") }}
</button> </button>
{#
Archiving, and un-archiving, are the same control reading the opposite
way round -- so one button, and the sidebar re-renders because a row has
to leave one group and appear in the other.
#}
<button class="btn btn--icon btn--sm" type="button"
hx-patch="/api/chats/{{ chat_item.id }}"
hx-vals='{"archived": "{{ 0 if chat_item.archived else 1 }}"}'
hx-target="#sidebar-tree" hx-swap="outerHTML"
aria-label="{{ 'Restore chat' if chat_item.archived else 'Archive chat' }}"
title="{{ 'Put this chat back in the list' if chat_item.archived
else 'Hide this chat without deleting it' }}">
{{ icon("arrow-up" if chat_item.archived else "archive", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" <button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/chats/{{ chat_item.id }}" hx-delete="/api/chats/{{ chat_item.id }}"
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone." hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
@@ -0,0 +1,19 @@
{% from "_macros.html" import icon %}
{#
The control that opens and closes the sidebar.
A partial rather than markup in each topbar, because for most of this
application's life it existed on `/chat` alone -- and below the phone
breakpoint the sidebar is a fixed overlay, so every other page rendered 280px
of opaque drawer over itself with nothing anywhere to dismiss it. `/settings`
was one of them, which is where the Install and Notifications buttons live.
`aria-expanded` is deliberately absent rather than `"true"`: it used to be
hard-coded open, which is a lie the moment anything closes the drawer, and
`app.js:syncToggles` writes the honest value on load and on every change.
#}
<button class="btn btn--icon sidebar-toggle" type="button"
aria-label="Toggle sidebar" aria-controls="sidebar"
data-toggle="#sidebar">
{{ icon("sidebar") }}
</button>
@@ -87,4 +87,26 @@
</p> </p>
{% endif %} {% endif %}
</div> </div>
{#
Archived chats, closed, and absent entirely when there are none.
A `<details>` 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 %}
<details class="nav-group nav-group--archived">
<summary class="nav-group__label">
{{ icon("archive", "icon--sm") }} Archived
<span class="nav-group__count">{{ archived_chats|length }}</span>
</summary>
{% for chat_item in archived_chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
</details>
{% endif %}
</div> </div>
@@ -9,6 +9,22 @@
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar__header"> <div class="sidebar__header">
{{ brandlink(uid="side") }} {{ brandlink(uid="side") }}
{#
The way out, and the reason it is *inside* the drawer.
Below the phone breakpoint this whole element is a fixed overlay, and the
toggle that opens it lives in the topbar underneath -- so once it was
open, the control for closing it was behind it. That was true on /chat,
where at least a toggle existed; on the seven other pages that carry this
sidebar there was no such control at all, and no way back.
Hidden above that breakpoint, where the sidebar is an ordinary column and
the topbar's toggle is perfectly visible.
#}
<button class="btn btn--icon sidebar__close" type="button"
aria-label="Close sidebar" data-toggle="#sidebar">
{{ icon("x") }}
</button>
</div> </div>
{% include "partials/_sidebar_actions.html" %} {% include "partials/_sidebar_actions.html" %}
@@ -47,6 +63,26 @@
</a> </a>
<div class="sidebar__tools"> <div class="sidebar__tools">
{#
Installing.
The only one of these was in the Appearance tab of /settings, which on
a phone is behind a drawer that used to be impossible to close and a tab
strip that gave no sign of scrolling -- so the button for installing
this on a phone was, on a phone, three taps into a place you could not
get to. It stays there as well; this is simply where somebody will meet
it.
Hidden until the browser says the app is installable: `app.js` reveals
every `[data-install-app]` when `beforeinstallprompt` fires, and hides
them again once it is installed. Firefox and desktop Safari never fire
it, and an Install button that does nothing is worse than none.
#}
<button class="btn btn--icon" type="button" data-install-app hidden
onclick="window.lembas.promptInstall()"
aria-label="Install as an app" title="Install as an app">
{{ icon("arrow-down") }}
</button>
{% if user.is_admin %} {% if user.is_admin %}
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings"> <a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
{{ icon("shield") }} {{ icon("shield") }}
@@ -66,3 +102,14 @@
</div> </div>
</div> </div>
</aside> </aside>
{#
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.
#}
<div class="sidebar-scrim" data-toggle="#sidebar" aria-hidden="true"></div>
@@ -27,6 +27,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{% block heading %}Reports{% endblock %}</h1> <h1 class="topbar__title">{% block heading %}Reports{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div> <div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header> </header>
@@ -20,6 +20,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{% block heading %}Scheduled{% endblock %}</h1> <h1 class="topbar__title">{% block heading %}Scheduled{% endblock %}</h1>
<div class="topbar__actions">{% block actions %}{% endblock %}</div> <div class="topbar__actions">{% block actions %}{% endblock %}</div>
</header> </header>
+19 -4
View File
@@ -16,6 +16,7 @@
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">Your settings</h1> <h1 class="topbar__title">Your settings</h1>
</header> </header>
@@ -25,7 +26,18 @@
state. Each panel is a real fragment of the page, not a fetch. state. Each panel is a real fragment of the page, not a fetch.
#} #}
<div class="tabs"> <div class="tabs">
<div class="tabs__bar" role="tablist"> {#
Deliberately no `role="tablist"`.
It had one, and the children are `<input type="radio">` and `<label>` -- so a
screen reader announced a tablist containing no tabs, and the panels carried
neither `role="tabpanel"` nor an `aria-labelledby` to be announced as. What
this actually is, is a radio group, and a perfectly good one: arrow keys move
between the options, the checked one is announced, and the CSS that reveals
the matching panel keys off exactly that. Being an honest radio group beats
claiming to be a tab interface and then not behaving as one.
#}
<div class="tabs__bar" role="radiogroup" aria-label="Settings sections">
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked> <input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label> <label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
@@ -196,7 +208,7 @@
redirect back is what stops a refresh re-submitting it. redirect back is what stops a refresh re-submitting it.
#} #}
<form method="post" action="/api/preferences/timezone" class="btn-row"> <form method="post" action="/api/preferences/timezone" class="btn-row">
<select class="select" name="timezone" style="flex: 1"> <select class="select" name="timezone" style="flex: 1" aria-label="Your timezone">
<option value="" {{ 'selected' if not timezone }}> <option value="" {{ 'selected' if not timezone }}>
Follow the server ({{ server_timezone }}) Follow the server ({{ server_timezone }})
</option> </option>
@@ -367,12 +379,14 @@
<form method="post" action="/api/library/memories/{{ memory.id }}" <form method="post" action="/api/library/memories/{{ memory.id }}"
class="row" style="flex: 1; gap: var(--sp-2); min-width: 0"> class="row" style="flex: 1; gap: var(--sp-2); min-width: 0">
<input class="input" name="content" value="{{ memory.content }}" <input class="input" name="content" value="{{ memory.content }}"
maxlength="{{ memory_limit }}" style="flex: 1"> maxlength="{{ memory_limit }}" style="flex: 1"
aria-label="What this memory says">
<button class="btn btn--sm" type="submit">Save</button> <button class="btn btn--sm" type="submit">Save</button>
<button class="btn btn--sm btn--danger" type="submit" <button class="btn btn--sm btn--danger" type="submit"
formaction="/api/library/memories/{{ memory.id }}/delete" formaction="/api/library/memories/{{ memory.id }}/delete"
data-confirm-button="Forget this?" data-confirm-button="Forget this?"
data-confirm-title="Forget"> data-confirm-title="Forget"
aria-label="Forget this" title="Forget this">
{{ icon("trash", "icon--sm") }} {{ icon("trash", "icon--sm") }}
</button> </button>
</form> </form>
@@ -396,6 +410,7 @@
style="gap: var(--sp-2)"> style="gap: var(--sp-2)">
<input class="input" name="content" required style="flex: 1" <input class="input" name="content" required style="flex: 1"
maxlength="{{ memory_limit }}" maxlength="{{ memory_limit }}"
aria-label="Something worth remembering"
placeholder="Prefers metric units and a 24-hour clock."> placeholder="Prefers metric units and a 24-hour clock.">
<button class="btn btn--primary" type="submit">Remember</button> <button class="btn btn--primary" type="submit">Remember</button>
</form> </form>
+110
View File
@@ -0,0 +1,110 @@
"""Archiving a chat, which the column has been filtered on and never written.
`Chat.archived` is read in four places, always `is_(False)`, and was set to True
by nothing anywhere in `src/` -- so the hiding shipped and the archiving did
not, and the column read as a built feature to anybody who grepped for it.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from lembas.db.models import Chat
def test_a_chat_can_be_archived(client: TestClient, db, registered, make_chat):
chat_id = make_chat()
response = client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
assert response.status_code == 200
db.expire_all()
assert db.get(Chat, chat_id).archived is True
def test_archiving_answers_with_a_sidebar_the_browser_can_swap_in(
client: TestClient, db, registered, make_chat
):
"""`update_chat` answers 204 for everything else, and htmx's own config is
`{code: "204", swap: false}` -- so a button aimed at `#sidebar-tree` set the
column and then did visibly nothing until the next page load.
Asserted on the *response*, because the obvious test -- archive, then load
the page, then look -- passes against both versions. It is the control doing
nothing that has to be caught, not the column failing to change.
"""
chat_id = make_chat()
response = client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
assert response.status_code == 200
assert 'id="sidebar-tree"' in response.text
assert "nav-group--archived" in response.text
assert chat_id in response.text
def test_a_patch_that_changes_nothing_still_answers_204(
client: TestClient, db, registered, make_chat
):
"""Re-rendering the sidebar for every PATCH would put the tree in the reply
to a rename, a folder move and a model change as well -- none of which
asked for it, and one of which already answers with its own fragment."""
chat_id = make_chat()
assert client.patch(
f"/api/chats/{chat_id}", data={"archived": "0"}
).status_code == 204
assert client.patch(
f"/api/chats/{chat_id}", data={"model_id": ""}
).status_code == 204
def test_it_can_be_put_back(client: TestClient, db, registered, make_chat):
"""An archive with no way out is a delete that lies about itself."""
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
client.patch(f"/api/chats/{chat_id}", data={"archived": "0"})
db.expire_all()
assert db.get(Chat, chat_id).archived is False
def test_leaving_the_field_out_leaves_it_alone(client: TestClient, db, registered, make_chat):
"""`update_chat` reads the raw form precisely so that absent and empty are
different things, and every other field there honours it."""
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
client.patch(f"/api/chats/{chat_id}", data={"title": "Still here"})
db.expire_all()
chat = db.get(Chat, chat_id)
assert chat.archived is True
assert chat.title == "Still here"
def test_an_archived_chat_leaves_the_list_and_joins_the_other_one(
client: TestClient, db, registered, make_chat
):
chat_id = make_chat()
page = client.get("/chat").text
assert chat_id in page
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
page = client.get("/chat").text
# Still reachable -- in the Archived group, which is the whole difference
# between archiving and deleting.
assert "nav-group--archived" in page
assert chat_id in page
def test_the_group_is_absent_when_nothing_is_in_it(client: TestClient, registered, make_chat):
make_chat()
assert "nav-group--archived" not in client.get("/chat").text
def test_nobody_else_may_archive_your_chat(client: TestClient, db, registered, make_chat):
"""`_owned_chat` is what stops it, and this is the test that says so."""
chat_id = make_chat()
client.cookies.clear()
# `follow_redirects=False`, or the redirect to the sign-in page is followed
# and the 200 that comes back reads as the request having succeeded.
response = client.patch(
f"/api/chats/{chat_id}", data={"archived": "1"}, follow_redirects=False
)
assert response.status_code in (401, 403, 404, 303, 307)
db.expire_all()
assert db.get(Chat, chat_id).archived is False
+88 -7
View File
@@ -446,7 +446,15 @@ def test_an_archived_chat_inside_a_folder_is_not_listed(
db.commit() db.commit()
page = client.get("/chat").text page = client.get("/chat").text
assert "Mount Doom" not in page
# The original guarantee, and now a narrower assertion than "nowhere on the
# page": archiving puts a chat in the Archived group, so it IS on the page
# -- being able to find it again is the difference between archiving it and
# deleting it. What must not happen is it still showing inside its folder,
# which is the bug this test was written for.
before_archived = page.split('nav-group--archived', 1)[0]
assert "Mount Doom" not in before_archived
assert "Mount Doom" in page
# And the folder must say so, rather than claiming to hold something. # And the folder must say so, rather than claiming to hold something.
assert "Empty" in page assert "Empty" in page
@@ -959,16 +967,89 @@ def test_the_agent_controls_shrink_rather_than_pushing_send_off_the_row(
assert opens < html.index(control) < actions, control assert opens < html.index(control) < actions, control
def test_the_chat_stylesheet_has_no_media_queries(client: TestClient): def _chat_css() -> str:
"""A stated design constraint, pinned so nobody 'fixes' a layout with a
breakpoint later. The composer fits at every width by saying which child
gives, not by rearranging itself at a threshold."""
from pathlib import Path from pathlib import Path
import lembas import lembas
css = Path(lembas.__file__).parent / "web/static/css/chat.css" return (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text()
assert "@media" not in css.read_text()
def test_the_composer_toolbar_can_never_wrap(client: TestClient):
"""This is what the old blanket ban on `@media` in this file was protecting.
The toolbar used to wrap, and `.composer__actions` is last in the DOM with
`margin-left: auto` -- so the moment an agent chat added a connection, a
directory and a mode to the row, Send and the microphone were what dropped
to a second line. The fix was to say which child gives, not to rearrange the
row at a threshold, and the test that pinned it refused every media query in
the file so that nobody would "fix" a regression with a breakpoint instead.
The ban outlived its usefulness: a phone needs bigger targets and different
spacing, and refusing all width- and pointer-awareness here made the file
unable to say so. What it was *actually* protecting is asserted directly
now, which is both narrower and stronger -- the old test would have passed a
version of this file that wrapped the toolbar without a media query.
"""
css = _chat_css()
toolbar = css.split(".composer__toolbar {", 1)[1].split("}", 1)[0]
assert "flex-wrap: nowrap" in toolbar
actions = css.split(".composer__actions {", 1)[1].split("}", 1)[0]
assert "flex: none" in actions
assert "flex-wrap" not in actions
# The one child allowed to give, and the reason the rest never have to.
context = css.split(".composer__context {", 1)[1].split("}", 1)[0]
assert "min-width: 0" in context
assert "overflow-x: auto" in context
def _media_blocks(css: str) -> list[str]:
"""Each `@media` block's own contents, by balancing braces.
Splitting on "@media" and taking what follows gives everything to the end of
the file, so a test written that way asserts about the whole stylesheet
while appearing to be about one block -- and fails on a rule three hundred
lines below the query.
"""
blocks = []
for start in (i for i in range(len(css)) if css.startswith("@media", i)):
opened = css.index("{", start)
depth, cursor = 0, opened
while cursor < len(css):
if css[cursor] == "{":
depth += 1
elif css[cursor] == "}":
depth -= 1
if depth == 0:
break
cursor += 1
blocks.append(css[opened + 1 : cursor])
return blocks
def test_no_breakpoint_may_undo_the_toolbar_rule(client: TestClient):
"""A media query in this file is allowed; one that lets the toolbar wrap or
lets the actions shrink is the original bug with a threshold in front of
it."""
for body in _media_blocks(_chat_css()):
assert "flex-wrap: wrap" not in body
assert ".composer__actions" not in body or "flex: none" in body
def test_width_awareness_in_this_file_is_deliberate(client: TestClient):
"""Every media query here carries a comment immediately above it.
The replacement for "none allowed": a breakpoint in this file has to say why
it exists, because the failure this file is shaped around is somebody
reaching for one instead of fixing the sizing.
"""
css = _chat_css()
for index, line in enumerate(css.splitlines()):
if line.strip().startswith("@media"):
above = "\n".join(css.splitlines()[max(0, index - 12):index])
assert "*" in above, f"undocumented @media at line {index + 1}"
def _user_id(db): def _user_id(db):
+85
View File
@@ -0,0 +1,85 @@
"""Extra headers on a connection: read on every request, written by no form.
`Connection.extra_headers_json` has been sent with every request to an endpoint
since it was added and there was nowhere to set it, so its one documented use --
OpenRouter reads `HTTP-Referer` and `X-Title` and attributes usage with them --
was unreachable. Nothing advertised it, so nothing was untrue; it was simply a
column that could only ever be empty.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from lembas.db.models import Connection
def _connection(client: TestClient, db):
client.post(
"/admin/connections",
data={"name": "OpenRouter", "base_url": "http://127.0.0.1:1", "api_key": ""},
follow_redirects=False,
)
from sqlalchemy import select
return db.scalars(select(Connection)).first().id
def _save(client: TestClient, connection_id: str, headers: str):
return client.post(
f"/admin/connections/{connection_id}",
data={
"name": "OpenRouter",
"base_url": "http://127.0.0.1:1",
"api_key": "",
"enabled": "on",
"unload_url": "",
"unload_method": "POST",
"extra_headers": headers,
},
follow_redirects=False,
)
def test_headers_are_stored_as_a_dict(client: TestClient, db, registered):
"""Asserted on the row, not on the form: a field that renders and is never
read looks exactly like one that works."""
cid = _connection(client, db)
_save(client, cid, "HTTP-Referer: https://example.org\nX-Title: LLeMbas")
db.expire_all()
stored = db.get(Connection, cid).extra_headers_json
assert stored == {
"HTTP-Referer": "https://example.org",
"X-Title": "LLeMbas",
}
def test_they_reach_the_endpoint(client: TestClient, db, registered):
"""The whole point. `openai_client` passes them to httpx verbatim."""
cid = _connection(client, db)
_save(client, cid, "X-Title: LLeMbas")
db.expire_all()
from lembas.services.llm.openai_client import Endpoint
endpoint = Endpoint.from_connection(db.get(Connection, cid))
assert endpoint.extra_headers["X-Title"] == "LLeMbas"
def test_clearing_the_box_clears_them(client: TestClient, db, registered):
cid = _connection(client, db)
_save(client, cid, "X-Title: LLeMbas")
_save(client, cid, "")
db.expire_all()
assert db.get(Connection, cid).extra_headers_json == {}
def test_a_name_cannot_smuggle_in_a_second_header(client: TestClient, db, registered):
"""One field must write one header. A colon or a newline in a *name* is how
one becomes two, and a header nobody can see the effect of is worse than one
that is visibly missing -- so a bad line is dropped, never repaired."""
cid = _connection(client, db)
_save(client, cid, "Bad Name: x\nX-Ok: y\n: nothing\nAlso-Bad\n")
db.expire_all()
assert db.get(Connection, cid).extra_headers_json == {"X-Ok": "y"}
+47
View File
@@ -72,3 +72,50 @@ def test_the_canvas_starts_wider_than_the_terminal():
for name in PANELS for name in PANELS
} }
assert widths["--canvas-width"] > widths["--terminal-width"] assert widths["--canvas-width"] > widths["--terminal-width"]
# --- Breakpoints -------------------------------------------------------------
# A media query cannot read a custom property, so the three widths this
# application breaks at are literals in three stylesheets with nothing tying
# them to the tokens that name them. Which is fine until somebody adds a fourth
# in passing, and then there are four breakpoints and a comment describing
# three.
def _breakpoints_used() -> set[str]:
"""Widths that appear in an `@media` condition, and nowhere else.
Scoped to the condition on purpose: `max-width` is also an ordinary
declaration -- `.composer__dir` is capped at 11rem, `.picker__menu` at
14rem -- and a pattern that reads every one of them calls two dozen
component caps "breakpoints" and fails on all of them.
"""
import re
used: set[str] = set()
for name in ("app.css", "chat.css", "admin.css"):
text = (ROOT / "web/static/css" / name).read_text(encoding="utf-8")
for condition in re.findall(r"@media([^{]*)\{", text):
used.update(re.findall(r"max-width:\s*([\d.]+rem)", condition))
return used
def test_every_breakpoint_is_one_of_the_declared_ones():
import re
declared = set(re.findall(r"--bp-[a-z]+:\s*([\d.]+rem)", TOKENS))
assert declared, "no --bp-* tokens declared"
used = _breakpoints_used()
assert used <= declared, (
f"breakpoints used but not declared in tokens.css: {sorted(used - declared)}"
)
def test_no_breakpoint_is_declared_and_never_used():
"""The other direction: a token naming a width nothing breaks at is the
same clutter as a colour nothing paints with."""
import re
declared = set(re.findall(r"--bp-[a-z]+:\s*([\d.]+rem)", TOKENS))
assert declared <= _breakpoints_used(), (
f"declared and unused: {sorted(declared - _breakpoints_used())}"
)
+99
View File
@@ -165,3 +165,102 @@ def test_the_mic_appears_only_when_dictation_is_configured(
key=settings_store.AUDIO, key=settings_store.AUDIO,
) )
assert "data-mic" in client.get("/chat").text assert "data-mic" in client.get("/chat").text
# --- The manifest, beyond the installability minimum -------------------------
def test_the_manifest_offers_launcher_shortcuts(client: TestClient):
"""A long-press on the launcher icon should reach the three places worth
going to directly. Absent, it offers nothing."""
payload = client.get("/manifest.webmanifest").json()
urls = {s["url"] for s in payload["shortcuts"]}
assert urls == {"/chat", "/messages", "/scheduled"}
def test_the_manifest_identity_matches_where_it_starts(client: TestClient):
"""`id` was "/", which serves nothing but a redirect, while the app started
at /chat. Legal, and it reads as a mistake to anyone comparing the two."""
payload = client.get("/manifest.webmanifest").json()
assert payload["id"] == payload["start_url"]
def test_the_manifest_declares_the_rest_of_the_quality_set(client: TestClient):
payload = client.get("/manifest.webmanifest").json()
for key in ("orientation", "categories", "lang", "dir",
"display_override", "launch_handler"):
assert key in payload, key
def test_the_splash_follows_the_instance_theme(client: TestClient, monkeypatch):
"""It was Moria's near-black whatever the instance was set up in, so a
parchment instance installed to a phone flashed dark and opened light --
and `THEME_COLOUR["shire"]` sat beside it, defined and read by nothing."""
from lembas.config import settings
monkeypatch.setattr(settings, "default_theme", "shire")
payload = client.get("/manifest.webmanifest").json()
assert payload["theme_color"] == "#F6F1E4"
assert payload["background_color"] == payload["theme_color"]
def test_the_page_paints_the_right_chrome_before_any_script_runs(client, registered):
"""One unscoped `theme-color` meant a light-theme reader got dark browser
chrome on every load until the deferred script corrected it."""
page = client.get("/chat").text
assert 'media="(prefers-color-scheme: dark)"' in page
assert 'media="(prefers-color-scheme: light)"' in page
# --- The worker --------------------------------------------------------------
def test_the_worker_does_not_take_over_a_page_being_read():
"""It called skipWaiting() unconditionally, so a release replaced the
assets under an open tab mid-session. It waits to be asked now."""
import re
source = (STATIC_DIR / "js" / "sw.js").read_text()
# Comments stripped first: the install handler explains at length that it
# deliberately does not call this, and a test that reads prose would fail
# on the explanation for the fix.
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
code = re.sub(r"//[^\n]*", "", code)
install = code.split('addEventListener("install"', 1)[1].split("addEventListener(", 1)[0]
assert "skipWaiting" not in install
assert 'event.data.type === "SKIP_WAITING"' in code
def test_the_worker_survives_a_rotated_subscription():
"""A browser replacing a subscription on its own is the normal way push
stops working, and nothing anywhere said so."""
source = (STATIC_DIR / "js" / "sw.js").read_text()
assert "pushsubscriptionchange" in source
def test_the_badge_is_not_the_full_colour_icon():
"""A badge is drawn as a mask -- the device keeps the alpha and throws the
colour away -- so an icon opaque to its edges renders as a grey square."""
source = (STATIC_DIR / "js" / "sw.js").read_text()
assert 'badge: "/static/img/badge-72.png"' in source
badge = Path(STATIC_DIR) / "img" / "badge-72.png"
assert badge.exists() and badge.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
def test_the_two_icons_a_device_crops_are_cached():
source = (STATIC_DIR / "js" / "sw.js").read_text()
shell = source.split("var SHELL = [", 1)[1].split("];", 1)[0]
assert "icon-maskable-512.png" in shell
assert "apple-touch-icon-180.png" in shell
# --- The window's own edges --------------------------------------------------
def test_the_page_asks_for_the_whole_screen_and_then_pays_for_it(client, registered):
"""`viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
anything but zero, and `black-translucent` below it is what puts the page
under the status bar in the first place. One without the other is a topbar
beneath the clock."""
page = client.get("/chat").text
assert "viewport-fit=cover" in page
css = (STATIC_DIR / "css" / "tokens.css").read_text()
assert "safe-area-inset-top" in css
app = (STATIC_DIR / "css" / "app.css").read_text()
assert "var(--safe-top)" in app
assert "var(--safe-bottom)" in app
+115
View File
@@ -0,0 +1,115 @@
"""The sidebar as a drawer: closed by default where it covers the page.
Below the phone breakpoint the sidebar is a fixed 280px overlay. It was
rendered with no `hidden` attribute at any width and nothing ever set one on
load, so on a 390px phone it covered the page from first paint -- with the only
control that could close it, the topbar's toggle, underneath it. And that toggle
existed on `/chat` alone: the seven other pages carrying the sidebar had no
dismiss control of any kind.
"""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
import lembas
ROOT = Path(lembas.__file__).parent
TEMPLATES = ROOT / "web/templates"
APP_CSS = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
APP_JS = (ROOT / "web/static/js/app.js").read_text(encoding="utf-8")
# Every page that renders the sidebar.
CARRIERS = [
"settings.html",
"chat/index.html",
"reports/_layout.html",
"messages/index.html",
"schedules/_layout.html",
"library/_layout.html",
"agents/_layout.html",
"folders/edit.html",
]
def test_every_page_with_a_sidebar_has_a_way_to_close_it():
"""`grep -rn 'data-toggle="#sidebar"'` returned exactly one hit, and the
other seven pages were unusable on a phone because of it."""
missing = [
name
for name in CARRIERS
if "partials/_sidebar_toggle.html" not in (TEMPLATES / name).read_text(encoding="utf-8")
]
assert not missing, f"no sidebar toggle on: {missing}"
def test_the_toggle_is_one_partial_and_not_eight_copies():
"""The next control added to a topbar should not have to be added eight
times, which is how the first one came to exist once."""
toggle = (TEMPLATES / "partials/_sidebar_toggle.html").read_text(encoding="utf-8")
assert 'data-toggle="#sidebar"' in toggle
assert 'aria-label="Toggle sidebar"' in toggle
def test_the_toggle_does_not_claim_to_be_open():
"""It rendered `aria-expanded="true"` from the template -- a fact nobody
checked and one that was false on every phone. `syncToggles` writes it."""
toggle = (TEMPLATES / "partials/_sidebar_toggle.html").read_text(encoding="utf-8")
# The element, not the file: the comment above it explains at length why
# the attribute is absent, and a test reading the file fails on the
# explanation for the fix.
button = toggle.split("<button", 1)[1].split(">", 1)[0]
assert "aria-expanded" not in button
assert 'syncToggles("#sidebar"' in APP_JS
def test_the_drawer_is_closed_by_default_only_where_it_is_a_drawer():
"""Three states, and the third is the one that matters: absent means
"follow the width", which is what the server renders because the server
does not know the width."""
assert 'data-sidebar="open"' in APP_CSS
assert 'data-sidebar="closed"' in APP_CSS
assert 'return !window.matchMedia(NARROW).matches;' in APP_JS
def test_the_close_button_is_reachable_inside_the_open_drawer():
"""`.sidebar__close` is `display: none` at width and turned back on inside
the media query. Both rules are one class deep, so the order decides -- and
written the other way round the button is invisible at every width,
including inside the drawer it exists for."""
base = APP_CSS.index("\n.sidebar__close {")
inside = APP_CSS.index(" .sidebar__close {")
assert base < inside, "the base rule must come first or it wins everywhere"
def test_nothing_behind_the_drawer_can_be_tabbed_into():
assert 'toggleAttribute("inert"' in APP_JS
def test_inert_is_never_left_behind_on_a_widened_window():
"""An `inert` left on a window somebody widened is a page that has stopped
responding, which is worse than the bug it is here to fix."""
assert 'matchMedia(NARROW).addEventListener("change"' in APP_JS
def test_the_drawer_is_dismissible_without_finding_a_button():
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8")
assert 'class="sidebar-scrim"' in sidebar
assert 'data-toggle="#sidebar"' in sidebar
assert ".sidebar-scrim" in APP_CSS
def test_the_sidebar_does_not_go_through_setpanel():
"""The other three panels use the `hidden` attribute, which is one value
for both widths -- the thing this panel cannot use."""
assert 'if (selector === "#sidebar") return setSidebar(open);' in APP_JS
def test_the_toggle_is_a_real_target(client: TestClient, registered):
"""44px comes from `--control-h` under the coarse-pointer block, so this
only holds while `.btn--icon` keeps taking its size from that token."""
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
assert "--tap-min: 2.75rem" in tokens
assert "--control-h: var(--tap-min)" in tokens