From 201281d61631ce773f6e8dc02fce915e65fd2d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Fri, 25 Sep 2026 18:16:24 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 26 ++++++++++ src/lembas/__init__.py | 2 +- src/lembas/web/static/css/app.css | 28 ++++++++++- src/lembas/web/static/js/sw.js | 15 +++++- src/lembas/web/templates/admin/_layout.html | 4 +- src/lembas/web/templates/agents/_layout.html | 4 +- src/lembas/web/templates/base.html | 24 +++++----- src/lembas/web/templates/chat/index.html | 16 +++---- src/lembas/web/templates/folders/edit.html | 4 +- src/lembas/web/templates/library/_layout.html | 4 +- src/lembas/web/templates/messages/index.html | 4 +- .../web/templates/partials/sidebar.html | 39 +++++++++++---- src/lembas/web/templates/reports/_layout.html | 4 +- .../web/templates/schedules/_layout.html | 4 +- src/lembas/web/templates/settings.html | 4 +- src/lembas/web/templating.py | 28 +++++++++++ tests/test_pwa.py | 47 +++++++++++++++++++ tests/test_ui_js.py | 7 ++- 18 files changed, 216 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4838e..421264a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,32 @@ for 1.0.0 have something to be assembled from. ## Unreleased +## 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 Mostly about using this on a phone, where it turns out a good deal of it could diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 6dddad1..5ceab52 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index fb73fc0..3658a58 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -446,13 +446,40 @@ button, input, textarea, select { } .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 { display: flex; align-items: center; + gap: var(--sp-2); height: var(--header-height); padding: 0 var(--sp-3); 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 { display: flex; align-items: center; @@ -1257,7 +1284,6 @@ body.is-resizing .canvas__body { pointer-events: none; } .sidebar__close { display: inline-flex; - margin-left: auto; } /* Dismissible by tapping beside it. Without this the only way out is a diff --git a/src/lembas/web/static/js/sw.js b/src/lembas/web/static/js/sw.js index 1d2e78c..854ef15 100644 --- a/src/lembas/web/static/js/sw.js +++ b/src/lembas/web/static/js/sw.js @@ -50,6 +50,18 @@ var SHELL = [ "/static/img/apple-touch-icon-180.png", ]; +/* The URL a page will actually ask for. + + Every `/static/` link carries `?v=` -- 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) { event.waitUntil( 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. return Promise.all( 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 () {}); }) ); }) diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index c1996e2..3017ee0 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -6,8 +6,8 @@ #} {% block head %} - - + + {% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %} diff --git a/src/lembas/web/templates/agents/_layout.html b/src/lembas/web/templates/agents/_layout.html index cd43947..2428250 100644 --- a/src/lembas/web/templates/agents/_layout.html +++ b/src/lembas/web/templates/agents/_layout.html @@ -9,8 +9,8 @@ #} {% block head %} - - + + {% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %} diff --git a/src/lembas/web/templates/base.html b/src/lembas/web/templates/base.html index 07bfcba..00a4f75 100644 --- a/src/lembas/web/templates/base.html +++ b/src/lembas/web/templates/base.html @@ -34,7 +34,7 @@ {% elif brand.icon_paths.favicon %} {% else %} - + {% endif %} {# @@ -57,15 +57,15 @@ {% if brand.icon_paths['apple-touch'] %} {% else %} - + {% endif %} - - + + {# 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 @@ -128,16 +128,16 @@ {% block body %}{% endblock %} - - - - - + + + + + {# 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 version in the query string is what versions the worker's cache, so a diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 022a917..f03c32b 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -4,9 +4,9 @@ {% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %} {% block head %} - + {% if terminal_enabled %} - + {% endif %} {% endblock %} @@ -393,21 +393,21 @@ {% block scripts %} {# 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. #} - + {% 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 whatever the composer has selected, and does nothing at all once a chat exists -- which is every other page this block renders on. #} - + {% endif %} {% if canvas_enabled %} - + {% endif %} {% if terminal_enabled %} {# Only where it can be used. xterm is nearly three times everything else vendored, so a plain chat must never load it. #} - - - + + + {% endif %} {% endblock %} diff --git a/src/lembas/web/templates/folders/edit.html b/src/lembas/web/templates/folders/edit.html index b9f23cd..51ca6cd 100644 --- a/src/lembas/web/templates/folders/edit.html +++ b/src/lembas/web/templates/folders/edit.html @@ -17,8 +17,8 @@ {% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %} {% block head %} - - + + {% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %} diff --git a/src/lembas/web/templates/library/_layout.html b/src/lembas/web/templates/library/_layout.html index 14f296b..f57e667 100644 --- a/src/lembas/web/templates/library/_layout.html +++ b/src/lembas/web/templates/library/_layout.html @@ -9,8 +9,8 @@ #} {% block head %} - - + + {% endblock %} {% block body_attrs %} data-authenticated="true"{% endblock %} diff --git a/src/lembas/web/templates/messages/index.html b/src/lembas/web/templates/messages/index.html index 2f8ee23..d9250df 100644 --- a/src/lembas/web/templates/messages/index.html +++ b/src/lembas/web/templates/messages/index.html @@ -13,7 +13,7 @@ {% block title %}Messages - {{ brand.name }}{% endblock %} {% block head %} - + {% 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. #} {% block scripts %} - + {% endblock %} diff --git a/src/lembas/web/templates/partials/sidebar.html b/src/lembas/web/templates/partials/sidebar.html index 15834b9..02c5562 100644 --- a/src/lembas/web/templates/partials/sidebar.html +++ b/src/lembas/web/templates/partials/sidebar.html @@ -7,8 +7,26 @@ nothing behind. #}