Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201281d616
|
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""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; }
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
@@ -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=<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) {
|
||||
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 () {});
|
||||
})
|
||||
);
|
||||
})
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{% elif brand.icon_paths.favicon %}
|
||||
<link rel="icon" href="/branding/{{ brand.icon_paths.favicon }}">
|
||||
{% 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 %}
|
||||
|
||||
{#
|
||||
@@ -57,15 +57,15 @@
|
||||
{% if brand.icon_paths['apple-touch'] %}
|
||||
<link rel="apple-touch-icon" href="/branding/{{ brand.icon_paths['apple-touch'] }}">
|
||||
{% 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 %}
|
||||
<meta name="apple-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-status-bar-style" content="black-translucent">
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
|
||||
{#
|
||||
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 %}
|
||||
|
||||
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
|
||||
<script src="{{ asset('vendor/htmx.min.js') }}" defer></script>
|
||||
<script src="{{ asset('vendor/htmx-ext-sse.js') }}" defer></script>
|
||||
<script src="{{ asset('vendor/alpine.min.js') }}" defer></script>
|
||||
<script src="{{ asset('js/app.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
|
||||
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="{{ url_for('static', path='js/composer.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script>
|
||||
<script src="{{ asset('js/commands.js') }}" defer></script>
|
||||
<script src="{{ asset('js/composer.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
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
{% block title %}{{ chat.title if chat else "New chat" }} - {{ brand.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
{% if terminal_enabled %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='vendor/xterm.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('vendor/xterm.css') }}">
|
||||
{% 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. #}
|
||||
<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) %}
|
||||
{# 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. #}
|
||||
<script src="{{ url_for('static', path='js/draft.js') }}" defer></script>
|
||||
<script src="{{ asset('js/draft.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% if canvas_enabled %}
|
||||
<script src="{{ url_for('static', path='js/canvas.js') }}" defer></script>
|
||||
<script src="{{ asset('js/canvas.js') }}" defer></script>
|
||||
{% 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. #}
|
||||
<script src="{{ url_for('static', path='vendor/xterm.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/xterm-addon-fit.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/terminal.js') }}" defer></script>
|
||||
<script src="{{ asset('vendor/xterm.js') }}" defer></script>
|
||||
<script src="{{ asset('vendor/xterm-addon-fit.js') }}" defer></script>
|
||||
<script src="{{ asset('js/terminal.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
{% block title %}{{ folder.name }} - {{ brand.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
{% block title %}Messages - {{ brand.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
{% 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 %}
|
||||
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
||||
<script src="{{ asset('js/steps.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,8 +7,26 @@
|
||||
nothing behind.
|
||||
#}
|
||||
<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.
|
||||
|
||||
@@ -19,13 +37,18 @@
|
||||
sidebar there was no such control at all, and no way back.
|
||||
|
||||
Hidden above that breakpoint, where the sidebar is an ordinary column and
|
||||
the topbar's toggle is perfectly visible.
|
||||
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"
|
||||
aria-label="Close sidebar" data-toggle="#sidebar">
|
||||
{{ icon("x") }}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
{% include "partials/_sidebar_actions.html" %}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
{% block title %}Your settings - {{ brand.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ asset('css/admin.css') }}">
|
||||
{% 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_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
|
||||
# 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`
|
||||
|
||||
@@ -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()
|
||||
assert "var(--safe-top)" 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
|
||||
|
||||
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()))
|
||||
assert always, "base.html stopped loading any script; this test is now blind"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user