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:
@@ -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,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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () {});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user