2 Commits
Author SHA1 Message Date
HomerandClaude Opus 5 b7bf7d728b An admin area a phone could reach and not navigate
Administration has a nav of its own rather than the chat sidebar, and 1.1.0
gave every `.sidebar` the drawer behaviour -- starts closed, slides in --
without giving that one any of the drawer's furniture. No id for the toggle to
resolve, no toggle, no close, no scrim: it sat at left:-280 with nothing in the
application able to open it. The close button and the scrim are partials now,
used by both, and the test that guards it *finds* sidebars by scanning the
templates rather than working from a list, which is exactly why this one was
missed.

The chat, measured at 390px, spent forty pixels of side padding and a
forty-four pixel avatar column before drawing a word -- close to a quarter of
the screen on margin, so anything that could not wrap had to be reached
sideways. Padding halved and the avatar moved above the turn; a code block
gained about sixty pixels.

Worse in the same row: `.topbar__actions` asked for 317px of a 390px bar,
because the control that used to give in that row is display:none below a
tablet width, so the group went rigid and the title -- flex: 1 -- was squeezed
to exactly zero. And `.btn--icon` sets a width with no `flex: none`, so the row
shrank the button instead of the text: the sidebar toggle measured eighteen
pixels across. The picker gives now, and shows its avatar rather than its name
on a phone.

Also the instrument, which lied twice more: it could not see horizontal
overflow at all, because `.shell` is overflow:hidden and its "is this
contained" test therefore answered yes for everything on the page; and run from
a copy it resolved `STATIC` to a directory that did not exist, rewrote every
asset URL to a dead file:// path and reported the whole application overflowing
by thirty thousand pixels. It resolves from the imported package now and
asserts that what it rewrote to is really there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:27:39 +00:00
HomerandClaude Opus 5 201281d616 New markup over an old stylesheet
Reported from a desktop browser: a stray close button beside the logo, badly
drawn, and a page that would not scroll. None of it was in the code that was
running -- it was the code the browser had not fetched.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:16:24 +00:00
25 changed files with 530 additions and 83 deletions
+56
View File
@@ -16,6 +16,62 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.1.2
Two things a phone found that 1.1.0's phone pass had not.
- Fixed: **the administration area could not be navigated on a phone.** Admin
has a nav of its own rather than the chat sidebar, and 1.1.0 gave every
sidebar the drawer behaviour — starts closed, slides in — without giving that
one any of the drawer's furniture. So it sat off-screen with no button to open
it, no close, and nothing to tap beside it: every administration page was
reachable and then a dead end. It now opens, closes and dims the page like the
other one, and a test refuses any future sidebar that cannot be opened.
- Fixed: **the chat gave nearly a quarter of a phone screen to margins**, so
anything that could not wrap had to be scrolled to sideways. The thread's side
padding is halved, and the speaker's avatar moves above the turn instead of
sitting in a 44px column beside every line of it — a code block gained about
sixty pixels of readable width.
- Fixed: **the chat's title was squeezed to nothing.** The row's designated
shrinker is hidden below a tablet width, so on a phone the controls went rigid
and asked for 317 pixels of a 390 pixel bar; the heading was not truncated, it
simply stopped occupying space. The model picker gives now, and on a phone it
shows its avatar rather than its name — the name is one tap away and the
title is not.
- Tick boxes and the smaller buttons are big enough to hit on a phone. A
checkbox is drawn by the browser at about sixteen pixels whatever the type
around it, which made it the smallest target in the application by some way,
and the admin lists are mostly checkboxes.
- Fixed: **icon buttons could be squashed below their own size.** The sidebar
toggle measured eighteen pixels across on a phone, under half its target,
because a full row shrank the button rather than the text beside it.
## 1.1.1
One bug, and it is the one that made 1.1.0 look broken the moment you updated to
it. If you saw a stray ✕ beside the logo on a desktop, controls that looked
half-styled, or a page that would not scroll, this is why — and none of it was
in the code you were running; it was the code your browser had *not* fetched.
- Fixed: **updating showed you the new page drawn with the old stylesheet.**
Pages are always fetched fresh, while the CSS and JavaScript beside them come
from the cache the offline support keeps — and that cache was keyed on the
release while the files inside it were not. For as long as the previous
release's worker was still in charge, you got 1.1.0's markup over 1.0.x's
stylesheet: a close button meant for the phone drawer appeared on the desktop
with nothing to style or place it, and anything else the new layout depended
on was simply absent. Every asset now carries the release in its address, so
a new page cannot be handed an old stylesheet whatever the cache holds.
It is self-correcting: updating to this version is enough, and no cache needs
clearing.
- The sidebar header is two slots — the name, and a rail on the right for the
drawer's own controls — instead of a brand with a button appended to it. The
close button sits in that rail, at the top right where it belongs, and a
second control added later lands beside it rather than pushing the name
around.
## 1.1.0 ## 1.1.0
Mostly about using this on a phone, where it turns out a good deal of it could Mostly about using this on a phone, where it turns out a good deal of it could
+35 -5
View File
@@ -29,10 +29,19 @@ import tempfile
from pathlib import Path from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
SRC = REPO / "src" sys.path.insert(0, str(REPO / "src"))
sys.path.insert(0, str(SRC))
STATIC = SRC / "lembas/web/static" # Resolved from the package that actually got imported, not from where this
# file happens to sit. A copy of this script run from somewhere else silently
# pointed STATIC at a directory that did not exist, every asset URL was
# rewritten to a file:// path with nothing behind it, and the run measured an
# unstyled document -- reporting that every page in the application overflowed
# by thirty thousand pixels. The guard below only asked whether the URLs had
# been rewritten, which they had.
import lembas # noqa: E402
SRC = Path(lembas.__file__).resolve().parent.parent
STATIC = Path(lembas.__file__).resolve().parent / "web/static"
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser") CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
# Routes that are served by the app rather than mounted, so the rewrite has to # Routes that are served by the app rather than mounted, so the rewrite has to
@@ -131,8 +140,17 @@ window.__measure = function () {
tallCulprits: culprits('y'), tallCulprits: culprits('y'),
wideCulprits: culprits('x'), wideCulprits: culprits('x'),
/* The invariant: the application shell fills the window and the DOCUMENT /* The invariant: the application shell fills the window and the DOCUMENT
never scrolls. A document taller than the window is the /settings bug. */ never scrolls *for the reader*. A document taller than the window is the
documentScrolls: de.scrollHeight > window.innerHeight + 1, /settings bug -- but only when the reader can actually move it. `overflow:
hidden` blocks a wheel and a finger while still permitting an assignment
to scrollTop, so a page whose shell clips a tall descendant reports a
scrollHeight of thousands and scrolls for nobody. /admin/prompts does
exactly that, and reading the raw height called it a bug four times. */
documentScrolls:
de.scrollHeight > window.innerHeight + 1 &&
["visible", "auto", "scroll"].indexOf(
getComputedStyle(document.documentElement).overflowY
) !== -1,
scrollsSideways: de.scrollWidth > window.innerWidth + 1, scrollsSideways: de.scrollWidth > window.innerWidth + 1,
smallTargets: small.slice(0, 40), smallTargets: small.slice(0, 40),
smallCount: small.length, smallCount: small.length,
@@ -219,6 +237,18 @@ def rewrite(html: str, client, assets: Path) -> str:
f"{sorted(set(blocking))[:8]}" f"{sorted(set(blocking))[:8]}"
) )
# And that what they were rewritten *to* is really there. A rewrite that
# matches and produces a dead path is indistinguishable, from inside the
# browser, from no stylesheet at all -- and it is the failure that actually
# happened, twice.
missing = [
url
for url in re.findall(r'(?:href|src)="file://([^"?]+)"', html)
if not Path(url).exists()
]
if missing:
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
# The one-time notifications offer is a modal over the very page we came # 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 # 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. # it runs before the deferred script that reads it.
+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.1.0" __version__ = "1.1.2"
+109 -4
View File
@@ -185,6 +185,12 @@ button, input, textarea, select {
/* Square, and the same height as everything beside it. */ /* Square, and the same height as everything beside it. */
.btn--icon { .btn--icon {
width: var(--control-h); width: var(--control-h);
/* Square, and it stays square. Without this a flex row that runs out of room
shrinks it instead of its neighbours -- the sidebar toggle measured 18px
across on a 390px chat, less than half the target it is supposed to be,
while the row beside it kept every pixel it had asked for. A control's
size is not the give in a layout; text is. */
flex: none;
padding: 0; padding: 0;
background: transparent; background: transparent;
border-color: transparent; border-color: transparent;
@@ -354,12 +360,28 @@ button, input, textarea, select {
} }
.checkbox input { .checkbox input {
accent-color: var(--accent); accent-color: var(--accent);
width: 1rem; width: var(--check-size);
height: 1rem; height: var(--check-size);
flex: none; flex: none;
cursor: pointer; cursor: pointer;
} }
/* Every tick box, not only the ones inside a `.checkbox` label -- the admin
lists put bare ones in a row and those were 16px square on a phone. */
input[type="checkbox"],
input[type="radio"] {
accent-color: var(--accent);
width: var(--check-size);
height: var(--check-size);
}
/* Except the ones that are deliberately 1px: a visually-hidden radio is the
state behind a label, and the label is the target. */
input.visually-hidden[type="radio"],
input.visually-hidden[type="checkbox"] {
width: 1px;
height: 1px;
}
/* Multi-column form layout, one definition. */ /* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); } .grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); } .grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
@@ -446,13 +468,40 @@ button, input, textarea, select {
} }
.sidebar[hidden] { display: none; } .sidebar[hidden] { display: none; }
/*
Two slots with a gap between them, and neither is positioned against the
other. The brand shrinks and truncates because its width is an instance
setting nobody here chose; the rail does not, because it is a whole number of
`--control-h` boxes and is the thing a hand is going for.
`gap` rather than `margin-left: auto` on the last child: auto-margin puts the
rail on the trailing edge only for as long as it happens to be last, and the
moment a second control is added it lands between the brand and the rail
instead of in it.
*/
.sidebar__header { .sidebar__header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--sp-2);
height: var(--header-height); height: var(--header-height);
padding: 0 var(--sp-3); padding: 0 var(--sp-3);
flex: none; flex: none;
} }
.sidebar__brand-slot {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
}
/* On the trailing edge, whatever the writing direction, and sized by its
contents rather than by what is left over. */
.sidebar__actions-rail {
flex: none;
display: flex;
align-items: center;
gap: var(--sp-1);
margin-inline-start: auto;
}
.sidebar__brand { .sidebar__brand {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -926,7 +975,48 @@ body.is-resizing .canvas__body { pointer-events: none; }
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; } /*
The controls on the right of the topbar.
`flex: none` on the group with `min-width: 0` inside it: the group keeps the
width its controls need, and the one child whose width is a *name* rather
than a control -- the model picker -- is the thing allowed to give. Without
the second half the group asked for 317px of a 390px bar and the chat's
title, which is `flex: 1`, was squeezed to exactly zero: a heading that had
not been shortened or truncated but had simply ceased to occupy space.
*/
.topbar__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
/* Allowed to give, which it was not. `--topbar__where` used to be the
designated shrinker in this row, and it is `display: none` below 64rem --
so on a phone the group became rigid, asked for 317px of a 390px bar, and
the title (`flex: 1`) was squeezed to exactly zero: a heading that had not
been truncated but had ceased to occupy space.
Nothing inside it shrinks except the model picker: every button here is
`flex: none` because a control's size is not the give in a layout. */
flex: 0 1 auto;
min-width: 0;
}
/* A title identifies the page, so it gets a floor and truncates rather than
disappearing. */
.topbar__title { min-width: 4rem; }
/* The one control in this row whose width is somebody else's decision -- a
model's label is whatever an administrator called it -- so it is the one
that gives, and it gives by truncating its name rather than its avatar or
its chevron. */
.topbar__actions .picker { min-width: 0; }
.topbar__actions .picker__button { max-width: 100%; }
.topbar__actions .picker__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker__avatar, .picker__chevron { flex: none; }
/* /*
Which machine an agent chat runs on, and where. Which machine an agent chat runs on, and where.
@@ -1216,6 +1306,22 @@ body.is-resizing .canvas__body { pointer-events: none; }
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
/* The bar is the densest row in the application and the one with the least
room: a toggle, a title, a model, and up to four panel buttons. Tighter
padding and a smaller gap buy back about 24px, which is the difference
between a title that truncates and one there is no room for at all. */
.topbar {
gap: var(--sp-2);
padding-right: max(var(--sp-2), var(--safe-right));
padding-left: max(var(--sp-2), var(--safe-left));
}
/* The model's name costs about a hundred pixels and its avatar does not,
and the picker opens onto a list of full names the moment it is touched.
So on a phone the avatar carries the identity and the chat's own title --
which nothing else on the screen tells you -- gets the room back. */
.topbar__actions .picker__label { display: none; }
.sidebar { .sidebar {
position: fixed; position: fixed;
inset: 0 auto 0 0; inset: 0 auto 0 0;
@@ -1257,7 +1363,6 @@ body.is-resizing .canvas__body { pointer-events: none; }
.sidebar__close { .sidebar__close {
display: inline-flex; display: inline-flex;
margin-left: auto;
} }
/* Dismissible by tapping beside it. Without this the only way out is a /* Dismissible by tapping beside it. Without this the only way out is a
+49
View File
@@ -1726,3 +1726,52 @@
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; } .thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
.thread__intro > *:nth-child(2) { animation-delay: 60ms; } .thread__intro > *:nth-child(2) { animation-delay: 60ms; }
.thread__intro > *:nth-child(3) { animation-delay: 120ms; } .thread__intro > *:nth-child(3) { animation-delay: 120ms; }
/*
--- A phone ----------------------------------------------------------------
The one width-aware block in this file, and the reason the blanket ban on
`@media` here was lifted: everything below is a *size*, and there is no
intrinsic-sizing trick that makes 24px of thread padding the right amount on
a 390px screen. The ban existed to stop the composer toolbar being "fixed"
with a breakpoint instead of by saying which child gives, and that guarantee
is asserted directly now (`tests/test_chat.py`) -- so this block may not touch
`.composer__toolbar` or `.composer__actions`, and a test refuses it if it
does.
What was wrong: a 390px screen spent 40px of its width on thread padding and
another 44 on the avatar gutter before a single word was drawn, which is
nearly a quarter of the screen given over to margin -- so anything that could
not wrap had to be scrolled to sideways.
*/
@media (max-width: 48rem) {
/* Half the horizontal padding. The vertical stays: it is what separates one
turn from the next, and turns are no closer together on a phone. */
.thread {
padding-left: var(--sp-3);
padding-right: var(--sp-3);
}
/* The avatar goes to the top of the turn rather than beside it, so the body
gets the whole width. The gutter is what identifies the speaker and it
still does; it simply stops costing 44px of every line. */
.msg {
grid-template-columns: 1fr;
gap: var(--sp-2);
}
.msg__gutter {
width: var(--control-h-sm);
height: var(--control-h-sm);
}
.msg__meta { gap: var(--sp-2); }
/* A bubble against the edge of the screen wants less inside it. */
.msg--user .msg__body--plain { padding: var(--sp-2) var(--sp-3); }
/* The composer is the other thing pressed against both edges. */
.composer { padding-left: var(--sp-2); padding-right: var(--sp-2); }
/* A hint that runs to four lines on a phone is a hint nobody reads, and it
sits directly under the thing a thumb is reaching for. */
.composer__hint { font-size: var(--text-xs); }
}
+11 -1
View File
@@ -145,6 +145,9 @@
Raising the token is the only version that reaches all of them, and it is Raising the token is the only version that reaches all of them, and it is
what `--control-h` exists for. */ what `--control-h` exists for. */
--tap-min: 2.75rem; --tap-min: 2.75rem;
/* A tick box, which does not take its size from `--control-h`: the browser
draws it and only `width`/`height` move it. */
--check-size: 1rem;
/* --- The window's own edges --------------------------------------------- /* --- The window's own edges ---------------------------------------------
Installed on a phone, the page runs under the notch and the home Installed on a phone, the page runs under the notch and the home
@@ -421,9 +424,16 @@
@media (pointer: coarse), (max-width: 48rem) { @media (pointer: coarse), (max-width: 48rem) {
:root { :root {
--control-h: var(--tap-min); --control-h: var(--tap-min);
--control-h-sm: 2.25rem; /* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
action, not an unimportant one -- Edit, Enable and Use default are all
`.btn--sm`, and on a phone they are the whole interaction. */
--control-h-sm: 2.5rem;
--control-px: var(--sp-4); --control-px: var(--sp-4);
--control-px-sm: var(--sp-3); --control-px-sm: var(--sp-3);
/* A native checkbox is 13-16px whatever the surrounding type is, and no
amount of padding on its label changes the box itself. It is the
smallest target in the application on a phone by some margin. */
--check-size: 1.375rem;
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+14 -1
View File
@@ -50,6 +50,18 @@ var SHELL = [
"/static/img/apple-touch-icon-180.png", "/static/img/apple-touch-icon-180.png",
]; ];
/* The URL a page will actually ask for.
Every `/static/` link carries `?v=<release>` -- see `templating.asset` -- and
`caches.match` compares the whole URL, query included. So precaching the bare
path would fill the cache with entries no page ever requests, and every asset
would go to the network on every load while looking perfectly cached.
`/offline` is a route rather than an asset and is left alone. */
function versioned(path) {
return path.indexOf("/static/") === 0 ? path + "?v=" + VERSION : path;
}
self.addEventListener("install", function (event) { self.addEventListener("install", function (event) {
event.waitUntil( event.waitUntil(
caches.open(CACHE).then(function (cache) { caches.open(CACHE).then(function (cache) {
@@ -57,7 +69,8 @@ self.addEventListener("install", function (event) {
// and the whole feature silently off, so each entry is added on its own. // and the whole feature silently off, so each entry is added on its own.
return Promise.all( return Promise.all(
SHELL.map(function (path) { SHELL.map(function (path) {
return cache.add(new Request(path, { cache: "reload" })).catch(function () {}); return cache.add(new Request(versioned(path), { cache: "reload" }))
.catch(function () {});
}) })
); );
}) })
+19 -6
View File
@@ -6,18 +6,28 @@
#} #}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %} {% block body %}
<div class="shell"> <div class="shell">
<aside class="sidebar"> {#
<div class="sidebar__header"> `id="sidebar"` and the drawer's furniture, because below the phone
{{ brandlink(uid="admin") }} breakpoint `.sidebar` is a fixed overlay that starts closed -- and this one
</div> had neither an id for `data-toggle="#sidebar"` to find nor any control to
open it. The administration area was reachable on a phone and then
unnavigable once you arrived.
#}
<aside class="sidebar" id="sidebar">
<header class="sidebar__header">
<div class="sidebar__brand-slot">
{{ brandlink(uid="admin") }}
</div>
{% include "partials/_sidebar_close.html" %}
</header>
<nav class="sidebar__scroll" aria-label="Administration"> <nav class="sidebar__scroll" aria-label="Administration">
<div class="nav-group"> <div class="nav-group">
@@ -106,8 +116,11 @@
</div> </div>
</aside> </aside>
{% include "partials/_sidebar_scrim.html" %}
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
{% include "partials/_sidebar_toggle.html" %}
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1> <h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme"> <button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span> <span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
+2 -2
View File
@@ -9,8 +9,8 @@
#} #}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
+12 -12
View File
@@ -34,7 +34,7 @@
{% elif brand.icon_paths.favicon %} {% elif brand.icon_paths.favicon %}
<link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}"> <link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}">
{% else %} {% else %}
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml"> <link rel="icon" href="{{ asset('img/favicon.svg') }}" type="image/svg+xml">
{% endif %} {% endif %}
{# {#
@@ -57,15 +57,15 @@
{% 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 %}
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon-180.png') }}"> <link rel="apple-touch-icon" href="{{ asset('img/apple-touch-icon-180.png') }}">
{% endif %} {% endif %}
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="{{ brand.name }}"> <meta name="apple-mobile-web-app-title" content="{{ brand.name }}">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}"> <link rel="stylesheet" href="{{ asset('css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}"> <link rel="stylesheet" href="{{ asset('css/app.css') }}">
{# {#
Last, so an administrator's rules win, and before {% block head %} so a page's Last, so an administrator's rules win, and before {% block head %} so a page's
own stylesheet still comes after it. The query string is a hash of everything own stylesheet still comes after it. The query string is a hash of everything
@@ -128,16 +128,16 @@
{% block body %}{% endblock %} {% block body %}{% endblock %}
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script> <script src="{{ asset('vendor/htmx.min.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script> <script src="{{ asset('vendor/htmx-ext-sse.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script> <script src="{{ asset('vendor/alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', path='js/app.js') }}" defer></script> <script src="{{ asset('js/app.js') }}" defer></script>
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script> <script src="{{ asset('js/ui.js') }}" defer></script>
{# commands.js before composer.js: the second reads the first's table to draw {# commands.js before composer.js: the second reads the first's table to draw
the `/` menu, and both are deferred so the order here is the run order. #} the `/` menu, and both are deferred so the order here is the run order. #}
<script src="{{ url_for('static', path='js/commands.js') }}" defer></script> <script src="{{ asset('js/commands.js') }}" defer></script>
<script src="{{ url_for('static', path='js/composer.js') }}" defer></script> <script src="{{ asset('js/composer.js') }}" defer></script>
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script> <script src="{{ asset('js/audio.js') }}" defer></script>
{# {#
The version in the query string is what versions the worker's cache, so a The version in the query string is what versions the worker's cache, so a
+8 -8
View File
@@ -4,9 +4,9 @@
{% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %} {% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
{% if terminal_enabled %} {% if terminal_enabled %}
<link rel="stylesheet" href="{{ url_for('static', path='vendor/xterm.css') }}"> <link rel="stylesheet" href="{{ asset('vendor/xterm.css') }}">
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -393,21 +393,21 @@
{% block scripts %} {% block scripts %}
{# Unconditional: every chat has a transcript, and this is what keeps a block {# Unconditional: every chat has a transcript, and this is what keeps a block
somebody opened open across the swaps that arrive twelve times a second. #} somebody opened open across the swaps that arrive twelve times a second. #}
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script> <script src="{{ asset('js/steps.js') }}" defer></script>
{% if not chat and (canvas_enabled or terminal_enabled) %} {% if not chat and (canvas_enabled or terminal_enabled) %}
{# Only where there is no chat yet. It points both panels at a draft id for {# Only where there is no chat yet. It points both panels at a draft id for
whatever the composer has selected, and does nothing at all once a chat whatever the composer has selected, and does nothing at all once a chat
exists -- which is every other page this block renders on. #} exists -- which is every other page this block renders on. #}
<script src="{{ url_for('static', path='js/draft.js') }}" defer></script> <script src="{{ asset('js/draft.js') }}" defer></script>
{% endif %} {% endif %}
{% if canvas_enabled %} {% if canvas_enabled %}
<script src="{{ url_for('static', path='js/canvas.js') }}" defer></script> <script src="{{ asset('js/canvas.js') }}" defer></script>
{% endif %} {% endif %}
{% if terminal_enabled %} {% if terminal_enabled %}
{# Only where it can be used. xterm is nearly three times everything else {# Only where it can be used. xterm is nearly three times everything else
vendored, so a plain chat must never load it. #} vendored, so a plain chat must never load it. #}
<script src="{{ url_for('static', path='vendor/xterm.js') }}" defer></script> <script src="{{ asset('vendor/xterm.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/xterm-addon-fit.js') }}" defer></script> <script src="{{ asset('vendor/xterm-addon-fit.js') }}" defer></script>
<script src="{{ url_for('static', path='js/terminal.js') }}" defer></script> <script src="{{ asset('js/terminal.js') }}" defer></script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+2 -2
View File
@@ -17,8 +17,8 @@
{% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %} {% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
@@ -9,8 +9,8 @@
#} #}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
+2 -2
View File
@@ -13,7 +13,7 @@
{% block title %}Messages - {{ brand.name }}{% endblock %} {% block title %}Messages - {{ brand.name }}{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
@@ -124,5 +124,5 @@
DOM stub, which is the rule the working notes set out and the reason it does. DOM stub, which is the rule the working notes set out and the reason it does.
#} #}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script> <script src="{{ asset('js/steps.js') }}" defer></script>
{% endblock %} {% endblock %}
@@ -0,0 +1,20 @@
{% from "_macros.html" import icon %}
{#
The way out of the drawer, and the reason it is *inside* it.
Below the phone breakpoint the sidebar is a fixed overlay and the toggle that
opens it is in the topbar underneath -- so once open, the control for closing
it is behind it. Its own partial because there are two sidebars in this
application, the chat one and the admin one, and the second was given the
drawer behaviour without the drawer's furniture: at a phone width it was
hidden off-screen with no toggle and no close anywhere, which is an admin area
that simply could not be navigated on a phone.
Hidden above that breakpoint, where the sidebar is an ordinary column.
#}
<div class="sidebar__actions-rail">
<button class="btn btn--icon sidebar__close" type="button"
aria-label="Close sidebar" data-toggle="#sidebar">
{{ icon("x") }}
</button>
</div>
@@ -0,0 +1,10 @@
{#
The scrim behind an open drawer. It carries the same `data-toggle` as every
other control that closes it, so tapping beside the drawer goes through one
code path rather than a second 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>
+21 -27
View File
@@ -7,25 +7,28 @@
nothing behind. nothing behind.
#} #}
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<div class="sidebar__header"> {#
{{ brandlink(uid="side") }} The header is two slots, not a brand with something appended to it.
{#
The way out, and the reason it is *inside* the drawer.
Below the phone breakpoint this whole element is a fixed overlay, and the `__brand` holds the identity and is the only part allowed to shrink;
toggle that opens it lives in the topbar underneath -- so once it was `__actions` is a fixed-width rail on the trailing edge that anything
open, the control for closing it was behind it. That was true on /chat, belonging to the drawer itself hangs off. It is a rail rather than one
where at least a toggle existed; on the seven other pages that carry this button because a second one -- pin the sidebar open, a search -- would
sidebar there was no such control at all, and no way back. otherwise be appended to the brand again, and the alignment would be a
coincidence for the third time.
Hidden above that breakpoint, where the sidebar is an ordinary column and This is the standing rule about rows applied to a row that got it wrong:
the topbar's toggle is perfectly visible. the two parts have a known width (a rail of `--control-h` boxes) and an
#} unknown one (a name somebody chose), so the unknown one is the one that
<button class="btn btn--icon sidebar__close" type="button" gives, and the rail is `flex: none`.
aria-label="Close sidebar" data-toggle="#sidebar"> #}
{{ icon("x") }} <header class="sidebar__header">
</button> <div class="sidebar__brand-slot">
</div> {{ brandlink(uid="side") }}
</div>
{% include "partials/_sidebar_close.html" %}
</header>
{% include "partials/_sidebar_actions.html" %} {% include "partials/_sidebar_actions.html" %}
@@ -103,13 +106,4 @@
</div> </div>
</aside> </aside>
{# {% include "partials/_sidebar_scrim.html" %}
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>
@@ -15,8 +15,8 @@
#} #}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
@@ -8,8 +8,8 @@
#} #}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
+2 -2
View File
@@ -4,8 +4,8 @@
{% block title %}Your settings - {{ brand.name }}{% endblock %} {% block title %}Your settings - {{ brand.name }}{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}"> <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}"> <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
{% endblock %} {% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %}
+28
View File
@@ -61,6 +61,34 @@ templates.env.filters["tokens"] = highlight_tokens
templates.env.globals["tool_label"] = tool_labels.label_for templates.env.globals["tool_label"] = tool_labels.label_for
templates.env.globals["tool_icon"] = tool_labels.icon_for templates.env.globals["tool_icon"] = tool_labels.icon_for
def asset(path: str) -> str:
"""A static asset's URL, with the release stamped into it.
🚨 This is not cache politeness, it is what stops a release drawing itself
from two versions at once.
The service worker caches `/static/...` under a cache named for the
release, and a *page* is fetched network-first while its assets come from
that cache. So the moment the worker stops taking over open tabs the
instant it installs -- which it must, or it swaps the stylesheets under
somebody mid-reply -- the new HTML and the old CSS are served together and
the interface is subtly wrong until the worker is replaced. That shipped in
1.1.0: a close button intended for a phone drawer appeared, unstyled, on
every desktop, because the markup knew about it and the stylesheet did not.
A version in the URL settles it without anybody having to be careful: the
new HTML asks for a URL the old cache has never heard of, so it goes to the
network. The two can no longer disagree, whichever worker is in charge.
Not a hash of the file: `__version__` is the one thing that already moves
with every release, and a hash would mean reading every asset on every
render or a build step, and there is deliberately no build step here.
"""
return f"/static/{path.lstrip('/')}?v={__version__}"
templates.env.globals["asset"] = asset
# A finished reply as the sequence of steps it was. A global for exactly the # A finished reply as the sequence of steps it was. A global for exactly the
# reason the two above are, and it is why turning the bubble into a sequence # reason the two above are, and it is why turning the bubble into a sequence
# needed no change in `pages.py`, `post_message`, `regenerate` or the `done` # needed no change in `pages.py`, `post_message`, `regenerate` or the `done`
+47
View File
@@ -264,3 +264,50 @@ def test_the_page_asks_for_the_whole_screen_and_then_pays_for_it(client, registe
app = (STATIC_DIR / "css" / "app.css").read_text() app = (STATIC_DIR / "css" / "app.css").read_text()
assert "var(--safe-top)" in app assert "var(--safe-top)" in app
assert "var(--safe-bottom)" in app assert "var(--safe-bottom)" in app
# --- A release cannot be drawn with the previous release's stylesheet --------
def test_every_static_asset_carries_the_release(client: TestClient, registered):
"""The bug this is here to stop shipped in 1.1.0.
The worker caches `/static/...` under a cache named for the release, and a
page is fetched network-first while its assets come from that cache -- so
once the worker stopped claiming open tabs the instant it installed (which
it had to, or it swaps stylesheets under somebody mid-reply), new HTML and
old CSS were served together. What that looked like was a close button
meant for a phone drawer appearing, unstyled, on every desktop.
A version in the URL settles it: the new HTML asks for something the old
cache has never heard of.
"""
import re
for path in ("/chat", "/settings"):
page = client.get(path).text
bare = re.findall(r'(?:href|src)="(/static/[^"?]+)"', page)
assert not bare, f"{path} loads unversioned assets: {bare[:5]}"
def test_no_template_reaches_past_the_helper(client: TestClient):
"""`url_for('static', ...)` produces a URL with no version in it, so one
left behind is one asset that can still come from the wrong release."""
from pathlib import Path
import lembas
root = Path(lembas.__file__).parent / "web/templates"
offenders = [
str(p.relative_to(root))
for p in root.rglob("*.html")
if "url_for('static'" in p.read_text(encoding="utf-8")
]
assert not offenders, f"still using url_for for static assets: {offenders}"
def test_the_worker_precaches_what_a_page_will_ask_for():
"""`caches.match` compares the whole URL. Precaching the bare path fills the
cache with entries nothing requests, and every asset then goes to the
network on every load while looking perfectly cached."""
source = (STATIC_DIR / "js" / "sw.js").read_text()
assert 'path + "?v=" + VERSION' in source
assert "versioned(path)" in source
+70 -3
View File
@@ -95,9 +95,12 @@ def test_inert_is_never_left_behind_on_a_widened_window():
def test_the_drawer_is_dismissible_without_finding_a_button(): def test_the_drawer_is_dismissible_without_finding_a_button():
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8") """The scrim is a partial because there are two sidebars, so the markup is
assert 'class="sidebar-scrim"' in sidebar asserted where it is defined and its *inclusion* is asserted per sidebar by
assert 'data-toggle="#sidebar"' in sidebar `test_every_sidebar_carries_the_way_out_and_the_scrim`."""
scrim = (TEMPLATES / "partials/_sidebar_scrim.html").read_text(encoding="utf-8")
assert 'class="sidebar-scrim"' in scrim
assert 'data-toggle="#sidebar"' in scrim
assert ".sidebar-scrim" in APP_CSS assert ".sidebar-scrim" in APP_CSS
@@ -113,3 +116,67 @@ def test_the_toggle_is_a_real_target(client: TestClient, registered):
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8") tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
assert "--tap-min: 2.75rem" in tokens assert "--tap-min: 2.75rem" in tokens
assert "--control-h: var(--tap-min)" in tokens assert "--control-h: var(--tap-min)" in tokens
# --- Any sidebar, not only the one this was written for ----------------------
def _sidebar_templates() -> list[str]:
"""Every template that renders a sidebar of its own, found rather than
listed -- the admin one was missed precisely because it was not on a list."""
return [
str(p.relative_to(TEMPLATES))
for p in TEMPLATES.rglob("*.html")
if '<aside class="sidebar"' in p.read_text(encoding="utf-8")
]
def test_every_sidebar_is_one_the_toggle_can_find():
"""`data-toggle="#sidebar"` resolves by id, and below the phone breakpoint
`.sidebar` is a fixed overlay that starts closed. A sidebar without that id
is one nothing can open: the admin area shipped that way in 1.1.0 and 1.1.1
-- reachable on a phone, and unnavigable the moment you arrived."""
without = [
name
for name in _sidebar_templates()
if '<aside class="sidebar" id="sidebar"' not in (TEMPLATES / name).read_text(
encoding="utf-8"
)
]
assert not without, f"sidebar with no id, so nothing can open it: {without}"
def test_every_sidebar_carries_the_way_out_and_the_scrim():
missing = []
for name in _sidebar_templates():
text = (TEMPLATES / name).read_text(encoding="utf-8")
if "partials/_sidebar_close.html" not in text:
missing.append(f"{name}: no close button")
if "partials/_sidebar_scrim.html" not in text:
missing.append(f"{name}: no scrim")
assert not missing, missing
def test_the_admin_area_can_be_navigated_on_a_phone(client: TestClient, registered):
"""The whole of administration is in that nav and nowhere else."""
page = client.get("/admin/models").text
assert '<aside class="sidebar" id="sidebar"' in page
assert 'data-toggle="#sidebar"' in page
assert "sidebar-scrim" in page
# --- Controls do not shrink below their own size -----------------------------
def test_an_icon_button_keeps_its_size_in_a_tight_row():
"""`.btn--icon` sets a width and, without `flex: none`, a row that runs out
of room shrinks it instead of the text beside it -- the sidebar toggle
measured 18px across on a 390px chat, well under half its target."""
rule = APP_CSS.split(".btn--icon {", 1)[1].split("}", 1)[0]
assert "flex: none" in rule
def test_the_topbar_can_give_somewhere(client: TestClient, registered):
"""`.topbar__where` was the designated shrinker in that row and it is
`display: none` below 64rem, so on a phone the group went rigid and the
title -- which is `flex: 1` -- was squeezed to exactly zero width."""
actions = APP_CSS.split(".topbar__actions {", 1)[1].split("}", 1)[0]
assert "flex: 0 1 auto" in actions
assert "min-width: 0" in actions
assert "min-width" in APP_CSS.split(".topbar__title {", 1)[1].split("}", 1)[0]
+6 -1
View File
@@ -324,7 +324,12 @@ def test_no_page_loads_a_script_that_the_base_template_already_loads():
from pathlib import Path from pathlib import Path
templates = Path(__file__).resolve().parents[1] / "src/lembas/web/templates" templates = Path(__file__).resolve().parents[1] / "src/lembas/web/templates"
pattern = re.compile(r"path='js/([a-z_]+\.js)'") # Both spellings, because the way a static URL is written has changed once
# already: `url_for('static', path='js/x.js')` became `asset('js/x.js')`
# when assets started carrying the release. The assertion below that the set
# is non-empty is what turned that rename into a loud failure rather than a
# sweep that silently stopped sweeping -- keep it.
pattern = re.compile(r"(?:path=|asset\()'js/([a-z_]+\.js)'")
always = set(pattern.findall((templates / "base.html").read_text())) always = set(pattern.findall((templates / "base.html").read_text()))
assert always, "base.html stopped loading any script; this test is now blind" assert always, "base.html stopped loading any script; this test is now blind"