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>
This commit is contained in:
2026-09-25 18:16:24 +00:00
co-authored by Claude Opus 5
parent 28390095a9
commit 201281d616
18 changed files with 216 additions and 48 deletions
+26
View File
@@ -16,6 +16,32 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## 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 ## 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
+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.1"
+27 -1
View File
@@ -446,13 +446,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;
@@ -1257,7 +1284,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
+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 () {});
}) })
); );
}) })
+2 -2
View File
@@ -6,8 +6,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
@@ -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 %}
+31 -8
View File
@@ -7,8 +7,26 @@
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.
`__brand` holds the identity and is the only part allowed to shrink;
`__actions` is a fixed-width rail on the trailing edge that anything
belonging to the drawer itself hangs off. It is a rail rather than one
button because a second one -- pin the sidebar open, a search -- would
otherwise be appended to the brand again, and the alignment would be a
coincidence for the third time.
This is the standing rule about rows applied to a row that got it wrong:
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
gives, and the rail is `flex: none`.
#}
<header class="sidebar__header">
<div class="sidebar__brand-slot">
{{ brandlink(uid="side") }}
</div>
{# {#
The way out, and the reason it is *inside* the drawer. The way out, and the reason it is *inside* the drawer.
@@ -19,13 +37,18 @@
sidebar there was no such control at all, and no way back. sidebar there was no such control at all, and no way back.
Hidden above that breakpoint, where the sidebar is an ordinary column and Hidden above that breakpoint, where the sidebar is an ordinary column and
the topbar's toggle is perfectly visible. the topbar's toggle is perfectly visible. It shipped *visible* on the
desktop in 1.1.0 -- not because this rule was wrong, but because the
browser was still drawing the page with the previous release's
stylesheet; see `templating.asset`.
#} #}
<button class="btn btn--icon sidebar__close" type="button" <div class="sidebar__actions-rail">
aria-label="Close sidebar" data-toggle="#sidebar"> <button class="btn btn--icon sidebar__close" type="button"
{{ icon("x") }} aria-label="Close sidebar" data-toggle="#sidebar">
</button> {{ icon("x") }}
</div> </button>
</div>
</header>
{% include "partials/_sidebar_actions.html" %} {% include "partials/_sidebar_actions.html" %}
@@ -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
+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"