Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201281d616
|
||
|
|
28390095a9
|
||
|
|
92070d7879
|
||
|
|
d73a791c86
|
+179
@@ -16,6 +16,185 @@ 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
|
||||
not be used at all.
|
||||
|
||||
### The sidebar on a phone
|
||||
|
||||
- Fixed: **the sidebar opened over the page on every phone, and the button that
|
||||
closes it was underneath it.** Below a phone width the sidebar is a 280px
|
||||
panel laid over the page; nothing ever closed it, and the only control that
|
||||
could was in the bar behind it. It now starts closed at that width, slides in
|
||||
when you ask for it, dims the page behind it, and closes by tapping beside it,
|
||||
by Escape, or by its own button — which is inside the drawer, where you can
|
||||
reach it.
|
||||
- Fixed: **seven of the eight pages with a sidebar had no way to show or hide it
|
||||
at all.** Only the chat page ever had that button. Settings, Messages,
|
||||
Reports, Scheduled, Library, Connections and a folder's own page did not —
|
||||
which on a phone meant arriving at a page already covered by a panel with
|
||||
nothing to do about it. Settings is where the Install and Notifications
|
||||
buttons live, so this was also why they were hard to reach.
|
||||
- The toggle no longer claims the sidebar is open when it is not, which matters
|
||||
to anyone using a screen reader.
|
||||
|
||||
### Anything you tap
|
||||
|
||||
- **Every control is now at least 44px on a touch screen**, instead of 36px —
|
||||
or 28px for the small ones, which included renaming and deleting a chat, all
|
||||
seven actions on a message, and every panel's close button. The dismiss button
|
||||
on a notification had no size of its own at all and was about 18 by 7 pixels.
|
||||
- Fixed: **renaming or deleting a chat, and copying, editing, regenerating or
|
||||
reading aloud a message, were impossible on a phone.** All of them appeared on
|
||||
hover, and there is no hover on a phone; tapping the row simply opened it.
|
||||
- Fixed: **the settings tabs scrolled sideways with nothing to say so**, hiding
|
||||
Appearance, Memory and Security off the right-hand edge of a phone screen.
|
||||
There is a fade at the edge now, and a flick lands on a tab.
|
||||
- Installed on an iPhone, the page ran underneath the clock and the home
|
||||
indicator. It no longer does.
|
||||
|
||||
### Installing it
|
||||
|
||||
- The install prompt now offers the richer dialog rather than the terse bar, and
|
||||
a long press on the icon offers New chat, Messages and Scheduled.
|
||||
- Fixed: **a light-themed instance installed to a phone showed a near-black
|
||||
splash screen and then opened parchment**, and every page load flashed dark
|
||||
browser chrome before the stylesheet had run. Both follow the theme now.
|
||||
- Fixed: **a new version used to take over pages you were reading**, swapping
|
||||
the stylesheets under an open tab while it emptied the cache they came from.
|
||||
It waits and offers you a reload instead.
|
||||
- Fixed: the small mark beside a notification on Android was a solid grey
|
||||
square, because the icon it used has no transparency to be cut from.
|
||||
- Fixed: notifications silently stopped working for good if the browser ever
|
||||
replaced its own subscription, which browsers do.
|
||||
- Pages start loading a little sooner, and the two icons a launcher actually
|
||||
crops are now kept for offline use.
|
||||
|
||||
### Things that move
|
||||
|
||||
- **Every request the application makes now says it is happening**, with a thin
|
||||
bar across the top of the window. Nothing did before, so anything slower than
|
||||
a few milliseconds looked like a click that had not registered.
|
||||
- The thinking indicator turns rather than fading, so a model that is working
|
||||
and one that has stopped no longer look alike.
|
||||
- Dialogs, the drawer and the panels arrive and leave rather than appearing;
|
||||
buttons answer a press; cards lift under the pointer. All of it stops if you
|
||||
have asked your system for reduced motion.
|
||||
|
||||
### Archiving
|
||||
|
||||
- **A chat can be archived** — out of the list, into a group at the bottom of the
|
||||
sidebar, and back again whenever you like. The setting behind this has existed
|
||||
and been honoured since folders arrived; nothing had ever been able to switch
|
||||
it on.
|
||||
|
||||
### Smaller things
|
||||
|
||||
- **Extra headers can be set on a connection.** They were sent with every
|
||||
request already and no form could write them, so OpenRouter's attribution
|
||||
headers were documented and unreachable.
|
||||
- A model is no longer told that it will hear when a background job finishes on
|
||||
instances where that notification is switched off.
|
||||
- The guidance for asking you a question can now be edited like every other
|
||||
piece of the prompt. It was the only one that could not be.
|
||||
- Several controls that a screen reader announced as nothing now have names, and
|
||||
two lists that claimed to be tab strips now describe themselves honestly.
|
||||
- Borders resolve through a token like every other value, so a theme can change
|
||||
one. They were a literal `1px` in about ninety places, which was the largest
|
||||
patch of hard-coded value left in the stylesheets.
|
||||
- `chat.css` may now contain media queries. It was forbidden them, for a good
|
||||
reason that had stopped applying: what the ban protected is asserted directly
|
||||
now, which is both narrower and stronger.
|
||||
|
||||
## 1.0.4
|
||||
|
||||
Six things that looked like they worked. Five of them were found by reading the
|
||||
code rather than by anybody reporting them, which is what they have in common:
|
||||
none of these fails loudly, and two of them correct themselves if you reload.
|
||||
|
||||
- Fixed: **a reply lost the model's name and picture the moment it finished.**
|
||||
While a reply streams it is attributed correctly; at the instant it lands, the
|
||||
frame that replaces the bubble was looking the models up as nobody, and "no
|
||||
user" answers "no models" rather than "all models". So a finished reply swapped
|
||||
the model's avatar for the plain leaf mark, put the instance's name where the
|
||||
model's should be, and grew a raw model id beside it. Reloading the page put it
|
||||
all back, which is why this survived a release: it is only ever wrong until you
|
||||
look away.
|
||||
- Fixed: **a limit on how many replies an account may write at once could be
|
||||
stepped over by pressing New chat.** It was enforced when sending into a chat
|
||||
that already existed and nowhere else — not on a new chat, not on editing an
|
||||
earlier message, not on sending a queued one, and not on regenerating. Four of
|
||||
the six ways to start a reply ignored it, including the commonest.
|
||||
- Fixed: **a custom theme's confirmations and warnings kept the built-in
|
||||
theme's colour behind them.** Setting `success` or `warning` moved the text and
|
||||
left the background it sits on, because the faded companion colour was derived
|
||||
for three of the five settable colours. Visible on every alert and badge of
|
||||
those two kinds, on the "on" state in the permissions list, and on the added
|
||||
lines of every diff in an agent chat.
|
||||
- Fixed: **on a phone, every page with a sidebar could be scrolled past its own
|
||||
bottom into empty background.** The shell was sized to the part of the screen
|
||||
you can actually see and the document around it to the part you can see with
|
||||
the browser's toolbar retracted; the difference between those is real on a
|
||||
phone and nil on a desktop, which is why it was never noticed on one. Reported
|
||||
on Settings and true everywhere. A flick that ran off the end of a list now
|
||||
stops there as well, instead of dragging the page behind it.
|
||||
- Fixed: **the conversation was rendering every assistant message twice on every
|
||||
page load** — once into Markdown that nothing read, and once the way it is
|
||||
actually shown. The same was true of Messages, for your own turns. Nothing
|
||||
looked wrong; a long conversation was simply slower to open than it needed to
|
||||
be, every time, along with every rewind and every compaction.
|
||||
- Fixed: a test file meant to skip itself on a machine without `setsid` never
|
||||
did, because it set its marker twice and the second one replaced the first.
|
||||
- Removed: an endpoint serving a message's unrendered Markdown, which nothing
|
||||
had ever called — the copy button reads the page it is already on.
|
||||
|
||||
## 1.0.3
|
||||
|
||||
Two Arch-isms in the installer, both of which only a Debian machine could find.
|
||||
`deploy/lxc-install.sh` had never been executed — it was reviewed and
|
||||
syntax-checked, which is not the same claim — and running it is what found them.
|
||||
|
||||
- Fixed: **`deploy/install.sh` could not create its virtualenv on Debian**, and
|
||||
so `deploy/lxc-install.sh` could not finish. It called bare `python`, which is
|
||||
Python 3 on Arch — the machine this was written and only ever run on — and
|
||||
does not exist on Debian at all unless `python-is-python3` is installed. The
|
||||
LXC bootstrap installs `python3`, so the install aborted at the virtualenv
|
||||
step with the service user, the bind mount and the clone already made. It now
|
||||
calls `python3`, which is right on both.
|
||||
- Fixed: the service account was created with `--shell /usr/bin/nologin`, which
|
||||
is where Arch keeps it and where Debian does not. Nothing invoked it — `sudo -u`
|
||||
execs directly and systemd's `User=` never reads a shell — so the account
|
||||
worked either way, but it was created pointing at a file that was not there.
|
||||
Now `/usr/sbin/nologin`, which is correct on Debian and resolves on Arch too,
|
||||
since Arch's `/usr/sbin` is a symlink to `bin`.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- **The documentation moved to the [wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki).**
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
+11
-2
@@ -95,9 +95,13 @@ fi
|
||||
echo "== service user =="
|
||||
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
||||
# venv and database sit on the larger volume.
|
||||
#
|
||||
# `/usr/sbin/nologin` is Debian's path and works on both: Arch keeps `nologin`
|
||||
# in /usr/bin, but its /usr/sbin is a symlink to bin, so the Debian spelling
|
||||
# resolves there while the Arch one does not resolve on Debian at all.
|
||||
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
||||
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
||||
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
||||
--shell /usr/sbin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
||||
else
|
||||
echo " user $SERVICE_USER already exists"
|
||||
fi
|
||||
@@ -120,8 +124,13 @@ else
|
||||
fi
|
||||
|
||||
echo "== virtualenv =="
|
||||
# `python3`, not `python`. On Arch -- the machine this was written on and the
|
||||
# only one it had ever run on -- `python` is Python 3 and the bare name worked.
|
||||
# On Debian it does not exist unless somebody installed `python-is-python3`, so
|
||||
# the LXC bootstrap aborted here, after the service user, the bind mount and the
|
||||
# clone were already in place. `python3` is correct on both.
|
||||
if [[ ! -x "$VENV/bin/python" ]]; then
|
||||
sudo -u "$SERVICE_USER" python -m venv "$VENV"
|
||||
sudo -u "$SERVICE_USER" python3 -m venv "$VENV"
|
||||
fi
|
||||
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
||||
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
||||
|
||||
@@ -53,6 +53,7 @@ SERVED_BY_APP = (
|
||||
"icon-512.png",
|
||||
"icon-maskable-512.png",
|
||||
"apple-touch-icon-180.png",
|
||||
"badge-72.png",
|
||||
)
|
||||
|
||||
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
|
||||
@@ -423,6 +424,28 @@ def build_apple_touch_icon() -> bytes:
|
||||
return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180)
|
||||
|
||||
|
||||
def build_badge() -> bytes:
|
||||
"""The small mark beside a notification in the Android status bar.
|
||||
|
||||
A badge is used as a *mask*: the device keeps the alpha channel and throws
|
||||
every colour away. So this is the leaf as a solid silhouette on nothing --
|
||||
no gradients, no rim, no veins, none of which would survive, and a plate
|
||||
behind it least of all. The application used `icon-192.png` here, which is
|
||||
opaque to its edges, so what Android drew was a grey square.
|
||||
|
||||
72px because that is the size Android asks for, and small enough that the
|
||||
blade alone is the only part that still reads.
|
||||
"""
|
||||
return _rasterise(
|
||||
f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<path d="{LEAF_BLADE}" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
""",
|
||||
72,
|
||||
)
|
||||
|
||||
|
||||
def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
|
||||
"""One jagged ridge line spanning the full width."""
|
||||
rng = random.Random(seed)
|
||||
@@ -564,6 +587,7 @@ BUILDERS = {
|
||||
"icon-512.png": build_icon_512,
|
||||
"icon-maskable-512.png": build_icon_maskable,
|
||||
"apple-touch-icon-180.png": build_apple_touch_icon,
|
||||
"badge-72.png": build_badge,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Render LLeMbas pages in a real browser, at a real size.
|
||||
|
||||
Run it:
|
||||
|
||||
python scripts/shoot.py OUTDIR [/chat,/settings] # measure + capture
|
||||
python scripts/shoot.py OUTDIR --manifest-screenshots # the two the
|
||||
# manifest wants
|
||||
|
||||
Needs a `chromium` on PATH and the development dependencies installed. It is a
|
||||
development instrument, like the Node DOM stub the JavaScript is driven under
|
||||
and like `fetch_vendor.py` -- it is not imported by the application and nothing
|
||||
in `src/` knows it exists.
|
||||
|
||||
Not a test runner: an instrument. It renders a page through TestClient, rewrites
|
||||
every asset URL to a file:// path, and refuses to continue if even one is left
|
||||
pointing at `testserver` -- because the last harness that did this silently
|
||||
measured an unstyled document and reported all five tab panels visible at once.
|
||||
A dramatic finding that was entirely an artefact of a rewrite matching nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
STATIC = SRC / "lembas/web/static"
|
||||
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
|
||||
|
||||
# Routes that are served by the app rather than mounted, so the rewrite has to
|
||||
# fetch them rather than point at a file that does not exist.
|
||||
ROUTE_ASSETS = {"/branding.css": "branding.css", "/sw.js": "sw.js"}
|
||||
|
||||
MEASURE = """
|
||||
<script>
|
||||
window.__measure = function () {
|
||||
var de = document.scrollingElement || document.documentElement;
|
||||
var small = [];
|
||||
document.querySelectorAll(
|
||||
'button, a.btn, a.nav-item, .tabs__tab, input, select, [role=tab]'
|
||||
).forEach(function (el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return; /* hidden */
|
||||
if (el.closest('[hidden]')) return;
|
||||
/* A `.visually-hidden` radio is 1x1 on purpose -- the <label> beside it is
|
||||
the target, and that one is measured. Counting the input reports five
|
||||
failures on a settings page whose tabs are all 44px. */
|
||||
if (el.classList.contains('visually-hidden')) return;
|
||||
/* Inline text inside a sentence is not a tap target in the sense this is
|
||||
checking; it is a word you can also click. */
|
||||
if (getComputedStyle(el).display === 'inline') return;
|
||||
if (r.height < 40 || r.width < 40) {
|
||||
small.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: el.className && el.className.toString().slice(0, 60),
|
||||
label: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30),
|
||||
w: Math.round(r.width), h: Math.round(r.height)
|
||||
});
|
||||
}
|
||||
});
|
||||
var wide = [];
|
||||
document.querySelectorAll('body *').forEach(function (el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
if (r.right > window.innerWidth + 1 || r.left < -1) {
|
||||
wide.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: el.className && el.className.toString().slice(0, 60),
|
||||
left: Math.round(r.left), right: Math.round(r.right)
|
||||
});
|
||||
}
|
||||
});
|
||||
/* Which element is actually making the document bigger than the window.
|
||||
"the page over-scrolls" is not actionable; "`.shell` is 1756px tall in an
|
||||
844px window" is. Reported for both axes, deepest first, because the
|
||||
outermost offender is usually just the ancestor of the real one. */
|
||||
/* Content taller than the window inside something built to scroll is not
|
||||
overflow, it is the point. So an element counts only when nothing between
|
||||
it and the root can scroll in that axis -- otherwise every long settings
|
||||
page reports its own cards as a bug and the signal is lost in them. */
|
||||
function contained(el, axis) {
|
||||
var prop = axis === 'y' ? 'overflowY' : 'overflowX';
|
||||
for (var n = el.parentElement; n && n !== document.documentElement; n = n.parentElement) {
|
||||
var o = getComputedStyle(n)[prop];
|
||||
if (o === 'auto' || o === 'scroll' || o === 'hidden') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function culprits(axis) {
|
||||
var found = [];
|
||||
document.querySelectorAll('body, body *').forEach(function (el) {
|
||||
if (contained(el, axis)) return;
|
||||
var r = el.getBoundingClientRect();
|
||||
var over = axis === 'y'
|
||||
? r.bottom - window.innerHeight
|
||||
: r.right - window.innerWidth;
|
||||
if (over > 1) {
|
||||
found.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className && el.className.toString().slice(0, 50)) || '',
|
||||
over: Math.round(over),
|
||||
size: Math.round(axis === 'y' ? r.height : r.width),
|
||||
pos: getComputedStyle(el).position,
|
||||
id: el.id || '',
|
||||
parent: el.parentElement ? (el.parentElement.tagName.toLowerCase() + '.' +
|
||||
(el.parentElement.className || '').toString().slice(0, 30)) : '',
|
||||
html: el.outerHTML.slice(0, 120)
|
||||
});
|
||||
}
|
||||
});
|
||||
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
|
||||
}
|
||||
|
||||
var shell = document.querySelector('.shell');
|
||||
return {
|
||||
docScrollH: de.scrollHeight,
|
||||
innerH: window.innerHeight,
|
||||
docScrollW: de.scrollWidth,
|
||||
innerW: window.innerWidth,
|
||||
bodyScrollH: document.body.scrollHeight,
|
||||
shellH: shell ? Math.round(shell.getBoundingClientRect().height) : null,
|
||||
shellW: shell ? Math.round(shell.getBoundingClientRect().width) : null,
|
||||
tallCulprits: culprits('y'),
|
||||
wideCulprits: culprits('x'),
|
||||
/* The invariant: the application shell fills the window and the DOCUMENT
|
||||
never scrolls. A document taller than the window is the /settings bug. */
|
||||
documentScrolls: de.scrollHeight > window.innerHeight + 1,
|
||||
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
|
||||
smallTargets: small.slice(0, 40),
|
||||
smallCount: small.length,
|
||||
overflowing: wide.slice(0, 20),
|
||||
overflowCount: wide.length
|
||||
};
|
||||
};
|
||||
/* Nothing is appended to the page itself. The first version of this harness
|
||||
did exactly that, and the div it added was 960px tall -- so the very first
|
||||
run reported that /chat over-scrolled by 960px on a phone, which was a
|
||||
finding entirely about the instrument. The frame outside reads __measure()
|
||||
across the boundary instead, and the page is left exactly as served. */
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def build_client():
|
||||
import lembas.config as config_mod
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="lembas-shoot-"))
|
||||
config_mod.settings.data_dir = tmp
|
||||
config_mod.settings.secret_key = "x" * 43
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from lembas.db.session import init_db, session_scope
|
||||
from lembas.main import create_app
|
||||
|
||||
init_db()
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Frodo", "email": "f@example.com", "password": "mellonmellon"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
from lembas.db.models import Connection, Model
|
||||
|
||||
with session_scope() as db:
|
||||
connection = Connection(
|
||||
name="local", base_url="http://127.0.0.1:1", api_key_encrypted=""
|
||||
)
|
||||
db.add(connection)
|
||||
db.flush()
|
||||
for name in ("gemma4-moe", "qwen3-coder"):
|
||||
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
|
||||
return client
|
||||
|
||||
|
||||
def rewrite(html: str, client, assets: Path) -> str:
|
||||
"""Point every asset at a file on disk, and prove none was missed."""
|
||||
for route, name in ROUTE_ASSETS.items():
|
||||
response = client.get(route)
|
||||
if response.status_code == 200:
|
||||
(assets / name).write_text(response.text)
|
||||
|
||||
html = re.sub(
|
||||
r'(?:http://testserver)?/static/([^"\'?\s>]+)(\?[^"\'\s>]*)?',
|
||||
lambda m: f"file://{STATIC}/{m.group(1)}",
|
||||
html,
|
||||
)
|
||||
html = re.sub(
|
||||
r'(?:http://testserver)?/branding\.css(\?[^"\'\s>]*)?',
|
||||
f"file://{assets}/branding.css",
|
||||
html,
|
||||
)
|
||||
|
||||
# Fail loudly, and only about things that decide how the page LOOKS: every
|
||||
# `src`, and `href` on a <link>. An `href` on an anchor is a destination,
|
||||
# not an asset -- flagging those makes the guard cry wolf on every page and
|
||||
# a guard nobody believes is worse than none.
|
||||
leftovers = re.findall(r'<link\b[^>]*\bhref="([^"]+)"', html)
|
||||
leftovers += re.findall(r'\bsrc="([^"]+)"', html)
|
||||
blocking = [
|
||||
url
|
||||
for url in leftovers
|
||||
if url.startswith(("/", "http://testserver"))
|
||||
and not url.startswith(("/branding/", "/manifest", "/sw.js"))
|
||||
]
|
||||
if blocking:
|
||||
raise SystemExit(
|
||||
"UNREWRITTEN ASSET URLS -- this would measure an unstyled document: "
|
||||
f"{sorted(set(blocking))[:8]}"
|
||||
)
|
||||
|
||||
# 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
|
||||
# it runs before the deferred script that reads it.
|
||||
quiet = (
|
||||
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
|
||||
"catch(e){}</script>"
|
||||
)
|
||||
return html.replace("</head>", quiet + MEASURE + "</head>", 1)
|
||||
|
||||
|
||||
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
|
||||
"""One page, at one size, in one theme.
|
||||
|
||||
The page is rendered inside an <iframe> of exactly the target size rather
|
||||
than into a window of it, because headless Chromium refuses to make a window
|
||||
narrower than about 500px -- ask for 390 and you get 500, and every
|
||||
measurement is then of a layout no phone will ever produce. A media query
|
||||
inside an iframe evaluates against the iframe's own viewport, so this is the
|
||||
real thing: `width: 390px` on the frame is a 390px viewport inside it.
|
||||
"""
|
||||
response = client.get(path)
|
||||
if response.status_code != 200:
|
||||
raise SystemExit(f"{path} -> HTTP {response.status_code}")
|
||||
|
||||
assets = outdir / "assets"
|
||||
assets.mkdir(parents=True, exist_ok=True)
|
||||
html = response.text.replace('data-theme="moria"', f'data-theme="{theme}"')
|
||||
html = rewrite(html, client, assets)
|
||||
|
||||
slug = f"{path.strip('/').replace('/', '-') or 'root'}-{theme}-{width}x{height}"
|
||||
page = outdir / f"{slug}.html"
|
||||
page.write_text(html)
|
||||
|
||||
frame = outdir / f"{slug}-frame.html"
|
||||
frame.write_text(
|
||||
"<!doctype html><meta charset=utf-8>"
|
||||
"<style>html,body{margin:0;background:#888}"
|
||||
f"iframe{{width:{width}px;height:{height}px;border:0;display:block}}</style>"
|
||||
f'<iframe id="f" src="{page.name}"></iframe>'
|
||||
"<div id=\"__measurements\"></div>"
|
||||
"<script>"
|
||||
"window.addEventListener('load',function(){setTimeout(function(){"
|
||||
"var w=document.getElementById('f').contentWindow;"
|
||||
"document.getElementById('__measurements').textContent="
|
||||
"JSON.stringify(w.__measure?w.__measure():{error:'no __measure -- the page did not load'});"
|
||||
"},600);});"
|
||||
"</script>"
|
||||
)
|
||||
|
||||
shot = outdir / f"{slug}.png"
|
||||
common = [
|
||||
CHROMIUM, "--headless", "--no-sandbox", "--disable-gpu",
|
||||
"--allow-file-access-from-files", "--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
f"--window-size={max(width, 520)},{height + 40}",
|
||||
"--virtual-time-budget=4000",
|
||||
]
|
||||
subprocess.run(common + [f"--screenshot={shot}", f"file://{frame}"],
|
||||
capture_output=True, timeout=120)
|
||||
dom = subprocess.run(common + ["--dump-dom", f"file://{frame}"],
|
||||
capture_output=True, text=True, timeout=120).stdout
|
||||
|
||||
match = re.search(r'id="__measurements">(.*?)</div>', dom, re.S)
|
||||
if not match or not match.group(1).strip():
|
||||
raise SystemExit(f"no measurements for {slug} -- the frame did not report")
|
||||
data = json.loads(match.group(1))
|
||||
if "error" in data:
|
||||
raise SystemExit(f"{slug}: {data['error']}")
|
||||
data["page"] = slug
|
||||
if data["innerW"] != width:
|
||||
raise SystemExit(
|
||||
f"{slug}: measured a {data['innerW']}px viewport, asked for {width}px"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# The two the manifest asks for. Without them Chrome on Android falls back to
|
||||
# the one-line mini-infobar instead of the install dialog with a name, an icon
|
||||
# and a picture in it -- which is the difference between an install somebody
|
||||
# chooses and one they dismiss without reading.
|
||||
MANIFEST_SHOTS = (
|
||||
("screenshot-narrow.png", 390, 844, "narrow"),
|
||||
("screenshot-wide.png", 1280, 800, "wide"),
|
||||
)
|
||||
|
||||
|
||||
def manifest_screenshots(client, outdir: Path) -> None:
|
||||
"""Capture the two, straight into static/img/ where the manifest names them.
|
||||
|
||||
A browser capture rather than something `build_artwork.py` draws: the point
|
||||
of a screenshot is that it is what the application actually looks like, and
|
||||
an illustration of what it looks like is the one thing it must not be.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError: # pragma: no cover - design-time tool
|
||||
raise SystemExit("pillow is needed to crop the frame off a screenshot") from None
|
||||
|
||||
for name, width, height, _form in MANIFEST_SHOTS:
|
||||
shoot(client, "/chat", width, height, "moria", outdir)
|
||||
slug = f"chat-moria-{width}x{height}.png"
|
||||
target = STATIC / "img" / name
|
||||
# Cropped to the iframe, which sits at the origin of a zero-margin
|
||||
# wrapper. The capture is of the *outer* document, so without this the
|
||||
# screenshot carries the harness's own readout along its bottom edge
|
||||
# and a strip of grey beside it -- and a manifest screenshot is the one
|
||||
# picture of this application most people will ever see.
|
||||
with Image.open(outdir / slug) as shot:
|
||||
shot.crop((0, 0, width, height)).save(target)
|
||||
print(f"wrote {target.relative_to(REPO)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CHROMIUM:
|
||||
raise SystemExit("no chromium")
|
||||
|
||||
if "--manifest-screenshots" in sys.argv:
|
||||
outdir = Path(sys.argv[1]) if len(sys.argv) > 2 else Path(tempfile.mkdtemp())
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
manifest_screenshots(build_client(), outdir)
|
||||
return
|
||||
outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/lembas-shoot/out")
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
paths = sys.argv[2].split(",") if len(sys.argv) > 2 else ["/chat", "/settings"]
|
||||
sizes = [(390, 844), (360, 640), (1280, 800)]
|
||||
themes = ["moria", "shire"]
|
||||
|
||||
client = build_client()
|
||||
results = []
|
||||
for path in paths:
|
||||
for width, height in sizes:
|
||||
for theme in themes:
|
||||
results.append(shoot(client, path, width, height, theme, outdir))
|
||||
|
||||
(outdir / "results.json").write_text(json.dumps(results, indent=2))
|
||||
for r in results:
|
||||
flags = []
|
||||
if r["documentScrolls"]:
|
||||
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
|
||||
if r["scrollsSideways"]:
|
||||
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
|
||||
if r["overflowCount"]:
|
||||
flags.append(f"overflow:{r['overflowCount']}")
|
||||
if r["smallCount"]:
|
||||
flags.append(f"small-targets:{r['smallCount']}")
|
||||
print(f"{r['page']:44} {' '.join(flags) or 'clean'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "1.0.2"
|
||||
__version__ = "1.1.1"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
@@ -103,6 +104,25 @@ async def connections_page(request: Request, db: Db, user: AdminUser, message: s
|
||||
)
|
||||
|
||||
|
||||
# Header names are a narrow set on purpose: a newline would let one field write
|
||||
# a second header, and a colon in a name splits it. Anything outside it is
|
||||
# dropped rather than repaired -- a header nobody can see the effect of is worse
|
||||
# than one that is visibly missing.
|
||||
_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$")
|
||||
|
||||
|
||||
def _parse_headers(raw: str) -> dict[str, str]:
|
||||
"""`Name: value` per line, into the dict the client sends verbatim."""
|
||||
headers: dict[str, str] = {}
|
||||
for line in (raw or "").splitlines()[:20]:
|
||||
name, _, value = line.partition(":")
|
||||
name = name.strip()
|
||||
value = value.strip()[:500]
|
||||
if name and value and _HEADER_NAME.match(name):
|
||||
headers[name] = value
|
||||
return headers
|
||||
|
||||
|
||||
@router.post("/connections")
|
||||
async def create_connection(
|
||||
db: Db,
|
||||
@@ -145,6 +165,7 @@ async def update_connection(
|
||||
enabled: bool = Form(False),
|
||||
unload_url: str = Form(""),
|
||||
unload_method: str = Form("POST"),
|
||||
extra_headers: str = Form(""),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
@@ -157,6 +178,13 @@ async def update_connection(
|
||||
method = unload_method.strip().upper()
|
||||
connection.unload_method = method if method in ("GET", "POST") else "POST"
|
||||
|
||||
# `extra_headers_json` has been sent with every request to this endpoint
|
||||
# since it was added and written by no form in the application, so its one
|
||||
# documented use -- OpenRouter wants an `HTTP-Referer` and an `X-Title` --
|
||||
# was unreachable. One `Name: value` per line, because a JSON textarea asks
|
||||
# somebody to get braces right in a settings screen.
|
||||
connection.extra_headers_json = _parse_headers(extra_headers)
|
||||
|
||||
submitted = api_key.strip()
|
||||
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||
connection.api_key_encrypted = encrypt(submitted)
|
||||
|
||||
+76
-21
@@ -308,6 +308,12 @@ async def start_chat(
|
||||
if not content and not file_ids:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Before `_new_chat`, not after: a refusal that has already written the row
|
||||
# leaves an empty chat in the sidebar as the visible result of being told
|
||||
# no. There is no chat yet to exclude from the count, and none is needed --
|
||||
# nothing can be running for a chat that does not exist.
|
||||
_refuse_extra_reply(db, None, user)
|
||||
|
||||
chat = _new_chat(
|
||||
db,
|
||||
user,
|
||||
@@ -981,7 +987,7 @@ def _note_rewind(chat: Chat) -> None:
|
||||
chat.rewound_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
def _too_many_replies(db: DBSession, chat: Chat | None, user: User) -> str:
|
||||
"""Why this account may not start another reply right now, or "".
|
||||
|
||||
In-process, and that is exact rather than approximate only because this
|
||||
@@ -998,10 +1004,13 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
row[0]
|
||||
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
||||
}
|
||||
# `chat` is None on the new-chat path, where there is no row yet and so
|
||||
# nothing to exclude -- every running reply of theirs counts.
|
||||
here = chat.id if chat is not None else None
|
||||
running = sum(
|
||||
1
|
||||
for chat_id in mine
|
||||
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
|
||||
if chat_id != here and generation_service.running_for(chat_id) is not None
|
||||
)
|
||||
if running < ceiling:
|
||||
return ""
|
||||
@@ -1011,6 +1020,22 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _refuse_extra_reply(db: DBSession, chat: Chat | None, user: User) -> None:
|
||||
"""Raise if this account is already writing as many replies as it may.
|
||||
|
||||
A function rather than two lines repeated, because it is repeated five
|
||||
times now. It used to be called once -- from `_send`, which serves
|
||||
`post_message` and `execute_plan` -- while four other routes start a
|
||||
generation: `start_chat`, `edit_message`, `send_queued_now` and
|
||||
`regenerate`. So a group's `concurrent_replies` was reached by sending into
|
||||
a chat that already existed and walked straight past by pressing New chat,
|
||||
which is the commonest way to start a reply there is. A quota you can step
|
||||
over by using the obvious button is not a quota.
|
||||
"""
|
||||
if busy := _too_many_replies(db, chat, user):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||
|
||||
|
||||
def _send(
|
||||
request: Request,
|
||||
db: Db,
|
||||
@@ -1042,8 +1067,7 @@ def _send(
|
||||
# This chat's own reply does not count against it -- a second message here
|
||||
# is queued rather than sent, a few lines down, and that path is what the
|
||||
# queue is for.
|
||||
if busy := _too_many_replies(db, chat, user):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||
_refuse_extra_reply(db, chat, user)
|
||||
|
||||
if queued := _reply_in_flight(db, chat):
|
||||
waiting = db.scalar(
|
||||
@@ -1328,8 +1352,16 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
# template shares both roles, and a missing `user` would only
|
||||
# blow up on whichever branch is not being exercised here.
|
||||
"user": owner,
|
||||
# `owner`, never None. `models_visible_to` answers an absent
|
||||
# user with [], so a None here is not "every model" but *no*
|
||||
# model -- and this frame replaces the whole bubble at the
|
||||
# moment a reply finishes. The template then finds no
|
||||
# `speaking_model` and the finished reply swaps its avatar for
|
||||
# the LLeMbas mark, its author for the instance name, and grows
|
||||
# a raw model_id chip, all of which a reload silently corrects.
|
||||
# That is why it went unreported for so long.
|
||||
"models_by_id": {
|
||||
m.model_id: m for m in chat_service.available_models(db, None)
|
||||
m.model_id: m for m in chat_service.available_models(db, owner)
|
||||
},
|
||||
# This frame replaces the whole bubble, so it has to carry the
|
||||
# speaker button's conditions too -- and the owner's, not the
|
||||
@@ -1360,7 +1392,7 @@ def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Messa
|
||||
"message": message,
|
||||
"chat": chat,
|
||||
"user": owner,
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, owner)},
|
||||
**audio_service.template_flags(db, owner),
|
||||
}
|
||||
)
|
||||
@@ -1447,11 +1479,6 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||
"user": user,
|
||||
"messages": messages,
|
||||
"compacted": compacted,
|
||||
"bodies": {
|
||||
m.id: render_markdown(m.content)
|
||||
for m in everything
|
||||
if m.role == ROLE_ASSISTANT and m.content
|
||||
},
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
||||
**audio_service.template_flags(db, user),
|
||||
}
|
||||
@@ -1560,6 +1587,8 @@ async def edit_message(
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
||||
)
|
||||
# `_reply_in_flight` is about *this* chat; the quota is about the account.
|
||||
_refuse_extra_reply(db, chat, user)
|
||||
|
||||
message.content = content
|
||||
|
||||
@@ -1703,6 +1732,7 @@ async def send_queued_now(
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
||||
)
|
||||
_refuse_extra_reply(db, chat, user)
|
||||
|
||||
message.queued = False
|
||||
db.commit()
|
||||
@@ -1957,6 +1987,21 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
folder = db.get(Folder, wanted) if wanted else None
|
||||
chat.folder_id = folder.id if folder is not None and folder.user_id == user.id else None
|
||||
|
||||
# Out of the way, and reversible.
|
||||
#
|
||||
# `Chat.archived` has been filtered on in four places since folders arrived
|
||||
# and written by nothing anywhere -- so the hiding worked, the archiving
|
||||
# did not, and the column read as a built feature to anyone who grepped for
|
||||
# it. Here rather than as its own endpoint because it is a property of the
|
||||
# chat, exactly like its title and its folder, and `update_chat` already
|
||||
# reads the raw form for the reason this field needs too: absent must mean
|
||||
# "leave it alone" and "0" must mean "put it back".
|
||||
archived_changed = False
|
||||
if "archived" in form:
|
||||
wanted = str(form["archived"]).strip() not in ("", "0", "false")
|
||||
archived_changed = wanted != chat.archived
|
||||
chat.archived = wanted
|
||||
|
||||
# The mode is the one agent field that changes mid-chat: it decides what
|
||||
# gets asked about, not what the conversation is.
|
||||
if "agent_mode" in form:
|
||||
@@ -2070,6 +2115,24 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
return HTMLResponse(
|
||||
templates.get_template("chat/_title_oob.html").render({"chat": chat})
|
||||
)
|
||||
|
||||
# Archiving moves a row out of one group and into another, so the sidebar
|
||||
# has to be re-rendered -- and it cannot be, from a 204. htmx's own config
|
||||
# is `{code: "204", swap: false}`, so a control aimed at `#sidebar-tree`
|
||||
# with this endpoint's usual answer sets the column and then does visibly
|
||||
# nothing at all, which is this codebase's signature failure rather than a
|
||||
# new one. The same fragment and the same `oob` the sidebar switch returns,
|
||||
# for the same reason: New chat lives above the tree and comes along out of
|
||||
# band.
|
||||
if archived_changed:
|
||||
from lembas.api.pages import sidebar_context
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/_sidebar_tree.html",
|
||||
{"chat": None, "user": user, "oob": True, **sidebar_context(db, user)},
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -2123,16 +2186,6 @@ async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages/{message_id}/raw")
|
||||
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
|
||||
"""The unrendered Markdown of a message, for the copy button."""
|
||||
_owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
return HTMLResponse(escape_text(message.content))
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
||||
async def regenerate(
|
||||
request: Request,
|
||||
@@ -2147,6 +2200,8 @@ async def regenerate(
|
||||
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
||||
|
||||
_refuse_extra_reply(db, chat, user)
|
||||
|
||||
message.content = ""
|
||||
message.error = ""
|
||||
message.complete = False
|
||||
|
||||
@@ -21,7 +21,6 @@ from lembas.api.pages import _chat_context, sidebar_context
|
||||
from lembas.db.models import Message, Schedule
|
||||
from lembas.services import messages as messages_service
|
||||
from lembas.services import schedules as schedules_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.services.schedule import clock
|
||||
from lembas.services.schedule import rule as rule_service
|
||||
from lembas.web.templating import render
|
||||
@@ -31,11 +30,6 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["messages"])
|
||||
|
||||
|
||||
def _bodies(messages: list[Message]) -> dict[str, str]:
|
||||
"""Markdown rendered server-side, keyed by id, as `chat_detail` does."""
|
||||
return {m.id: render_markdown(m.content) for m in messages if m.role == "user"}
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
||||
conversation = messages_service.for_user(db, user)
|
||||
@@ -61,7 +55,6 @@ async def messages_page(request: Request, db: Db, user: RequiredUser):
|
||||
"chat": conversation,
|
||||
"messages": live,
|
||||
"compacted": [],
|
||||
"bodies": _bodies(live),
|
||||
"inherited_prompt": "",
|
||||
"inherited_from": "",
|
||||
"more_before": bool(live) and messages_service.has_more_before(
|
||||
@@ -109,7 +102,6 @@ async def messages_history(
|
||||
"messages/_history.html",
|
||||
{
|
||||
"messages": page,
|
||||
"bodies": _bodies(page),
|
||||
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
||||
"oldest_id": page[0].id,
|
||||
# `render()` injects `user` and friends; `TemplateResponse` does
|
||||
|
||||
+79
-3
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import (
|
||||
KIND_CHAT,
|
||||
KIND_MESSAGES,
|
||||
@@ -42,6 +43,17 @@ router = APIRouter(tags=["pages"])
|
||||
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"}
|
||||
|
||||
|
||||
def _instance_colour(brand) -> str:
|
||||
"""The background this instance paints before anything has loaded.
|
||||
|
||||
A custom theme sets `bg` itself; otherwise the built-in it inherits from
|
||||
decides, which is what `data-base` means everywhere else. Falls back to
|
||||
Moria rather than raising -- a splash screen is not worth a 500.
|
||||
"""
|
||||
theme = brand.theme(settings.default_theme)
|
||||
return theme.tokens.get("bg") or THEME_COLOUR.get(theme.base, THEME_COLOUR["moria"])
|
||||
|
||||
|
||||
def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
|
||||
"""Model lists and permissions every chat page needs.
|
||||
|
||||
@@ -388,9 +400,29 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
unfiled = list(
|
||||
db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
|
||||
)
|
||||
# The same query with the one filter inverted, and no `pinned` in the order:
|
||||
# a pinned chat that somebody archived is one they have said two opposite
|
||||
# things about, and the more recent instruction is the one to honour.
|
||||
archived = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.archived.is_(True),
|
||||
Chat.temporary.is_(False),
|
||||
Chat.kind.in_((kind,) if kind else KINDS),
|
||||
)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {
|
||||
"folders": folders,
|
||||
"unfiled_chats": unfiled,
|
||||
# Archived chats are NOT narrowed to unfiled ones: a chat inside a
|
||||
# folder disappears from that folder when it is archived (the folder's
|
||||
# own listing has always filtered them out), so without this it would
|
||||
# have left one list and joined none.
|
||||
"archived_chats": archived,
|
||||
# The shortcuts at the top of the sidebar. Here rather than in
|
||||
# `_chat_context`, where they used to be, for two reasons: they are
|
||||
# sidebar content and the fragment route that re-renders the sidebar has
|
||||
@@ -472,17 +504,61 @@ async def manifest(db: Db) -> Response:
|
||||
"""
|
||||
brand = branding_service.for_db(db)
|
||||
icons = brand.icon_paths
|
||||
colour = _instance_colour(brand)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": "/",
|
||||
# Matches `start_url`. An id is only an identity key and need not be
|
||||
# navigable, but "/" named a path that serves nothing but a redirect
|
||||
# while the app started somewhere else, which reads as a mistake to
|
||||
# anyone comparing the two.
|
||||
"id": "/chat",
|
||||
"name": brand.name,
|
||||
"short_name": brand.name[:12],
|
||||
"description": brand.tagline or "A web UI for your language models.",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"start_url": "/chat",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": THEME_COLOUR["moria"],
|
||||
"theme_color": THEME_COLOUR["moria"],
|
||||
# Ordered best-first: a browser takes the first it understands and
|
||||
# falls through to `display` if it understands none of them.
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "utilities"],
|
||||
# Opening a link belonging to this scope focuses the window that is
|
||||
# already open rather than making a second one.
|
||||
"launch_handler": {"client_mode": "navigate-existing"},
|
||||
# The launcher's long-press menu. Three destinations rather than
|
||||
# ten: a menu nobody can read at a glance is a menu nobody opens.
|
||||
"shortcuts": [
|
||||
{"name": "New chat", "url": "/chat"},
|
||||
{"name": "Messages", "url": "/messages"},
|
||||
{"name": "Scheduled", "url": "/scheduled"},
|
||||
],
|
||||
# Both follow whatever theme this instance is set up in. They were
|
||||
# Moria's near-black regardless, so a parchment instance installed
|
||||
# to a phone flashed a dark splash screen and then opened light --
|
||||
# and `THEME_COLOUR["shire"]` sat beside them, defined and read by
|
||||
# nothing. The *instance* default and not the reader's own theme:
|
||||
# a manifest is fetched without credentials unless the link asks
|
||||
# otherwise, so there is nobody to ask.
|
||||
"background_color": colour,
|
||||
"theme_color": colour,
|
||||
# Without these, Chrome on Android offers the one-line mini-infobar
|
||||
# rather than the install dialog that carries a name, an icon and a
|
||||
# picture -- which is the difference between an install somebody
|
||||
# chooses and one they swipe away without reading. Captured from the
|
||||
# running application by `scripts/shoot.py --manifest-screenshots`,
|
||||
# because the one thing a screenshot must not be is a drawing of
|
||||
# what the application looks like.
|
||||
"screenshots": [
|
||||
{"src": "/static/img/screenshot-narrow.png", "sizes": "390x844",
|
||||
"type": "image/png", "form_factor": "narrow",
|
||||
"label": "A conversation on a phone"},
|
||||
{"src": "/static/img/screenshot-wide.png", "sizes": "1280x800",
|
||||
"type": "image/png", "form_factor": "wide",
|
||||
"label": "A conversation, with the sidebar beside it"},
|
||||
],
|
||||
# An uploaded logo's derived icons, or the shipped ones. Whole-set
|
||||
# rather than per size: a manifest listing two custom icons and one
|
||||
# shipped is a launcher tile that changes when the device picks a
|
||||
|
||||
@@ -462,7 +462,20 @@ def theme_css(theme: Theme) -> str:
|
||||
if not theme.tokens:
|
||||
return ""
|
||||
lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
|
||||
for name, alpha in (("accent", "0.14"), ("leaf", "0.14"), ("danger", "0.14")):
|
||||
# Every settable colour that has a `-soft` companion in tokens.css, not the
|
||||
# three somebody stopped at. `success` and `warning` were settable and their
|
||||
# softs were not derived, so a custom theme moved the text and left the
|
||||
# background behind it in the base theme's hue -- an alert, a badge, a
|
||||
# permission's "on" state and the `+` lines of every agent diff, each in two
|
||||
# colours that were never meant to meet. Precisely the half-working failure
|
||||
# this function's own docstring says it exists to prevent.
|
||||
for name, alpha in (
|
||||
("accent", "0.14"),
|
||||
("leaf", "0.14"),
|
||||
("danger", "0.14"),
|
||||
("success", "0.14"),
|
||||
("warning", "0.14"),
|
||||
):
|
||||
soft = _soft(theme.tokens.get(name, ""), alpha)
|
||||
if soft:
|
||||
lines.append(f" --{name}-soft: {soft};")
|
||||
|
||||
@@ -250,6 +250,7 @@ def context_variables(
|
||||
"agent_mode": "",
|
||||
"agent_rewound": "",
|
||||
"background": "",
|
||||
"background_notify": "",
|
||||
"project_files": "",
|
||||
"agent_instructions": "",
|
||||
"agent_instructions_file": "",
|
||||
@@ -347,6 +348,9 @@ def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
|
||||
# Non-empty only when commands may run in the background, which is what
|
||||
# gates the fragment telling the model so.
|
||||
"background": "on" if context.background else "",
|
||||
# Its own gate, because the runner branches on it and the guidance
|
||||
# above says a turn will arrive. See `tool.background_notify`.
|
||||
"background_notify": "on" if context.background_notify else "",
|
||||
"max_rounds": str(context.limits.steps),
|
||||
# Blanked, which is what makes `core.rounds` vanish here: `steps` is a
|
||||
# runaway backstop and telling a model it has a budget of two hundred
|
||||
|
||||
@@ -214,6 +214,14 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
"Non-empty when a command may run detached. Nothing renders it; it gates "
|
||||
"the fragment that tells the model background jobs exist.",
|
||||
),
|
||||
Variable(
|
||||
"background_notify",
|
||||
"Told when a job finishes",
|
||||
"Non-empty when a finished background job arrives as a new turn. Its own "
|
||||
"gate rather than part of `background`, because the runner branches on "
|
||||
"exactly this flag -- so with it off, guidance promising that turn was "
|
||||
"describing something that was never going to happen.",
|
||||
),
|
||||
Variable(
|
||||
"plan",
|
||||
"The current plan",
|
||||
@@ -1489,12 +1497,53 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"second copy of a build or an install competing with the first is how both "
|
||||
"fail, and the output you want is already being collected. Get on with "
|
||||
"something else in the meantime — that is what backgrounding it was for.\n"
|
||||
"- Check on a job with job_output when you want to know where it got to."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.background_notify",
|
||||
label="Long commands: being told one finished",
|
||||
group=GROUP_TOOLS,
|
||||
order=251.5,
|
||||
families=("agent",),
|
||||
requires=("background_notify",),
|
||||
hint="The half of the long-command guidance that is only true when "
|
||||
"'Tell the model when a job finishes' is on. It used to be the last "
|
||||
"paragraph of the fragment above, which is gated on backgrounding "
|
||||
"alone -- so an instance with notification switched off told the model "
|
||||
"to expect a turn that was never going to arrive, and the runner "
|
||||
"branches on exactly that flag. One fragment, two behaviours.",
|
||||
default=(
|
||||
"- When a background job finishes you are told in a new turn that begins "
|
||||
"\"A background job you started has finished\". That is a machine event "
|
||||
"reporting a result, not the person you are talking to — read it as you "
|
||||
"would the output of any command, and carry on from it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.ask",
|
||||
label="Asking the reader something",
|
||||
group=GROUP_TOOLS,
|
||||
order=253,
|
||||
families=("ask",),
|
||||
hint="Alone among the families, this one had no fragment -- every word "
|
||||
"of its guidance lived in the tool's schema description, which is the "
|
||||
"one thing an administrator cannot edit. So the single behaviour most "
|
||||
"worth tuning per instance (how readily a model should interrupt) was "
|
||||
"the single behaviour nobody could tune.",
|
||||
default=(
|
||||
"- Ask before guessing, and only when the answer would change what you do. "
|
||||
"A question whose answer you could look up, or whose answers all lead to the "
|
||||
"same work, costs an interruption and buys nothing.\n"
|
||||
"- Ask everything you need in ONE ask_user call. Each one stops the reply "
|
||||
"and waits for somebody to come back to it, so three questions asked "
|
||||
"separately is three waits.\n"
|
||||
"- Always give options. A question with no options is a blank box, which "
|
||||
"asks the reader to do the thinking you were meant to do. Say whether they "
|
||||
"are alternatives or a set. Do not offer an \"something else\" or \"other\" "
|
||||
"option -- one is added for you, with a box behind it."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.agent_edits",
|
||||
label="Changing a file",
|
||||
|
||||
@@ -15,18 +15,17 @@
|
||||
that one, silently, while the reader was lost in the other. Under
|
||||
`.admin-scroll` the body is now an ordinary block and the page scrolls as one.
|
||||
*/
|
||||
.admin-scroll,
|
||||
.main > .tabs > .tabs__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
/* What makes one of these scroll is `.scroll-region` in app.css, which both of
|
||||
these selectors are listed in. Named there so the four declarations exist
|
||||
once; named *here* is the reasoning above, which is about which element is
|
||||
the scroller on which screen rather than about how a scroller behaves. */
|
||||
|
||||
.page,
|
||||
.admin-page {
|
||||
max-width: 48rem;
|
||||
/* The same measure as the transcript, and the same token: a settings page and
|
||||
a conversation are both prose, and having them differ by a rounding is the
|
||||
kind of thing nobody reports and everybody notices. */
|
||||
max-width: var(--thread-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
||||
}
|
||||
@@ -75,7 +74,7 @@
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 0 var(--sp-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
background: var(--bg);
|
||||
flex: none;
|
||||
overflow-x: auto;
|
||||
@@ -91,7 +90,7 @@
|
||||
contexts and would otherwise paint over it. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
z-index: var(--z-raised);
|
||||
}
|
||||
|
||||
.tabs__tab {
|
||||
@@ -100,7 +99,7 @@
|
||||
gap: var(--sp-2);
|
||||
height: var(--control-h-lg);
|
||||
padding: 0 var(--sp-4);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-bottom: var(--border-w-thick) solid transparent;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
@@ -150,7 +149,40 @@ a.tabs__tab { text-decoration: none; }
|
||||
.tabs__bar:has(input:nth-of-type(8):checked) ~ .tabs__body .tabs__panel:nth-of-type(8) {
|
||||
display: block;
|
||||
}
|
||||
.tabs__tab:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.tabs__tab:has(:focus-visible) { outline: var(--outline-w) solid var(--accent); outline-offset: -2px; }
|
||||
|
||||
/*
|
||||
The bar scrolls sideways when the tabs do not fit, and said nothing about it.
|
||||
|
||||
`scrollbar-width: none` is right -- a scrollbar under a row of tabs is ugly
|
||||
and, on a touch device, invisible anyway -- but with nothing in its place the
|
||||
overflow is undetectable. On a 390px phone the six tabs on /settings overflow
|
||||
by about 190px, and the two that fall off the end are Memory and Security,
|
||||
with Appearance only just reachable. Appearance is where both the Install and
|
||||
the Notifications buttons live, so the effect was an install prompt nobody
|
||||
could find on the device it exists for.
|
||||
|
||||
A fade at the edge that is only painted when there is something behind it:
|
||||
`scroll-driven` would be nicer and is not universal, so this is two gradients
|
||||
pinned to the scrollport with `background-attachment: local`, which is the old
|
||||
trick and works everywhere -- the `local` layers scroll with the content and
|
||||
cover the `scroll` ones exactly when there is nothing more to see.
|
||||
*/
|
||||
.tabs__bar {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--bg) 40%, transparent),
|
||||
linear-gradient(to left, var(--bg) 40%, transparent),
|
||||
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
||||
linear-gradient(to left, var(--scrim), transparent 1.5rem);
|
||||
background-position: left center, right center, left center, right center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5rem 100%;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
/* A tab is a destination, so a flick should land on one rather than between
|
||||
two. */
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
.tabs__tab { scroll-snap-align: start; }
|
||||
|
||||
/*
|
||||
A form's action row, and the space after the form it closes.
|
||||
@@ -177,7 +209,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
/* --- Cards ----------------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-5);
|
||||
margin-bottom: var(--sp-4);
|
||||
@@ -203,7 +235,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card__header {
|
||||
@@ -239,7 +271,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
gap: var(--sp-3); margin-bottom: var(--sp-4); flex-wrap: wrap; }
|
||||
.connection__footer { display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--sp-3); margin-top: var(--sp-5); padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||
border-top: var(--border-w) solid var(--border); flex-wrap: wrap; }
|
||||
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
|
||||
|
||||
/* --- Definition lists ------------------------------------------------------ */
|
||||
@@ -277,7 +309,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.filter-tab {
|
||||
display: inline-flex;
|
||||
@@ -285,7 +317,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
gap: var(--sp-2);
|
||||
height: var(--control-h);
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-bottom: var(--border-w-thick) solid transparent;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
@@ -319,7 +351,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
border-bottom: 0;
|
||||
background: var(--bg-sunken);
|
||||
@@ -327,7 +359,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
.bulk-bar__label { font-size: var(--text-sm); color: var(--ink-muted); }
|
||||
|
||||
.model-rows {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
@@ -337,7 +369,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.model-row:last-child { border-bottom: 0; }
|
||||
.model-row:hover { background: var(--surface-hover); }
|
||||
@@ -412,7 +444,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.model-list__item:first-child { padding-top: 0; }
|
||||
.model-list__item:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
@@ -429,7 +461,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
.perm-row {
|
||||
align-items: flex-start;
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.perm-row:last-child { border-bottom: 0; }
|
||||
.perm-row input { margin-top: 0.15rem; }
|
||||
@@ -474,7 +506,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
gap: var(--sp-3);
|
||||
align-items: baseline;
|
||||
padding: var(--sp-2) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
@@ -500,7 +532,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
@@ -527,7 +559,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
}
|
||||
|
||||
/* --- The permission modes, explained on the agents page ------------------- */
|
||||
@@ -556,7 +588,7 @@ a.tabs__tab { text-decoration: none; }
|
||||
correct: `_rule_from_form` reads only the keys the chosen repeat mode uses.
|
||||
*/
|
||||
.schedule-repeat {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-4);
|
||||
margin-bottom: var(--sp-4);
|
||||
|
||||
@@ -18,6 +18,33 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
A page built around the shell never scrolls its own document.
|
||||
|
||||
`.shell` is `100dvh` -- the *dynamic* viewport, which is what you can actually
|
||||
see -- while `html` and `body` above are `100%`, which resolves against the
|
||||
initial containing block and is the *large* viewport, the one you get with the
|
||||
browser's toolbar retracted. On a desktop those are the same number and this
|
||||
rule does nothing. On a phone they differ by the height of the toolbar, and
|
||||
the difference is a document taller than its own window: you scroll past the
|
||||
bottom of the sidebar and the main column into bare background, and because
|
||||
every gesture retracts or extends the toolbar the shell resizes underneath you
|
||||
and it never settles.
|
||||
|
||||
Reported on /settings, true of every page with a shell. `:has()` rather than a
|
||||
class because the shell is what decides this, not the route -- the auth, error
|
||||
and offline pages have no shell and genuinely do scroll their document, and
|
||||
they must keep doing so.
|
||||
*/
|
||||
html:has(body > .shell),
|
||||
body:has(> .shell) {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
/* A flick that reaches the end of an inner scroller stops there rather than
|
||||
pulling the page around behind it. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/*
|
||||
The `hidden` attribute has to win.
|
||||
|
||||
@@ -33,6 +60,11 @@ body {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* The browser's own grey flash on tap is a rectangle around whatever box the
|
||||
control happens to be, drawn in a colour no theme here chose. Removed in
|
||||
favour of the `:active` states below, which are the application's own --
|
||||
removed *with* a replacement, never on its own. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
@@ -66,7 +98,7 @@ button, input, textarea, select {
|
||||
|
||||
/* A single, consistent focus ring. Never remove it without a replacement. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline: var(--outline-w) solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -109,7 +141,7 @@ button, input, textarea, select {
|
||||
gap: var(--sp-2);
|
||||
height: var(--control-h);
|
||||
padding: 0 var(--control-px);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
@@ -222,7 +254,7 @@ button, input, textarea, select {
|
||||
width: 100%;
|
||||
height: var(--control-h);
|
||||
padding: 0 var(--control-px);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--ink);
|
||||
@@ -232,7 +264,7 @@ button, input, textarea, select {
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: var(--sp-2) var(--control-px);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--ink);
|
||||
@@ -248,7 +280,7 @@ button, input, textarea, select {
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
|
||||
|
||||
@@ -279,7 +311,7 @@ button, input, textarea, select {
|
||||
margin: 0 var(--sp-3) 0 0;
|
||||
padding: 0 var(--control-px);
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
border-right: var(--border-w) solid var(--border);
|
||||
background: var(--surface-hover);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
@@ -338,8 +370,8 @@ button, input, textarea, select {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-left-width: var(--border-w-accent);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--sp-4);
|
||||
@@ -367,6 +399,39 @@ button, input, textarea, select {
|
||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
||||
|
||||
/* --- Scroll regions --------------------------------------------------------
|
||||
The four declarations that make an element *the* scroller, written once.
|
||||
|
||||
They were spelled out five times -- the sidebar's list, the thread, the
|
||||
inspector, the canvas and (in admin.css) the tabs and admin pages -- and
|
||||
agreed on three of them. The fourth, `overscroll-behavior`, was on the
|
||||
sidebar alone, with a good comment explaining why it was needed there. It is
|
||||
needed everywhere for the same reason: a flick that reaches the end of a
|
||||
scroller chains to whatever is behind it, and behind these is the shell,
|
||||
which does not scroll -- so what the gesture produces is not a scrolled page
|
||||
but a rubber-band into blank background, which reads as the layout having
|
||||
come loose.
|
||||
|
||||
`min-height: 0` is the half that is load-bearing rather than cosmetic: a flex
|
||||
child will not shrink below its content without it, so a scroller missing it
|
||||
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
|
||||
scroll container's automatic minimum size to get away with omitting it, which
|
||||
is true and is not something the next person should have to know. */
|
||||
.scroll-region,
|
||||
.sidebar__scroll,
|
||||
.inspector__body,
|
||||
.canvas__body,
|
||||
.thread-scroll,
|
||||
.admin-scroll,
|
||||
.main > .tabs > .tabs__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
/* --- Application shell ----------------------------------------------------- */
|
||||
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
||||
|
||||
@@ -376,18 +441,45 @@ button, input, textarea, select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-sunken);
|
||||
border-right: 1px solid var(--border);
|
||||
border-right: var(--border-w) solid var(--border);
|
||||
min-height: 0;
|
||||
}
|
||||
.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;
|
||||
@@ -412,18 +504,9 @@ button, input, textarea, select {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* A `.scroll-region`; only the padding is its own. */
|
||||
.sidebar__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* A flick past the end of the list stops there rather than chaining to
|
||||
whatever is behind it. The shell is `overflow: hidden`, so what chaining
|
||||
produced was not a scrolled page but a rubber-band into blank background --
|
||||
which reads as the sidebar having come loose from the layout. */
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 var(--sp-2) var(--sp-3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
@@ -460,7 +543,7 @@ button, input, textarea, select {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-sunken);
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: var(--border-w) solid var(--border);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -473,10 +556,14 @@ button, input, textarea, select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
height: var(--header-height);
|
||||
/* The same sum as `.topbar`, for the same reason and so the three still line
|
||||
up across the shell -- which is the whole point of this element. */
|
||||
height: calc(var(--header-height) + var(--safe-top));
|
||||
padding-top: var(--safe-top);
|
||||
flex: none;
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-right: var(--sp-3);
|
||||
padding-left: var(--sp-3);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.panel-head__title {
|
||||
display: flex;
|
||||
@@ -491,12 +578,7 @@ button, input, textarea, select {
|
||||
}
|
||||
|
||||
.inspector__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.inspector__heading {
|
||||
@@ -532,7 +614,7 @@ button, input, textarea, select {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
@@ -564,7 +646,7 @@ button, input, textarea, select {
|
||||
/* So the resize handle can sit on the edge. */
|
||||
position: relative;
|
||||
background: var(--bg-sunken);
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: var(--border-w) solid var(--border);
|
||||
}
|
||||
|
||||
/* The drag handle on a panel's left edge. Wider than it looks -- a one-pixel
|
||||
@@ -634,7 +716,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
line-height: var(--leading-normal);
|
||||
@@ -671,7 +753,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--bg-sunken);
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: var(--border-w) solid var(--border);
|
||||
}
|
||||
.canvas__inner {
|
||||
display: flex;
|
||||
@@ -714,7 +796,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
background: var(--bg-sunken);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
.canvas__tab {
|
||||
display: inline-flex;
|
||||
@@ -723,7 +805,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
max-width: 14rem;
|
||||
/* Square at the bottom: a tab is attached to what it opens. */
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
border: 1px solid transparent;
|
||||
border: var(--border-w) solid transparent;
|
||||
border-bottom: 0;
|
||||
/* The strip's own bottom border is 1px; this covers it for the active tab
|
||||
without moving anything, so the row does not shift by a pixel on switch. */
|
||||
@@ -785,12 +867,12 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
gap: var(--sp-2);
|
||||
flex: none;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
}
|
||||
|
||||
.canvas__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Both axes, unlike every other scroll region: nothing re-wraps a source
|
||||
line, so it has to be reachable sideways. */
|
||||
overflow: auto;
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
@@ -820,7 +902,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
width: 100%;
|
||||
min-height: 24rem;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
color: var(--ink);
|
||||
@@ -850,10 +932,15 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: var(--header-height);
|
||||
/* The bar is `--header-height` of *content* and whatever the device puts
|
||||
above it. Installed on a phone the page runs under the status bar, so
|
||||
without this the title and the sidebar toggle sit beneath the clock. */
|
||||
height: calc(var(--header-height) + var(--safe-top));
|
||||
padding-top: var(--safe-top);
|
||||
flex: none;
|
||||
padding: 0 var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-right: max(var(--sp-4), var(--safe-right));
|
||||
padding-left: max(var(--sp-4), var(--safe-left));
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar__title {
|
||||
@@ -913,7 +1000,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
gap: var(--sp-2);
|
||||
height: var(--control-h);
|
||||
padding: 0 var(--sp-1) 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
@@ -922,14 +1009,14 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
.model-select:hover { border-color: var(--border-strong); }
|
||||
.model-select:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
.model-select__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); }
|
||||
|
||||
/* Collapsible settings panel, shared by chat settings and anything like it. */
|
||||
.panel {
|
||||
flex: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
background: var(--bg-sunken);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
@@ -1006,6 +1093,40 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
}
|
||||
.nav-item:hover .nav-item__actions,
|
||||
.nav-item:focus-within .nav-item__actions { opacity: 1; }
|
||||
/*
|
||||
There is no hover on a phone, and the row's own tap target is the link -- so
|
||||
tapping a chat navigated to it and these never appeared at all. Renaming or
|
||||
deleting a chat from a phone was not difficult, it was impossible.
|
||||
|
||||
`hover: none` rather than a width: a touchscreen laptop at 1440px has the same
|
||||
problem, and a narrow desktop window does not.
|
||||
*/
|
||||
@media (hover: none) {
|
||||
.nav-item__actions { opacity: 1; }
|
||||
}
|
||||
|
||||
/* The archived group. A `<summary>` is a real control, so it takes the row
|
||||
treatment rather than the label's -- it is something you press. */
|
||||
.nav-group--archived > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
list-style: none;
|
||||
}
|
||||
.nav-group--archived > summary::-webkit-details-marker { display: none; }
|
||||
.nav-group--archived > summary:hover { background: var(--surface-hover); color: var(--ink-muted); }
|
||||
.nav-group__count {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
/* Archived rows read as put away rather than as unavailable: dimmed until
|
||||
they are looked at, never greyed out -- every action on them still works. */
|
||||
.nav-group--archived .nav-item { opacity: 0.72; }
|
||||
.nav-group--archived .nav-item:hover,
|
||||
.nav-group--archived .nav-item:focus-within { opacity: 1; }
|
||||
|
||||
.nav-empty {
|
||||
padding: var(--sp-2);
|
||||
@@ -1029,7 +1150,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
width: 100%;
|
||||
max-width: 25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--sp-8);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -1050,7 +1171,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
.auth__footer {
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
@@ -1087,16 +1208,101 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* --- Small screens -------------------------------------------------------- */
|
||||
/*
|
||||
--- The sidebar, and the two different things "closed" means ---------------
|
||||
|
||||
Above the breakpoint the sidebar is a column and closed means "give the space
|
||||
to the conversation". Below it the sidebar is an overlay and closed is the
|
||||
*resting* state -- 280px of opaque drawer over a 390px screen is not a
|
||||
navigation aid, it is the page gone.
|
||||
|
||||
The `hidden` attribute cannot express that, because it is one value for both
|
||||
widths: it was absent, so the drawer was open on every phone, on every page,
|
||||
from the first paint -- with its own toggle underneath it. So the state is an
|
||||
attribute on <html> with three values, and the third is the one that matters:
|
||||
|
||||
data-sidebar="open" shown at every width
|
||||
data-sidebar="closed" hidden at every width
|
||||
(absent) follow the width -- open wide, closed narrow
|
||||
|
||||
Absent is what the server renders, because the server does not know how wide
|
||||
the window is. See `setSidebar` in app.js, which is also why this panel does
|
||||
not go through `setPanel` like the three on the other side.
|
||||
*/
|
||||
:root[data-sidebar="closed"] .sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Above the breakpoint the drawer's own furniture has no job. Declared BEFORE
|
||||
the media query that turns it back on: both rules are one class deep, so
|
||||
source order is what decides, and this one written afterwards made the close
|
||||
button `display: none` at every width -- including inside the open drawer,
|
||||
which is the only place it exists for. */
|
||||
.sidebar__close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
/* Never the full width, and never wider than the screen: a drawer with no
|
||||
page showing beside it gives nothing to tap to dismiss it, and reads as
|
||||
a navigation *page* you have arrived at rather than a layer over the one
|
||||
you were on. */
|
||||
width: min(var(--sidebar-width), 84vw);
|
||||
z-index: var(--z-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
/* Off-screen rather than `display: none`, so opening it is a movement the
|
||||
eye can follow from the button that caused it. `visibility` is what
|
||||
takes it out of the tab order while it is away -- `transform` alone
|
||||
leaves every control in it focusable, just somewhere nobody can see. */
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
transition: transform var(--dur-3) var(--ease-out),
|
||||
visibility var(--dur-3) var(--ease-out);
|
||||
}
|
||||
/* Hiding it is the `hidden` attribute, forced to win at the top of this
|
||||
file. There used to be a `[data-collapsed="true"]` rule here that nothing
|
||||
ever set. */
|
||||
:root:not([data-sidebar="closed"]) .sidebar {
|
||||
/* `display` must not be the thing that hides it here, or there is nothing
|
||||
to animate. The attribute rule above is reversed for this width. */
|
||||
display: flex;
|
||||
}
|
||||
:root[data-sidebar="open"] .sidebar {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Its own edges, once it is the thing against the side of the screen. */
|
||||
.sidebar__header,
|
||||
.sidebar__actions,
|
||||
.sidebar__scroll {
|
||||
padding-left: max(var(--sp-3), var(--safe-left));
|
||||
}
|
||||
.sidebar__footer {
|
||||
padding-bottom: max(var(--sp-2), var(--safe-bottom));
|
||||
}
|
||||
|
||||
.sidebar__close {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Dismissible by tapping beside it. Without this the only way out is a
|
||||
button, and a drawer you can only leave deliberately is one people close
|
||||
by reloading. */
|
||||
:root[data-sidebar="open"] .sidebar-scrim {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: calc(var(--z-panel) - 1);
|
||||
background: var(--scrim);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--dur-3) var(--ease-out);
|
||||
}
|
||||
|
||||
/* --- Toasts ----------------------------------------------------------------
|
||||
@@ -1120,8 +1326,8 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-left: var(--border-w-accent) solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -1138,21 +1344,31 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
.toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.toast__close {
|
||||
flex: none;
|
||||
/* It had no height at all -- `font-size` and 0.15rem of side padding, which
|
||||
is about 18x7px. The smallest target in the application, on the one control
|
||||
somebody reaches for when they are already mildly annoyed. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--control-h-sm);
|
||||
height: var(--control-h-sm);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--ink-faint);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
padding: 0;
|
||||
}
|
||||
.toast__close:hover { color: var(--ink); }
|
||||
.toast__action { flex: none; align-self: center; }
|
||||
|
||||
/* --- Dialogs ----------------------------------------------------------------
|
||||
<dialog> gives focus trapping, Escape and page inertness for free.
|
||||
*/
|
||||
.dialog {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -1205,7 +1421,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
height: var(--control-h);
|
||||
max-width: 16rem;
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -1216,7 +1432,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
.picker__button:hover { border-color: var(--border-strong); }
|
||||
.picker__button[aria-expanded="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
.picker__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
|
||||
.picker__label {
|
||||
@@ -1234,7 +1450,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
right: 0;
|
||||
z-index: var(--z-dropdown);
|
||||
width: min(24rem, calc(100vw - var(--sp-8)));
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -1290,7 +1506,7 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: var(--border-w) solid var(--border);
|
||||
background: var(--bg-sunken);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
@@ -1305,12 +1521,12 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
max-height: min(24rem, 50vh);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.dialog__results .picker__list { max-height: none; }
|
||||
|
||||
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||
.picker__search { padding: var(--sp-2); border-bottom: var(--border-w) solid var(--border); }
|
||||
/*
|
||||
Small controls, declared here because this is the file every page loads.
|
||||
|
||||
@@ -1387,3 +1603,119 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* --- Saying that something is happening ------------------------------------
|
||||
A three-pixel bar across the top of the window, above everything including
|
||||
the panels, because it describes the whole page rather than any part of it.
|
||||
|
||||
It never claims to know how far along it is. A request whose length is
|
||||
unknown and a bar that fills at a constant rate is a lie that gets found out
|
||||
on every slow request -- so this one travels, and stops when the answer
|
||||
lands. `transform` only, so it costs no layout on a page that may be
|
||||
streaming a reply at twelve frames a second underneath it.
|
||||
*/
|
||||
.progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--border-w-accent);
|
||||
z-index: var(--z-toast);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-2) var(--ease-out);
|
||||
}
|
||||
.progress.is-busy { opacity: 1; }
|
||||
.progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(90deg, transparent, var(--leaf), transparent);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.progress.is-busy span { animation: progress-sweep var(--dur-slow) var(--ease-in-out) infinite; }
|
||||
@keyframes progress-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
/* --- Content that has not arrived yet ---------------------------------------
|
||||
A shape where the thing will be, rather than a blank. Used with `aria-hidden`
|
||||
on whatever is waiting, so a screen reader is not read a paragraph of
|
||||
nothing.
|
||||
|
||||
The shimmer is a moving gradient rather than an opacity pulse, because a list
|
||||
of eight pulsing blocks all at the same phase reads as a fault. */
|
||||
.skeleton {
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--surface) 0%,
|
||||
var(--surface-hover) 50%,
|
||||
var(--surface) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-sweep var(--dur-slow) var(--ease-in-out) infinite;
|
||||
}
|
||||
.skeleton--row { height: var(--control-h); margin-bottom: var(--sp-1); }
|
||||
.skeleton--line { height: var(--text-base); margin-bottom: var(--sp-2); }
|
||||
.skeleton--short { width: 60%; }
|
||||
@keyframes skeleton-sweep {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* --- Press ----------------------------------------------------------------
|
||||
The tap highlight was removed in the reset, so something has to take its
|
||||
place: a control that moves under the finger is the cheapest possible
|
||||
confirmation that the tap landed, and the only one that works before the
|
||||
request it started has answered. Kept small -- this is feedback, not an
|
||||
animation somebody has to sit through. */
|
||||
.btn:active:not(:disabled),
|
||||
.nav-item:active,
|
||||
.tabs__tab:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn { transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
color var(--transition-fast), transform var(--dur-1) var(--ease-out); }
|
||||
|
||||
/* --- Arrival ---------------------------------------------------------------
|
||||
`@starting-style` plus `allow-discrete` is what lets a `display: none`
|
||||
element animate in with no JavaScript at all and no class to add and remove.
|
||||
Where it is unsupported the element simply appears, which is what it did
|
||||
before. */
|
||||
.dialog {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
transition: opacity var(--dur-2) var(--ease-out),
|
||||
transform var(--dur-2) var(--ease-spring),
|
||||
overlay var(--dur-2) allow-discrete,
|
||||
display var(--dur-2) allow-discrete;
|
||||
}
|
||||
.dialog[open] { opacity: 1; transform: none; }
|
||||
@starting-style {
|
||||
.dialog[open] { opacity: 0; transform: scale(0.97); }
|
||||
}
|
||||
.dialog::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-2) var(--ease-out),
|
||||
overlay var(--dur-2) allow-discrete,
|
||||
display var(--dur-2) allow-discrete;
|
||||
}
|
||||
.dialog[open]::backdrop { opacity: 1; }
|
||||
@starting-style {
|
||||
.dialog[open]::backdrop { opacity: 0; }
|
||||
}
|
||||
|
||||
/* A card lifts a little under the pointer -- only where there is a pointer, and
|
||||
only where the card is something you can act on. */
|
||||
@media (hover: hover) {
|
||||
a.card:hover,
|
||||
.card--action:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
}
|
||||
a.card, .card--action { transition: transform var(--dur-2) var(--ease-out),
|
||||
box-shadow var(--dur-2) var(--ease-out); }
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
*/
|
||||
|
||||
/* --- Thread --------------------------------------------------------------- */
|
||||
/* A `.scroll-region` (app.css); the smooth behaviour is this one's own, because
|
||||
this is the scroller something is repeatedly scrolled *to* -- the newest
|
||||
message, a jump back to the bottom -- and the others are not. */
|
||||
.thread-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.thread {
|
||||
@@ -23,11 +22,22 @@
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
/*
|
||||
The new-chat screen.
|
||||
|
||||
Deliberately the only thing in the transcript that animates on arrival.
|
||||
A message bubble must not: the steps container is replaced with `innerHTML`
|
||||
up to twelve times a second while a reply streams, and the `done` frame
|
||||
replaces the whole article -- so an entry animation on a bubble re-triggers
|
||||
on every swap and what it produces is not an arrival, it is a flicker at
|
||||
twelve hertz. This element renders once and is never swapped.
|
||||
*/
|
||||
.thread__intro {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
text-align: center;
|
||||
animation: intro-rise var(--dur-3) var(--ease-out) both;
|
||||
padding: var(--sp-12) 0 var(--sp-6);
|
||||
}
|
||||
|
||||
@@ -154,7 +164,7 @@
|
||||
/* --- Reasoning ------------------------------------------------------------ */
|
||||
.reasoning {
|
||||
margin: 0 0 var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
font-size: var(--text-sm);
|
||||
@@ -269,7 +279,7 @@
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
@@ -278,7 +288,7 @@
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
||||
.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.suggestion:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.suggestion__name { font-weight: 600; font-size: var(--text-sm); }
|
||||
.suggestion__note {
|
||||
@@ -348,7 +358,7 @@
|
||||
.reasoning__body {
|
||||
padding: 0 var(--sp-3) var(--sp-3);
|
||||
margin-left: var(--sp-2);
|
||||
border-left: 2px solid var(--border-strong);
|
||||
border-left: var(--border-w-thick) solid var(--border-strong);
|
||||
padding-left: var(--sp-3);
|
||||
white-space: pre-wrap;
|
||||
color: var(--ink-muted);
|
||||
@@ -359,8 +369,48 @@
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Gentle pulse on the icon while thinking is still streaming. */
|
||||
.reasoning--live .reasoning__icon { animation: think-pulse 1.6s ease-in-out infinite; }
|
||||
/*
|
||||
While a model is thinking.
|
||||
|
||||
This was an opacity fade on the icon, which at a glance is indistinguishable
|
||||
from an icon that is simply a bit faint -- and "is it working or has it
|
||||
stopped?" is the one question this element exists to answer. So it now turns
|
||||
as well as breathes, and carries a ring that sweeps: rotation is the thing the
|
||||
eye reads as *ongoing* rather than as decoration, and it is the difference
|
||||
between a reply that is being written and one that has quietly died.
|
||||
|
||||
Two animations on two elements rather than one compound transform, because the
|
||||
icon is a `<use>` of a shared sprite and the ring is a pseudo-element -- and
|
||||
because `prefers-reduced-motion` should be able to stop the spin while leaving
|
||||
the colour, which two separate declarations allow and one does not.
|
||||
|
||||
No timer, no class to add or remove, nothing to clean up: it stops existing
|
||||
when the element does, which is the same reason the animated ellipsis is a
|
||||
`content` keyframe.
|
||||
*/
|
||||
.reasoning--live .reasoning__icon {
|
||||
animation: think-pulse var(--dur-slow) var(--ease-in-out) infinite,
|
||||
think-turn calc(var(--dur-slow) * 2.5) linear infinite;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.reasoning--live .reasoning__label { position: relative; }
|
||||
.reasoning--live .reasoning__label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -2px;
|
||||
height: var(--border-w);
|
||||
background: linear-gradient(90deg, transparent, var(--leaf), transparent);
|
||||
background-size: 50% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: think-sweep calc(var(--dur-slow) * 1.5) var(--ease-in-out) infinite;
|
||||
}
|
||||
@keyframes think-turn { to { transform: rotate(360deg); } }
|
||||
@keyframes think-sweep {
|
||||
0% { background-position: -60% 0; }
|
||||
100% { background-position: 160% 0; }
|
||||
}
|
||||
@keyframes think-pulse {
|
||||
0%, 100% { opacity: 0.45; }
|
||||
50% { opacity: 1; }
|
||||
@@ -374,7 +424,7 @@
|
||||
|
||||
.tool-activity {
|
||||
margin: 0 0 var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
font-size: var(--text-sm);
|
||||
@@ -420,7 +470,7 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: var(--sp-3);
|
||||
border-left: 2px solid var(--border-strong);
|
||||
border-left: var(--border-w-thick) solid var(--border-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-result__title {
|
||||
@@ -512,7 +562,7 @@
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-3) 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--accent);
|
||||
border: var(--border-w) solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -539,7 +589,7 @@
|
||||
}
|
||||
.interaction__question + .interaction__question {
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
}
|
||||
.interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; }
|
||||
/* Stacked, one per line. A row of chips was fine while an option was two words
|
||||
@@ -557,7 +607,7 @@
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
@@ -578,7 +628,7 @@
|
||||
background: var(--surface-active);
|
||||
}
|
||||
.interaction__option:has(input:focus-visible) {
|
||||
outline: 2px solid var(--accent);
|
||||
outline: var(--outline-w) solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -619,7 +669,7 @@
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink-muted);
|
||||
@@ -631,7 +681,7 @@
|
||||
background: var(--surface-active);
|
||||
color: var(--ink);
|
||||
}
|
||||
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.chip input:focus-visible + span { outline: var(--outline-w) solid var(--accent); outline-offset: 2px; }
|
||||
.interaction__detail {
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
@@ -774,6 +824,11 @@
|
||||
}
|
||||
.msg:hover .msg__actions,
|
||||
.msg:focus-within .msg__actions { opacity: 1; }
|
||||
/* Copy, regenerate, edit and read-aloud were hover-only, which on a phone means
|
||||
they did not exist. See the same rule on `.nav-item__actions` in app.css. */
|
||||
@media (hover: none) {
|
||||
.msg__actions { opacity: 1; }
|
||||
}
|
||||
.msg__actions .is-copied { color: var(--success); }
|
||||
|
||||
/* --- A turn nobody typed ---------------------------------------------------
|
||||
@@ -793,7 +848,7 @@
|
||||
.msg--machine .msg__author { color: var(--ink-muted); font-weight: 500; }
|
||||
.msg--user.msg--machine .msg__body--plain {
|
||||
background: var(--bg-sunken);
|
||||
border-inline-start: 2px solid var(--border-strong);
|
||||
border-inline-start: var(--border-w-thick) solid var(--border-strong);
|
||||
border-start-start-radius: var(--radius-sm);
|
||||
border-end-start-radius: var(--radius-sm);
|
||||
color: var(--ink-muted);
|
||||
@@ -836,12 +891,12 @@
|
||||
.msg__body blockquote {
|
||||
margin: 0 0 var(--sp-4);
|
||||
padding: var(--sp-1) var(--sp-4);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
border-left: var(--border-w-accent) solid var(--border-strong);
|
||||
color: var(--ink-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
|
||||
.msg__body hr { border: 0; border-top: var(--border-w) solid var(--border); margin: var(--sp-5) 0; }
|
||||
|
||||
.msg__body :not(pre) > code {
|
||||
font-family: var(--font-mono);
|
||||
@@ -849,7 +904,7 @@
|
||||
padding: 0.13em 0.36em;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
}
|
||||
|
||||
.msg__body table {
|
||||
@@ -861,7 +916,7 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
.msg__body th, .msg__body td {
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
text-align: left;
|
||||
}
|
||||
@@ -872,7 +927,7 @@
|
||||
/* --- Code blocks ---------------------------------------------------------- */
|
||||
.code-block {
|
||||
margin: 0 0 var(--sp-4);
|
||||
border: 1px solid var(--code-border);
|
||||
border: var(--border-w) solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--code-bg);
|
||||
overflow: hidden;
|
||||
@@ -882,7 +937,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
border-bottom: 1px solid var(--code-border);
|
||||
border-bottom: var(--border-w) solid var(--code-border);
|
||||
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
|
||||
}
|
||||
.code-block__pre {
|
||||
@@ -948,7 +1003,7 @@
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
@@ -1158,7 +1213,7 @@
|
||||
while the header over it sat --sp-3 in. The padding goes inside the row and
|
||||
the border stays on it, so the divider is still full-bleed -- which is what
|
||||
makes a stack of rows read as a list rather than as paragraphs. */
|
||||
.jobs__row { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); }
|
||||
.jobs__row { padding: var(--sp-2) var(--sp-3); border-bottom: var(--border-w) solid var(--border); }
|
||||
.jobs__row:last-child { border-bottom: 0; }
|
||||
|
||||
/* Which row's log is on screen. An inset shadow rather than a
|
||||
@@ -1276,7 +1331,7 @@
|
||||
max-height: min(20rem, 45vh);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -1287,7 +1342,7 @@
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: var(--border-w) solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-xs);
|
||||
@@ -1322,7 +1377,7 @@
|
||||
.sheet { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
.sheet td { padding: var(--sp-1) var(--sp-2); vertical-align: top; }
|
||||
.sheet td:first-child { white-space: nowrap; color: var(--ink-muted); width: 1%; }
|
||||
.sheet tr + tr td { border-top: 1px solid var(--border); }
|
||||
.sheet tr + tr td { border-top: var(--border-w) solid var(--border); }
|
||||
|
||||
/* --- Folders -------------------------------------------------------------- */
|
||||
.folder__row { padding-right: var(--sp-1); }
|
||||
@@ -1378,7 +1433,7 @@
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
max-width: 20rem;
|
||||
@@ -1453,7 +1508,7 @@
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
font-size: var(--text-sm);
|
||||
@@ -1532,7 +1587,7 @@
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border: var(--border-w) solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
@@ -1564,7 +1619,7 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.segmented__option input:focus-visible + span {
|
||||
outline: 2px solid var(--accent);
|
||||
outline: var(--outline-w) solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* The sidebar's copy fills its column rather than sitting at its content
|
||||
@@ -1577,8 +1632,8 @@
|
||||
.plan {
|
||||
margin: var(--sp-3) 0;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-left: 3px solid var(--accent);
|
||||
border: var(--border-w) solid var(--border-strong);
|
||||
border-left: var(--border-w-accent) solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -1652,3 +1707,22 @@
|
||||
padding: var(--sp-4) 0;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* The shape of the turns being fetched, at the width they will arrive in. */
|
||||
.history-sentinel__shape {
|
||||
width: 100%;
|
||||
max-width: var(--thread-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--sp-5);
|
||||
}
|
||||
|
||||
/* The mark first, then the question, then the line under it -- a tenth of a
|
||||
second apart, which is enough to read as one movement rather than three
|
||||
things appearing at once. */
|
||||
@keyframes intro-rise {
|
||||
from { opacity: 0; transform: translateY(var(--sp-2)); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
|
||||
.thread__intro > *:nth-child(2) { animation-delay: 60ms; }
|
||||
.thread__intro > *:nth-child(3) { animation-delay: 120ms; }
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.25rem;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.6;
|
||||
@@ -42,7 +41,6 @@
|
||||
--sp-8: 2rem;
|
||||
--sp-10: 2.5rem;
|
||||
--sp-12: 3rem;
|
||||
--sp-16: 4rem;
|
||||
|
||||
/* --- Radius & shadow -------------------------------------------------- */
|
||||
--radius-sm: 4px;
|
||||
@@ -119,12 +117,88 @@
|
||||
--z-handle: 10;
|
||||
--z-dropdown: 30;
|
||||
--z-panel: 40;
|
||||
--z-overlay: 50;
|
||||
--z-toast: 60;
|
||||
|
||||
--transition-fast: 120ms ease;
|
||||
--transition: 200ms ease;
|
||||
|
||||
/* --- Borders -----------------------------------------------------------
|
||||
A hairline was a literal `1px` in about ninety places, which made it the
|
||||
largest category of hard-coded value left in the codebase -- and the one
|
||||
thing a theme cannot currently change. */
|
||||
--border-w: 1px;
|
||||
--border-w-thick: 2px;
|
||||
--border-w-accent: 3px;
|
||||
|
||||
/* The focus outline's own width. Not `--border-w-thick`, though they are the
|
||||
same number today: an outline is drawn outside the box and takes no space,
|
||||
a border is part of the box and does. Making one of them follow the other
|
||||
means a theme that wants a heavier border gets a heavier focus ring too,
|
||||
which is two decisions tied together by a coincidence. */
|
||||
--outline-w: 2px;
|
||||
|
||||
/* --- Touch --------------------------------------------------------------
|
||||
A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is
|
||||
36 -- comfortable with a pointer and under every published minimum for a
|
||||
finger -- so the coarse-pointer block at the foot of this file raises the
|
||||
control tokens to this rather than patching components one at a time.
|
||||
Raising the token is the only version that reaches all of them, and it is
|
||||
what `--control-h` exists for. */
|
||||
--tap-min: 2.75rem;
|
||||
|
||||
/* --- The window's own edges ---------------------------------------------
|
||||
Installed on a phone, the page runs under the notch and the home
|
||||
indicator: base.html asks iOS for `black-translucent`, which is what puts
|
||||
it there, and `viewport-fit=cover` is what lets these resolve to anything
|
||||
but zero. Declared here so no component spells `env()` out -- and so a
|
||||
desktop browser, where all four are 0, costs nothing. */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
|
||||
/* --- Breakpoints --------------------------------------------------------
|
||||
A media query cannot read a custom property, so these cannot be *used*
|
||||
here. They are declared anyway so the numbers have one home and a grep for
|
||||
one lands somewhere that says what it means -- and
|
||||
`tests/test_layout_bounds.py` refuses a width in any stylesheet that is not
|
||||
declared here, so a fourth breakpoint invented in passing fails the suite
|
||||
rather than joining the set unannounced.
|
||||
|
||||
--bp-admin 44rem 704px a two-column reference row stacks
|
||||
--bp-narrow 48rem 768px the sidebar becomes a drawer, and controls
|
||||
grow to a thumb's size
|
||||
--bp-wide 64rem 1024px the right-hand panels become overlays */
|
||||
--bp-admin: 44rem;
|
||||
--bp-narrow: 48rem;
|
||||
--bp-wide: 64rem;
|
||||
|
||||
/* --- Motion -------------------------------------------------------------
|
||||
Durations and curves, so the `prefers-reduced-motion` block at the foot of
|
||||
this file keeps covering everything by construction: a literal `1.6s` in a
|
||||
component is a value that block can still neutralise, but one nobody can
|
||||
tune. `--ease-out` is the one to reach for -- something arriving should
|
||||
decelerate; `--ease-spring` overshoots slightly and belongs on a thing
|
||||
that appears, never on a thing that moves under the pointer. */
|
||||
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--dur-1: 120ms;
|
||||
--dur-2: 200ms;
|
||||
--dur-3: 320ms;
|
||||
--dur-slow: 1.6s;
|
||||
|
||||
/* --- Panel minimums -----------------------------------------------------
|
||||
`api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored
|
||||
against an account and only two of them -- the two with a drag handle --
|
||||
had a `-min` token or a `min-width` to clamp with. The other two are not
|
||||
draggable, so nothing in the interface could produce a bad value; but the
|
||||
endpoint takes one from anybody signed in, `base.html` applies stored
|
||||
widths to <html> before first paint, and with no clamp a stored 800px
|
||||
sidebar is one nothing in the application can drag back. */
|
||||
--sidebar-width-min: 12.5rem;
|
||||
--inspector-width-min: 17.5rem;
|
||||
|
||||
/* The focus treatment, written once. Three components spelled it out. It
|
||||
resolves --accent-soft at the point of use, so it follows the theme even
|
||||
though it is declared above them. */
|
||||
@@ -322,6 +396,37 @@
|
||||
--ansi-bright-white: #453A2A;
|
||||
}
|
||||
|
||||
/*
|
||||
--- Touch -----------------------------------------------------------------
|
||||
A pointer is precise and a finger is about 9mm across, so the same control
|
||||
cannot be the right size for both. `--control-h` is 36px, which is comfortable
|
||||
with a mouse and under every published minimum for a thumb; `--control-h-sm`
|
||||
is 28px, which is a target most people miss.
|
||||
|
||||
Raised here rather than patched per component, because there are upwards of
|
||||
forty of them and the next one added would be 36px again. `--control-h` is
|
||||
what every button, input and select resolves its height from, so one block
|
||||
moves all of them -- which is the reason that token exists.
|
||||
|
||||
Two conditions, either of which is enough.
|
||||
|
||||
`(pointer: coarse)` is the honest one: it is the input device that decides how
|
||||
big a target has to be, and a touchscreen laptop at 1440px has the same thumb
|
||||
as a phone. But a layout below the phone breakpoint is a one-column, drawer-
|
||||
navigated layout whatever is pointing at it -- there is room for bigger
|
||||
controls and every reason to use it -- and that half is also the half a
|
||||
headless browser can be made to prove, which is not nothing: a rule that can
|
||||
only be checked by holding a phone is a rule that quietly rots.
|
||||
*/
|
||||
@media (pointer: coarse), (max-width: 48rem) {
|
||||
:root {
|
||||
--control-h: var(--tap-min);
|
||||
--control-h-sm: 2.25rem;
|
||||
--control-px: var(--sp-4);
|
||||
--control-px-sm: var(--sp-3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect a stated preference for reduced motion everywhere, at once. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
@@ -332,4 +437,16 @@
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
/* The motion tokens too, for anything that composes a duration rather than
|
||||
declaring one -- a `transition: transform var(--dur-3)` is neutralised by
|
||||
the rule above, but an `animation-delay` built from one is not. */
|
||||
:root {
|
||||
--dur-1: 0.01ms;
|
||||
--dur-2: 0.01ms;
|
||||
--dur-3: 0.01ms;
|
||||
--dur-slow: 0.01ms;
|
||||
--transition-fast: 0.01ms;
|
||||
--transition: 0.01ms;
|
||||
--transition-slow: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -54,11 +54,20 @@
|
||||
/* Installed, the browser's own chrome is the application's chrome, so it
|
||||
has to follow the theme too. Read from the stylesheet rather than
|
||||
repeating the hex here: tokens.css is the one place colours live. */
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
var bg = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--bg").trim();
|
||||
if (bg) meta.setAttribute("content", bg);
|
||||
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||
var bg = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--bg").trim();
|
||||
if (bg) {
|
||||
metas.forEach(function (meta) {
|
||||
/* There are two of them, scoped by `prefers-color-scheme`, so that a
|
||||
light instance is not painted dark before this file has run. Once it
|
||||
has, the reader's *chosen* theme is the answer and the system's
|
||||
preference is not -- somebody on the parchment theme inside a dark
|
||||
desktop wants parchment. Dropping the `media` attribute is what makes
|
||||
the choice win; leaving it would let the unchosen one apply. */
|
||||
meta.removeAttribute("media");
|
||||
meta.setAttribute("content", bg);
|
||||
});
|
||||
}
|
||||
|
||||
/* The toggle names where it is going, not where it is. With more than two
|
||||
@@ -668,7 +677,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- The sidebar -------------------------------------------------------
|
||||
Its own pair of functions rather than a branch inside `setPanel`, because
|
||||
it is the one panel whose *default* depends on the width of the window:
|
||||
open beside the conversation on a desktop, closed over it on a phone. The
|
||||
`hidden` attribute the other three use is a single value for both, which
|
||||
is how the drawer came to be open on every phone with its own toggle
|
||||
underneath it.
|
||||
|
||||
`data-sidebar` on <html> has a third state -- absent -- meaning "follow
|
||||
the width", and absent is what the server renders, because the server
|
||||
cannot know the width. Everything downstream is unchanged: `syncToggles`
|
||||
still writes `aria-expanded` on every control pointing here, and the panel
|
||||
still gets `lembas:toggle`. */
|
||||
var NARROW = "(max-width: 48rem)";
|
||||
|
||||
function sidebarOpen() {
|
||||
var state = document.documentElement.dataset.sidebar;
|
||||
if (state === "open") return true;
|
||||
if (state === "closed") return false;
|
||||
return !window.matchMedia(NARROW).matches;
|
||||
}
|
||||
|
||||
function setSidebar(open) {
|
||||
var panel = document.querySelector("#sidebar");
|
||||
document.documentElement.dataset.sidebar = open ? "open" : "closed";
|
||||
syncToggles("#sidebar", open);
|
||||
|
||||
/* Nothing behind an open drawer may be reached by the keyboard -- but only
|
||||
while it *is* a drawer. Cleared whenever the query stops matching, and
|
||||
cleared unconditionally when it closes: an `inert` left behind on a
|
||||
window somebody widened is a page that has stopped responding, which is
|
||||
a far worse bug than the one it is here to fix. */
|
||||
var main = document.querySelector(".shell > .main");
|
||||
if (main) main.toggleAttribute("inert", open && window.matchMedia(NARROW).matches);
|
||||
|
||||
if (panel) {
|
||||
panel.dispatchEvent(
|
||||
new CustomEvent("lembas:toggle", { bubbles: true, detail: { open: open } })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setPanel(selector, open, group) {
|
||||
if (selector === "#sidebar") return setSidebar(open);
|
||||
|
||||
var panel = document.querySelector(selector);
|
||||
if (!panel) return;
|
||||
|
||||
@@ -855,6 +908,10 @@
|
||||
var toggle = event.target.closest("[data-toggle]");
|
||||
if (toggle) {
|
||||
event.preventDefault();
|
||||
if (toggle.dataset.toggle === "#sidebar") {
|
||||
setSidebar(!sidebarOpen());
|
||||
return;
|
||||
}
|
||||
var panel = document.querySelector(toggle.dataset.toggle);
|
||||
if (!panel) return;
|
||||
setPanel(toggle.dataset.toggle, panel.hasAttribute("hidden"), toggle.dataset.toggleGroup);
|
||||
@@ -900,12 +957,132 @@
|
||||
applyTheme(currentTheme());
|
||||
setupDropzone();
|
||||
setupResize();
|
||||
|
||||
/* The toggle used to render `aria-expanded="true"` in the template, which
|
||||
is a claim nobody checked and which was false on every phone. The
|
||||
stylesheet decides whether the drawer is showing; this is the one place
|
||||
that can ask it and say so. */
|
||||
syncToggles("#sidebar", sidebarOpen());
|
||||
});
|
||||
|
||||
/* A drawer that is dismissed by tapping beside it should be dismissed by
|
||||
Escape too -- and only while it *is* a drawer, or Escape would collapse the
|
||||
sidebar on a desktop, where nobody asked it to. */
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Escape") return;
|
||||
if (!window.matchMedia(NARROW).matches || !sidebarOpen()) return;
|
||||
if (document.querySelector("dialog[open]")) return;
|
||||
setSidebar(false);
|
||||
});
|
||||
|
||||
/* Widening the window past the breakpoint must not leave `inert` on the page
|
||||
behind a drawer that is no longer a drawer. Recomputed rather than cleared,
|
||||
so narrowing it again while the drawer is open puts the guard back. */
|
||||
window.matchMedia(NARROW).addEventListener("change", function () {
|
||||
var main = document.querySelector(".shell > .main");
|
||||
if (main) {
|
||||
main.toggleAttribute(
|
||||
"inert", sidebarOpen() && window.matchMedia(NARROW).matches
|
||||
);
|
||||
}
|
||||
syncToggles("#sidebar", sidebarOpen());
|
||||
});
|
||||
|
||||
/* Before first paint rather than on DOMContentLoaded, so a panel that was
|
||||
dragged wider does not open at its default and jump. */
|
||||
applyWidths();
|
||||
|
||||
/* --- Saying that something is happening --------------------------------
|
||||
A count, not a flag: several requests overlap constantly here -- the
|
||||
unread poll every ten seconds, the transcript tail, whatever somebody just
|
||||
clicked -- and a flag means the first of them to finish switches the bar
|
||||
off while the others are still running.
|
||||
|
||||
The poll and the tail are excluded. They are the two requests nobody
|
||||
started and nobody is waiting for, and a bar that sweeps every ten seconds
|
||||
on an idle page is not information, it is a tic. */
|
||||
var pending = 0;
|
||||
|
||||
function quiet(event) {
|
||||
var el = event.detail && event.detail.elt;
|
||||
if (!el || !el.getAttribute) return false;
|
||||
var url = (event.detail.pathInfo && event.detail.pathInfo.requestPath) || "";
|
||||
return url.indexOf("/unread") !== -1 || url.indexOf("/tail") !== -1;
|
||||
}
|
||||
|
||||
function showProgress(on) {
|
||||
var bar = document.querySelector("[data-progress]");
|
||||
if (bar) bar.classList.toggle("is-busy", on);
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (event) {
|
||||
if (quiet(event)) return;
|
||||
pending += 1;
|
||||
showProgress(true);
|
||||
});
|
||||
|
||||
["htmx:afterRequest", "htmx:sendError", "htmx:timeout", "htmx:abort"].forEach(
|
||||
function (name) {
|
||||
document.body.addEventListener(name, function (event) {
|
||||
if (quiet(event)) return;
|
||||
pending = Math.max(0, pending - 1);
|
||||
if (!pending) showProgress(false);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/* --- A release that arrived while you were reading ----------------------
|
||||
The worker no longer takes over open pages on its own -- see sw.js -- so
|
||||
something has to say that one is waiting, and the reader decides. A toast
|
||||
rather than a reload: an application with a reply streaming into it must
|
||||
not be navigated out from under somebody. */
|
||||
function watchForUpdate(registration) {
|
||||
function offer(worker) {
|
||||
if (!worker || !navigator.serviceWorker.controller) return;
|
||||
worker.addEventListener("statechange", function () {
|
||||
if (worker.state !== "installed") return;
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
});
|
||||
}
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
}
|
||||
registration.addEventListener("updatefound", function () {
|
||||
offer(registration.installing);
|
||||
});
|
||||
}
|
||||
|
||||
/* The new worker calling skipWaiting() is what fires this, and reloading is
|
||||
the right answer to it -- the page is now being served by a worker whose
|
||||
cache it did not start from.
|
||||
|
||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
||||
`controllerchange` can fire more than once. And `hadController`, because on
|
||||
a *first* visit there is no worker at all: the one that installs then calls
|
||||
`clients.claim()`, which fires this event for the first time -- so without
|
||||
it, the very first page anybody loads reloads itself in front of them for
|
||||
no reason they could possibly work out. */
|
||||
var reloading = false;
|
||||
if ("serviceWorker" in navigator) {
|
||||
var hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||
if (reloading || !hadController) return;
|
||||
reloading = true;
|
||||
window.location.reload();
|
||||
});
|
||||
navigator.serviceWorker.ready.then(watchForUpdate).catch(function () {});
|
||||
}
|
||||
|
||||
/* After any htmx swap: re-measure the composer and follow new content. */
|
||||
document.body.addEventListener("htmx:afterSwap", function () {
|
||||
document.querySelectorAll("[data-autosize]").forEach(autosize);
|
||||
|
||||
@@ -42,8 +42,26 @@ var SHELL = [
|
||||
"/static/img/logo-mark.svg",
|
||||
"/static/img/icon-192.png",
|
||||
"/static/img/icon-512.png",
|
||||
// The two a device reaches for when the network is not there: the maskable
|
||||
// one is what every Android launcher crops, and the Apple one is the home
|
||||
// screen. Both were absent from this list while the two nothing crops were
|
||||
// in it.
|
||||
"/static/img/icon-maskable-512.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) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then(function (cache) {
|
||||
@@ -51,16 +69,44 @@ 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 () {});
|
||||
})
|
||||
);
|
||||
}).then(function () { return self.skipWaiting(); })
|
||||
})
|
||||
);
|
||||
/* Deliberately NOT skipWaiting() here.
|
||||
|
||||
It used to, unconditionally, together with clients.claim() below -- so a
|
||||
release took over every open tab the moment it was installed, while the
|
||||
cache those tabs were reading from was being emptied underneath them. A
|
||||
page could end up drawing itself from two releases at once, and nothing
|
||||
said so.
|
||||
|
||||
The new worker waits instead, the page is told, and the reader decides.
|
||||
`messages/SKIP_WAITING` below is how they say yes. A worker that is never
|
||||
activated costs a few hundred kilobytes and is replaced by the next one. */
|
||||
});
|
||||
|
||||
/* The page asking to be taken over now. The only message this worker answers,
|
||||
and it does exactly one thing, because a message channel into a service
|
||||
worker is a thing any script on the origin can post to. */
|
||||
self.addEventListener("message", function (event) {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (names) {
|
||||
/* Without this, every navigation waits for this worker to start before its
|
||||
request is even made -- which on a cold phone is the difference between
|
||||
a page and a pause. The navigate branch below is a plain fetch, so the
|
||||
preloaded response is used simply by preferring it when it exists. */
|
||||
(self.registration.navigationPreload
|
||||
? self.registration.navigationPreload.enable().catch(function () {})
|
||||
: Promise.resolve()
|
||||
).then(function () {
|
||||
return caches.keys();
|
||||
}).then(function (names) {
|
||||
return Promise.all(
|
||||
names.map(function (name) {
|
||||
if (name !== CACHE && name.indexOf("lembas-") === 0) return caches.delete(name);
|
||||
@@ -97,9 +143,9 @@ self.addEventListener("fetch", function (event) {
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(function () {
|
||||
return caches.match("/offline");
|
||||
})
|
||||
Promise.resolve(event.preloadResponse)
|
||||
.then(function (preloaded) { return preloaded || fetch(request); })
|
||||
.catch(function () { return caches.match("/offline"); })
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -158,13 +204,53 @@ self.addEventListener("push", function (event) {
|
||||
tag: "lembas-" + (payload.kind || "unread"),
|
||||
renotify: true,
|
||||
icon: "/static/img/icon-192.png",
|
||||
badge: "/static/img/icon-192.png",
|
||||
/* A badge is drawn as a *mask* in the status bar -- the device keeps
|
||||
the alpha and throws the colour away. The full-colour 192 is opaque
|
||||
to its edges, so what Android rendered was a solid grey square. The
|
||||
leaf has transparency, so it survives being masked. */
|
||||
badge: "/static/img/badge-72.png",
|
||||
data: { url: payload.url || "/" },
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
A browser may replace a subscription on its own -- a push service expiring a
|
||||
key, a browser upgrade. When it does, the endpoint this server holds stops
|
||||
working and nothing anywhere says so: notifications simply stop. The event
|
||||
fires exactly once, at the moment of the swap, and it is the only chance to
|
||||
hear about it.
|
||||
|
||||
Re-subscribing needs the server's public key, which this worker does not hold,
|
||||
so it asks the same endpoint the page does.
|
||||
*/
|
||||
self.addEventListener("pushsubscriptionchange", function (event) {
|
||||
event.waitUntil(
|
||||
fetch("/api/push/key")
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.key) return null;
|
||||
return self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: Uint8Array.from(
|
||||
atob(data.key.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
function (c) { return c.charCodeAt(0); }
|
||||
),
|
||||
});
|
||||
})
|
||||
.then(function (subscription) {
|
||||
if (!subscription) return null;
|
||||
return fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
})
|
||||
.catch(function () { /* Nothing here can ask a person for help. */ })
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
Clicking one.
|
||||
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
var toast = el("div", "toast toast--" + (options.kind || "info"));
|
||||
toast.appendChild(el("span", "toast__text", message));
|
||||
|
||||
/* Some news is worth acting on where it is read: "a new version is ready"
|
||||
with no way to take it is a sentence that sends somebody looking for a
|
||||
menu. One action, never two -- a toast is not a dialog, and anything
|
||||
needing a choice should be one. */
|
||||
if (options.action && options.action.label) {
|
||||
var act = el("button", "btn btn--sm toast__action", options.action.label);
|
||||
act.type = "button";
|
||||
act.addEventListener("click", function () {
|
||||
dismiss(toast);
|
||||
if (options.action.run) options.action.run();
|
||||
});
|
||||
toast.appendChild(act);
|
||||
}
|
||||
|
||||
var close = el("button", "toast__close");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Dismiss");
|
||||
@@ -58,7 +72,11 @@
|
||||
// Next frame, so the entry transition has a state to move from.
|
||||
requestAnimationFrame(function () { toast.classList.add("is-in"); });
|
||||
|
||||
var timeout = options.timeout == null ? TOAST_MS : options.timeout;
|
||||
/* A toast offering an action must not take it away while it is being read.
|
||||
Anything with a button stays until it is answered or dismissed. */
|
||||
var timeout = options.timeout == null
|
||||
? (options.action ? 0 : TOAST_MS)
|
||||
: options.timeout;
|
||||
if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout);
|
||||
return toast;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<input class="input input--mono" id="unload-{{ connection.id }}" name="unload_url"
|
||||
value="{{ connection.unload_url }}" placeholder="No unload call"
|
||||
style="flex: 1; min-width: 0">
|
||||
<select class="select" name="unload_method" style="flex: none">
|
||||
<select class="select" name="unload_method" aria-label="How to ask it to unload" style="flex: none">
|
||||
<option value="POST" {{ 'selected' if connection.unload_method != 'GET' }}>POST</option>
|
||||
<option value="GET" {{ 'selected' if connection.unload_method == 'GET' }}>GET</option>
|
||||
</select>
|
||||
@@ -91,6 +91,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="headers-{{ connection.id }}">Extra headers</label>
|
||||
<textarea class="textarea input--mono" id="headers-{{ connection.id }}"
|
||||
name="extra_headers" rows="2"
|
||||
placeholder="HTTP-Referer: https://example.org">{% for name, value in (connection.extra_headers_json or {}).items() %}{{ name }}: {{ value }}
|
||||
{% endfor %}</textarea>
|
||||
<p class="field__hint">
|
||||
One <code>Name: value</code> per line, sent with every request to this
|
||||
endpoint. OpenRouter reads <code>HTTP-Referer</code> and
|
||||
<code>X-Title</code> and attributes your usage with them. Leave it empty
|
||||
unless an endpoint has asked for something.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
#}
|
||||
<form method="post" action="/admin/prompts" id="prompt-form">
|
||||
<div class="tabs">
|
||||
<div class="tabs__bar" role="tablist">
|
||||
<div class="tabs__bar" role="radiogroup" aria-label="Prompt groups">
|
||||
{% for key, label, fragments in groups %}
|
||||
<input class="visually-hidden" type="radio" name="prompts-tab"
|
||||
id="tab-{{ key }}" {{ 'checked' if loop.first }}>
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
|
||||
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
|
||||
data-confirm-title="Delete suggestion">
|
||||
data-confirm-title="Delete suggestion"
|
||||
aria-label="Delete suggestion" title="Delete suggestion">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Connections{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
@@ -13,7 +13,15 @@
|
||||
data-themes="{{ brand.theme_list }}"{% if layout %} style="{{ layout }}"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{#
|
||||
`viewport-fit=cover` is what lets `env(safe-area-inset-*)` resolve to
|
||||
anything but zero, and without it the `black-translucent` status bar style
|
||||
below is a promise with nothing behind it: iOS puts the page under the clock
|
||||
and the notch and the tokens that would have paid for it stay at 0.
|
||||
No `maximum-scale` and no `user-scalable=no` -- pinch-zoom is somebody's
|
||||
accessibility setting, not a layout problem to be suppressed.
|
||||
#}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{% block title %}{{ brand.name }}{% endblock %}</title>
|
||||
<meta name="description" content="{{ brand.tagline or brand.name ~ ' — a web UI for your language models.' }}">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
@@ -26,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 %}
|
||||
|
||||
{#
|
||||
@@ -36,19 +44,28 @@
|
||||
only what the browser paints with before the stylesheet has resolved.
|
||||
#}
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#101317">
|
||||
{#
|
||||
Two, scoped by preference, so the browser has an answer before any of our CSS
|
||||
or JavaScript has run. There used to be one and it was Moria's near-black, so
|
||||
every reader of the light theme got a dark browser chrome on every page load
|
||||
until `app.js` -- which is deferred -- corrected it. `applyTheme` still has
|
||||
the last word, and still reads the value from `--bg` rather than repeating a
|
||||
hex here; these two are only what is painted before it can.
|
||||
#}
|
||||
<meta name="theme-color" content="#101317" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#F6F1E4" media="(prefers-color-scheme: light)">
|
||||
{% 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
|
||||
@@ -92,18 +109,35 @@
|
||||
<body{% block body_attrs %}{% endblock %}>
|
||||
{% include "partials/icons.html" %}
|
||||
|
||||
{#
|
||||
Every fetch this application makes, said out loud.
|
||||
|
||||
htmx has had `htmx-request` on the triggering element since the beginning and
|
||||
nothing here has ever used it, so a click that saved a setting, opened a
|
||||
panel or loaded a page of a list looked exactly like a click that did nothing
|
||||
until the answer arrived. On a local endpoint that is a few milliseconds and
|
||||
on anything else it is long enough to click again.
|
||||
|
||||
One bar for the whole page rather than a spinner per control: the interesting
|
||||
question is "is the application busy", and an indicator on the control would
|
||||
need adding to every control ever written, which is how the last one came to
|
||||
be used nowhere. `aria-hidden` because the answer arriving is the thing worth
|
||||
announcing, and htmx already moves focus for that.
|
||||
#}
|
||||
<div class="progress" data-progress aria-hidden="true"><span></span></div>
|
||||
|
||||
{% 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
|
||||
|
||||
@@ -25,16 +25,12 @@
|
||||
</p>
|
||||
<div class="compacted__body">
|
||||
{% for message in compacted %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||
aria-expanded="true" data-toggle="#sidebar">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
|
||||
<h1 class="topbar__title">
|
||||
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
|
||||
@@ -396,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 %}
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">
|
||||
{{ icon("folder", "icon--sm") }}
|
||||
<span>{{ folder.name }}</span>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Library{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
@@ -21,8 +21,16 @@
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
{# The shape of what is coming, rather than an ellipsis that says only that
|
||||
something is missing. `aria-hidden` and a `role="status"` label beside it,
|
||||
because a paragraph of grey blocks is nothing to read aloud. #}
|
||||
<section class="card">
|
||||
<h2 class="card__title">Shared with <span class="badge">…</span></h2>
|
||||
<h2 class="card__title">Shared with</h2>
|
||||
<span class="visually-hidden" role="status">Loading who this is shared with</span>
|
||||
<div aria-hidden="true">
|
||||
<div class="skeleton skeleton--row"></div>
|
||||
<div class="skeleton skeleton--row skeleton--short"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% elif not is_owner %}
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:drop">
|
||||
<span class="text-xs faint">Loading earlier messages…</span>
|
||||
<span class="visually-hidden" role="status">Loading earlier messages</span>
|
||||
<div class="history-sentinel__shape" aria-hidden="true">
|
||||
<div class="skeleton skeleton--line"></div>
|
||||
<div class="skeleton skeleton--line skeleton--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{{ icon("chat", "icon--sm") }} Messages</h1>
|
||||
<div class="topbar__actions">
|
||||
{% if schedules %}
|
||||
@@ -78,14 +79,16 @@
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-sync="this:drop">
|
||||
<span class="text-xs faint">Loading earlier messages…</span>
|
||||
<span class="visually-hidden" role="status">Loading earlier messages</span>
|
||||
<div class="history-sentinel__shape" aria-hidden="true">
|
||||
<div class="skeleton skeleton--line"></div>
|
||||
<div class="skeleton skeleton--line skeleton--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,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 %}
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
aria-label="Rename chat" title="Rename chat">
|
||||
{{ icon("pencil", "icon--sm") }}
|
||||
</button>
|
||||
{#
|
||||
Archiving, and un-archiving, are the same control reading the opposite
|
||||
way round -- so one button, and the sidebar re-renders because a row has
|
||||
to leave one group and appear in the other.
|
||||
#}
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-patch="/api/chats/{{ chat_item.id }}"
|
||||
hx-vals='{"archived": "{{ 0 if chat_item.archived else 1 }}"}'
|
||||
hx-target="#sidebar-tree" hx-swap="outerHTML"
|
||||
aria-label="{{ 'Restore chat' if chat_item.archived else 'Archive chat' }}"
|
||||
title="{{ 'Put this chat back in the list' if chat_item.archived
|
||||
else 'Hide this chat without deleting it' }}">
|
||||
{{ icon("arrow-up" if chat_item.archived else "archive", "icon--sm") }}
|
||||
</button>
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The control that opens and closes the sidebar.
|
||||
|
||||
A partial rather than markup in each topbar, because for most of this
|
||||
application's life it existed on `/chat` alone -- and below the phone
|
||||
breakpoint the sidebar is a fixed overlay, so every other page rendered 280px
|
||||
of opaque drawer over itself with nothing anywhere to dismiss it. `/settings`
|
||||
was one of them, which is where the Install and Notifications buttons live.
|
||||
|
||||
`aria-expanded` is deliberately absent rather than `"true"`: it used to be
|
||||
hard-coded open, which is a lie the moment anything closes the drawer, and
|
||||
`app.js:syncToggles` writes the honest value on load and on every change.
|
||||
#}
|
||||
<button class="btn btn--icon sidebar-toggle" type="button"
|
||||
aria-label="Toggle sidebar" aria-controls="sidebar"
|
||||
data-toggle="#sidebar">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
@@ -87,4 +87,26 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{#
|
||||
Archived chats, closed, and absent entirely when there are none.
|
||||
|
||||
A `<details>` rather than a page of their own: archiving is for getting a
|
||||
conversation out of the way, not for filing it somewhere, and a second
|
||||
screen to visit would make putting one back a journey. Closed by default
|
||||
because that is the whole point, and the browser keeps the open state
|
||||
across the out-of-band swaps the unread poll makes -- the same property the
|
||||
folder tree relies on.
|
||||
#}
|
||||
{% if archived_chats %}
|
||||
<details class="nav-group nav-group--archived">
|
||||
<summary class="nav-group__label">
|
||||
{{ icon("archive", "icon--sm") }} Archived
|
||||
<span class="nav-group__count">{{ archived_chats|length }}</span>
|
||||
</summary>
|
||||
{% for chat_item in archived_chats %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,48 @@
|
||||
nothing behind.
|
||||
#}
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar__header">
|
||||
{{ brandlink(uid="side") }}
|
||||
</div>
|
||||
{#
|
||||
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.
|
||||
|
||||
Below the phone breakpoint this whole element is a fixed overlay, and the
|
||||
toggle that opens it lives in the topbar underneath -- so once it was
|
||||
open, the control for closing it was behind it. That was true on /chat,
|
||||
where at least a toggle existed; on the seven other pages that carry this
|
||||
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. 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`.
|
||||
#}
|
||||
<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" %}
|
||||
|
||||
@@ -47,6 +86,26 @@
|
||||
</a>
|
||||
|
||||
<div class="sidebar__tools">
|
||||
{#
|
||||
Installing.
|
||||
|
||||
The only one of these was in the Appearance tab of /settings, which on
|
||||
a phone is behind a drawer that used to be impossible to close and a tab
|
||||
strip that gave no sign of scrolling -- so the button for installing
|
||||
this on a phone was, on a phone, three taps into a place you could not
|
||||
get to. It stays there as well; this is simply where somebody will meet
|
||||
it.
|
||||
|
||||
Hidden until the browser says the app is installable: `app.js` reveals
|
||||
every `[data-install-app]` when `beforeinstallprompt` fires, and hides
|
||||
them again once it is installed. Firefox and desktop Safari never fire
|
||||
it, and an Install button that does nothing is worse than none.
|
||||
#}
|
||||
<button class="btn btn--icon" type="button" data-install-app hidden
|
||||
onclick="window.lembas.promptInstall()"
|
||||
aria-label="Install as an app" title="Install as an app">
|
||||
{{ icon("arrow-down") }}
|
||||
</button>
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||
{{ icon("shield") }}
|
||||
@@ -66,3 +125,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#
|
||||
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 %}
|
||||
<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 %}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Reports{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Scheduled{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">Your settings</h1>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +26,18 @@
|
||||
state. Each panel is a real fragment of the page, not a fetch.
|
||||
#}
|
||||
<div class="tabs">
|
||||
<div class="tabs__bar" role="tablist">
|
||||
{#
|
||||
Deliberately no `role="tablist"`.
|
||||
|
||||
It had one, and the children are `<input type="radio">` and `<label>` -- so a
|
||||
screen reader announced a tablist containing no tabs, and the panels carried
|
||||
neither `role="tabpanel"` nor an `aria-labelledby` to be announced as. What
|
||||
this actually is, is a radio group, and a perfectly good one: arrow keys move
|
||||
between the options, the checked one is announced, and the CSS that reveals
|
||||
the matching panel keys off exactly that. Being an honest radio group beats
|
||||
claiming to be a tab interface and then not behaving as one.
|
||||
#}
|
||||
<div class="tabs__bar" role="radiogroup" aria-label="Settings sections">
|
||||
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-account" checked>
|
||||
<label class="tabs__tab" for="tab-account">{{ icon("user", "icon--sm") }} Account</label>
|
||||
|
||||
@@ -196,7 +208,7 @@
|
||||
redirect back is what stops a refresh re-submitting it.
|
||||
#}
|
||||
<form method="post" action="/api/preferences/timezone" class="btn-row">
|
||||
<select class="select" name="timezone" style="flex: 1">
|
||||
<select class="select" name="timezone" style="flex: 1" aria-label="Your timezone">
|
||||
<option value="" {{ 'selected' if not timezone }}>
|
||||
Follow the server ({{ server_timezone }})
|
||||
</option>
|
||||
@@ -367,12 +379,14 @@
|
||||
<form method="post" action="/api/library/memories/{{ memory.id }}"
|
||||
class="row" style="flex: 1; gap: var(--sp-2); min-width: 0">
|
||||
<input class="input" name="content" value="{{ memory.content }}"
|
||||
maxlength="{{ memory_limit }}" style="flex: 1">
|
||||
maxlength="{{ memory_limit }}" style="flex: 1"
|
||||
aria-label="What this memory says">
|
||||
<button class="btn btn--sm" type="submit">Save</button>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/api/library/memories/{{ memory.id }}/delete"
|
||||
data-confirm-button="Forget this?"
|
||||
data-confirm-title="Forget">
|
||||
data-confirm-title="Forget"
|
||||
aria-label="Forget this" title="Forget this">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -396,6 +410,7 @@
|
||||
style="gap: var(--sp-2)">
|
||||
<input class="input" name="content" required style="flex: 1"
|
||||
maxlength="{{ memory_limit }}"
|
||||
aria-label="Something worth remembering"
|
||||
placeholder="Prefers metric units and a 24-hour clock.">
|
||||
<button class="btn btn--primary" type="submit">Remember</button>
|
||||
</form>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -22,14 +22,19 @@ from lembas.services.agent.base import ExecRequest, ExecResult, clean_output
|
||||
from lembas.services.agent.session import AgentContext
|
||||
from lembas.services.agent.tools import _run_shell
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("setsid") is None or shutil.which("base64") is None,
|
||||
reason="needs setsid and base64 (Linux)",
|
||||
)
|
||||
|
||||
|
||||
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
||||
pytestmark = pytest.mark.slow
|
||||
# A list, because there are two of them and `pytestmark = ...` twice is not two
|
||||
# marks -- the second binding replaces the first, silently. It did, for the
|
||||
# whole life of this file: the guard below was written, read as present, and
|
||||
# never once applied, so a host without `setsid` got a module that errored
|
||||
# instead of the skip somebody had taken the trouble to write.
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
shutil.which("setsid") is None or shutil.which("base64") is None,
|
||||
reason="needs setsid and base64 (Linux)",
|
||||
),
|
||||
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
||||
pytest.mark.slow,
|
||||
]
|
||||
|
||||
class LocalExecutor:
|
||||
"""`SshExecutor.run`'s contract, run against the local shell.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Archiving a chat, which the column has been filtered on and never written.
|
||||
|
||||
`Chat.archived` is read in four places, always `is_(False)`, and was set to True
|
||||
by nothing anywhere in `src/` -- so the hiding shipped and the archiving did
|
||||
not, and the column read as a built feature to anybody who grepped for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from lembas.db.models import Chat
|
||||
|
||||
|
||||
def test_a_chat_can_be_archived(client: TestClient, db, registered, make_chat):
|
||||
chat_id = make_chat()
|
||||
response = client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
|
||||
assert response.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat_id).archived is True
|
||||
|
||||
|
||||
def test_archiving_answers_with_a_sidebar_the_browser_can_swap_in(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""`update_chat` answers 204 for everything else, and htmx's own config is
|
||||
`{code: "204", swap: false}` -- so a button aimed at `#sidebar-tree` set the
|
||||
column and then did visibly nothing until the next page load.
|
||||
|
||||
Asserted on the *response*, because the obvious test -- archive, then load
|
||||
the page, then look -- passes against both versions. It is the control doing
|
||||
nothing that has to be caught, not the column failing to change.
|
||||
"""
|
||||
chat_id = make_chat()
|
||||
response = client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'id="sidebar-tree"' in response.text
|
||||
assert "nav-group--archived" in response.text
|
||||
assert chat_id in response.text
|
||||
|
||||
|
||||
def test_a_patch_that_changes_nothing_still_answers_204(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""Re-rendering the sidebar for every PATCH would put the tree in the reply
|
||||
to a rename, a folder move and a model change as well -- none of which
|
||||
asked for it, and one of which already answers with its own fragment."""
|
||||
chat_id = make_chat()
|
||||
assert client.patch(
|
||||
f"/api/chats/{chat_id}", data={"archived": "0"}
|
||||
).status_code == 204
|
||||
assert client.patch(
|
||||
f"/api/chats/{chat_id}", data={"model_id": ""}
|
||||
).status_code == 204
|
||||
|
||||
|
||||
def test_it_can_be_put_back(client: TestClient, db, registered, make_chat):
|
||||
"""An archive with no way out is a delete that lies about itself."""
|
||||
chat_id = make_chat()
|
||||
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
|
||||
client.patch(f"/api/chats/{chat_id}", data={"archived": "0"})
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat_id).archived is False
|
||||
|
||||
|
||||
def test_leaving_the_field_out_leaves_it_alone(client: TestClient, db, registered, make_chat):
|
||||
"""`update_chat` reads the raw form precisely so that absent and empty are
|
||||
different things, and every other field there honours it."""
|
||||
chat_id = make_chat()
|
||||
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
|
||||
client.patch(f"/api/chats/{chat_id}", data={"title": "Still here"})
|
||||
db.expire_all()
|
||||
chat = db.get(Chat, chat_id)
|
||||
assert chat.archived is True
|
||||
assert chat.title == "Still here"
|
||||
|
||||
|
||||
def test_an_archived_chat_leaves_the_list_and_joins_the_other_one(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
chat_id = make_chat()
|
||||
page = client.get("/chat").text
|
||||
assert chat_id in page
|
||||
|
||||
client.patch(f"/api/chats/{chat_id}", data={"archived": "1"})
|
||||
page = client.get("/chat").text
|
||||
# Still reachable -- in the Archived group, which is the whole difference
|
||||
# between archiving and deleting.
|
||||
assert "nav-group--archived" in page
|
||||
assert chat_id in page
|
||||
|
||||
|
||||
def test_the_group_is_absent_when_nothing_is_in_it(client: TestClient, registered, make_chat):
|
||||
make_chat()
|
||||
assert "nav-group--archived" not in client.get("/chat").text
|
||||
|
||||
|
||||
def test_nobody_else_may_archive_your_chat(client: TestClient, db, registered, make_chat):
|
||||
"""`_owned_chat` is what stops it, and this is the test that says so."""
|
||||
chat_id = make_chat()
|
||||
client.cookies.clear()
|
||||
# `follow_redirects=False`, or the redirect to the sign-in page is followed
|
||||
# and the 200 that comes back reads as the request having succeeded.
|
||||
response = client.patch(
|
||||
f"/api/chats/{chat_id}", data={"archived": "1"}, follow_redirects=False
|
||||
)
|
||||
assert response.status_code in (401, 403, 404, 303, 307)
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat_id).archived is False
|
||||
+153
-7
@@ -446,7 +446,15 @@ def test_an_archived_chat_inside_a_folder_is_not_listed(
|
||||
db.commit()
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert "Mount Doom" not in page
|
||||
|
||||
# The original guarantee, and now a narrower assertion than "nowhere on the
|
||||
# page": archiving puts a chat in the Archived group, so it IS on the page
|
||||
# -- being able to find it again is the difference between archiving it and
|
||||
# deleting it. What must not happen is it still showing inside its folder,
|
||||
# which is the bug this test was written for.
|
||||
before_archived = page.split('nav-group--archived', 1)[0]
|
||||
assert "Mount Doom" not in before_archived
|
||||
assert "Mount Doom" in page
|
||||
# And the folder must say so, rather than claiming to hold something.
|
||||
assert "Empty" in page
|
||||
|
||||
@@ -959,16 +967,89 @@ def test_the_agent_controls_shrink_rather_than_pushing_send_off_the_row(
|
||||
assert opens < html.index(control) < actions, control
|
||||
|
||||
|
||||
def test_the_chat_stylesheet_has_no_media_queries(client: TestClient):
|
||||
"""A stated design constraint, pinned so nobody 'fixes' a layout with a
|
||||
breakpoint later. The composer fits at every width by saying which child
|
||||
gives, not by rearranging itself at a threshold."""
|
||||
def _chat_css() -> str:
|
||||
from pathlib import Path
|
||||
|
||||
import lembas
|
||||
|
||||
css = Path(lembas.__file__).parent / "web/static/css/chat.css"
|
||||
assert "@media" not in css.read_text()
|
||||
return (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text()
|
||||
|
||||
|
||||
def test_the_composer_toolbar_can_never_wrap(client: TestClient):
|
||||
"""This is what the old blanket ban on `@media` in this file was protecting.
|
||||
|
||||
The toolbar used to wrap, and `.composer__actions` is last in the DOM with
|
||||
`margin-left: auto` -- so the moment an agent chat added a connection, a
|
||||
directory and a mode to the row, Send and the microphone were what dropped
|
||||
to a second line. The fix was to say which child gives, not to rearrange the
|
||||
row at a threshold, and the test that pinned it refused every media query in
|
||||
the file so that nobody would "fix" a regression with a breakpoint instead.
|
||||
|
||||
The ban outlived its usefulness: a phone needs bigger targets and different
|
||||
spacing, and refusing all width- and pointer-awareness here made the file
|
||||
unable to say so. What it was *actually* protecting is asserted directly
|
||||
now, which is both narrower and stronger -- the old test would have passed a
|
||||
version of this file that wrapped the toolbar without a media query.
|
||||
"""
|
||||
css = _chat_css()
|
||||
toolbar = css.split(".composer__toolbar {", 1)[1].split("}", 1)[0]
|
||||
assert "flex-wrap: nowrap" in toolbar
|
||||
|
||||
actions = css.split(".composer__actions {", 1)[1].split("}", 1)[0]
|
||||
assert "flex: none" in actions
|
||||
assert "flex-wrap" not in actions
|
||||
|
||||
# The one child allowed to give, and the reason the rest never have to.
|
||||
context = css.split(".composer__context {", 1)[1].split("}", 1)[0]
|
||||
assert "min-width: 0" in context
|
||||
assert "overflow-x: auto" in context
|
||||
|
||||
|
||||
def _media_blocks(css: str) -> list[str]:
|
||||
"""Each `@media` block's own contents, by balancing braces.
|
||||
|
||||
Splitting on "@media" and taking what follows gives everything to the end of
|
||||
the file, so a test written that way asserts about the whole stylesheet
|
||||
while appearing to be about one block -- and fails on a rule three hundred
|
||||
lines below the query.
|
||||
"""
|
||||
blocks = []
|
||||
for start in (i for i in range(len(css)) if css.startswith("@media", i)):
|
||||
opened = css.index("{", start)
|
||||
depth, cursor = 0, opened
|
||||
while cursor < len(css):
|
||||
if css[cursor] == "{":
|
||||
depth += 1
|
||||
elif css[cursor] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
cursor += 1
|
||||
blocks.append(css[opened + 1 : cursor])
|
||||
return blocks
|
||||
|
||||
|
||||
def test_no_breakpoint_may_undo_the_toolbar_rule(client: TestClient):
|
||||
"""A media query in this file is allowed; one that lets the toolbar wrap or
|
||||
lets the actions shrink is the original bug with a threshold in front of
|
||||
it."""
|
||||
for body in _media_blocks(_chat_css()):
|
||||
assert "flex-wrap: wrap" not in body
|
||||
assert ".composer__actions" not in body or "flex: none" in body
|
||||
|
||||
|
||||
def test_width_awareness_in_this_file_is_deliberate(client: TestClient):
|
||||
"""Every media query here carries a comment immediately above it.
|
||||
|
||||
The replacement for "none allowed": a breakpoint in this file has to say why
|
||||
it exists, because the failure this file is shaped around is somebody
|
||||
reaching for one instead of fixing the sizing.
|
||||
"""
|
||||
css = _chat_css()
|
||||
for index, line in enumerate(css.splitlines()):
|
||||
if line.strip().startswith("@media"):
|
||||
above = "\n".join(css.splitlines()[max(0, index - 12):index])
|
||||
assert "*" in above, f"undocumented @media at line {index + 1}"
|
||||
|
||||
|
||||
def _user_id(db):
|
||||
@@ -1167,3 +1248,68 @@ def test_the_think_frame_lands_beside_the_reasoning_body_not_around_it():
|
||||
# Neither element may open a tag that the other closes: siblings, not nested.
|
||||
assert "</span>" in between or "</div>" in between
|
||||
assert between.count("<div") <= 1
|
||||
|
||||
|
||||
# --- Who a finished reply says it came from ----------------------------------
|
||||
def test_a_finished_bubble_names_the_model_that_wrote_it(db, client, registered, make_chat):
|
||||
"""The `done` frame and the tail route render the bubble from scratch, and
|
||||
both looked the models up as *nobody* -- which `models_visible_to` answers
|
||||
with an empty list, not with everything. So a reply was attributed correctly
|
||||
for as long as it was streaming and lost its avatar and its author line at
|
||||
the instant it finished, then corrected itself on the next page load.
|
||||
|
||||
Asserted on the rendered HTML rather than on the argument: passing `owner`
|
||||
is what the old code looked like it was doing, and an assertion on the call
|
||||
would have been green throughout.
|
||||
"""
|
||||
from lembas.api import chats as chats_api
|
||||
from lembas.db.models import Chat, Connection, Message, Model, User
|
||||
|
||||
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="mithril-7b", display_name="Mithril 7B"))
|
||||
db.commit()
|
||||
|
||||
chat_id = make_chat(model_id="mithril-7b")
|
||||
chat = db.get(Chat, chat_id)
|
||||
owner = db.get(User, chat.user_id)
|
||||
message = Message(
|
||||
chat_id=chat.id, role="assistant", content="Spoken.",
|
||||
complete=True, model_id="mithril-7b",
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
|
||||
html = chats_api._render_bubble(db, chat, owner, message)
|
||||
assert "Mithril 7B" in html
|
||||
|
||||
|
||||
def test_the_reply_limit_covers_every_way_of_starting_one(db):
|
||||
"""It was enforced in `_send` alone, so an account at its ceiling reached it
|
||||
by sending into an existing chat and walked past it by pressing New chat --
|
||||
and by editing, by sending a queued message, and by regenerating.
|
||||
|
||||
Reading the source is the honest test here: driving four routes to the point
|
||||
of refusal needs four live generations, which is a fixture that would tell
|
||||
you more about the fixture than about the guard.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from lembas.api import chats as chats_api
|
||||
|
||||
source = inspect.getsource(chats_api)
|
||||
for route in ("start_chat", "edit_message", "send_queued_now", "regenerate", "_send"):
|
||||
body = source.split(f"def {route}(", 1)[1].split("\n@router", 1)[0]
|
||||
assert "_refuse_extra_reply" in body, route
|
||||
|
||||
|
||||
def test_a_new_chat_is_not_written_before_the_limit_is_checked(db):
|
||||
"""A refusal that has already created the row leaves an empty chat in the
|
||||
sidebar as the visible result of being told no."""
|
||||
import inspect
|
||||
|
||||
from lembas.api import chats as chats_api
|
||||
|
||||
body = inspect.getsource(chats_api.start_chat)
|
||||
assert body.index("_refuse_extra_reply") < body.index("_new_chat(")
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Extra headers on a connection: read on every request, written by no form.
|
||||
|
||||
`Connection.extra_headers_json` has been sent with every request to an endpoint
|
||||
since it was added and there was nowhere to set it, so its one documented use --
|
||||
OpenRouter reads `HTTP-Referer` and `X-Title` and attributes usage with them --
|
||||
was unreachable. Nothing advertised it, so nothing was untrue; it was simply a
|
||||
column that could only ever be empty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from lembas.db.models import Connection
|
||||
|
||||
|
||||
def _connection(client: TestClient, db):
|
||||
client.post(
|
||||
"/admin/connections",
|
||||
data={"name": "OpenRouter", "base_url": "http://127.0.0.1:1", "api_key": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
return db.scalars(select(Connection)).first().id
|
||||
|
||||
|
||||
def _save(client: TestClient, connection_id: str, headers: str):
|
||||
return client.post(
|
||||
f"/admin/connections/{connection_id}",
|
||||
data={
|
||||
"name": "OpenRouter",
|
||||
"base_url": "http://127.0.0.1:1",
|
||||
"api_key": "",
|
||||
"enabled": "on",
|
||||
"unload_url": "",
|
||||
"unload_method": "POST",
|
||||
"extra_headers": headers,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_headers_are_stored_as_a_dict(client: TestClient, db, registered):
|
||||
"""Asserted on the row, not on the form: a field that renders and is never
|
||||
read looks exactly like one that works."""
|
||||
cid = _connection(client, db)
|
||||
_save(client, cid, "HTTP-Referer: https://example.org\nX-Title: LLeMbas")
|
||||
|
||||
db.expire_all()
|
||||
stored = db.get(Connection, cid).extra_headers_json
|
||||
assert stored == {
|
||||
"HTTP-Referer": "https://example.org",
|
||||
"X-Title": "LLeMbas",
|
||||
}
|
||||
|
||||
|
||||
def test_they_reach_the_endpoint(client: TestClient, db, registered):
|
||||
"""The whole point. `openai_client` passes them to httpx verbatim."""
|
||||
cid = _connection(client, db)
|
||||
_save(client, cid, "X-Title: LLeMbas")
|
||||
db.expire_all()
|
||||
|
||||
from lembas.services.llm.openai_client import Endpoint
|
||||
|
||||
endpoint = Endpoint.from_connection(db.get(Connection, cid))
|
||||
assert endpoint.extra_headers["X-Title"] == "LLeMbas"
|
||||
|
||||
|
||||
def test_clearing_the_box_clears_them(client: TestClient, db, registered):
|
||||
cid = _connection(client, db)
|
||||
_save(client, cid, "X-Title: LLeMbas")
|
||||
_save(client, cid, "")
|
||||
db.expire_all()
|
||||
assert db.get(Connection, cid).extra_headers_json == {}
|
||||
|
||||
|
||||
def test_a_name_cannot_smuggle_in_a_second_header(client: TestClient, db, registered):
|
||||
"""One field must write one header. A colon or a newline in a *name* is how
|
||||
one becomes two, and a header nobody can see the effect of is worse than one
|
||||
that is visibly missing -- so a bad line is dropped, never repaired."""
|
||||
cid = _connection(client, db)
|
||||
_save(client, cid, "Bad Name: x\nX-Ok: y\n: nothing\nAlso-Bad\n")
|
||||
db.expire_all()
|
||||
assert db.get(Connection, cid).extra_headers_json == {"X-Ok": "y"}
|
||||
@@ -72,3 +72,50 @@ def test_the_canvas_starts_wider_than_the_terminal():
|
||||
for name in PANELS
|
||||
}
|
||||
assert widths["--canvas-width"] > widths["--terminal-width"]
|
||||
|
||||
|
||||
# --- Breakpoints -------------------------------------------------------------
|
||||
# A media query cannot read a custom property, so the three widths this
|
||||
# application breaks at are literals in three stylesheets with nothing tying
|
||||
# them to the tokens that name them. Which is fine until somebody adds a fourth
|
||||
# in passing, and then there are four breakpoints and a comment describing
|
||||
# three.
|
||||
def _breakpoints_used() -> set[str]:
|
||||
"""Widths that appear in an `@media` condition, and nowhere else.
|
||||
|
||||
Scoped to the condition on purpose: `max-width` is also an ordinary
|
||||
declaration -- `.composer__dir` is capped at 11rem, `.picker__menu` at
|
||||
14rem -- and a pattern that reads every one of them calls two dozen
|
||||
component caps "breakpoints" and fails on all of them.
|
||||
"""
|
||||
import re
|
||||
|
||||
used: set[str] = set()
|
||||
for name in ("app.css", "chat.css", "admin.css"):
|
||||
text = (ROOT / "web/static/css" / name).read_text(encoding="utf-8")
|
||||
for condition in re.findall(r"@media([^{]*)\{", text):
|
||||
used.update(re.findall(r"max-width:\s*([\d.]+rem)", condition))
|
||||
return used
|
||||
|
||||
|
||||
def test_every_breakpoint_is_one_of_the_declared_ones():
|
||||
import re
|
||||
|
||||
declared = set(re.findall(r"--bp-[a-z]+:\s*([\d.]+rem)", TOKENS))
|
||||
assert declared, "no --bp-* tokens declared"
|
||||
|
||||
used = _breakpoints_used()
|
||||
assert used <= declared, (
|
||||
f"breakpoints used but not declared in tokens.css: {sorted(used - declared)}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_breakpoint_is_declared_and_never_used():
|
||||
"""The other direction: a token naming a width nothing breaks at is the
|
||||
same clutter as a colour nothing paints with."""
|
||||
import re
|
||||
|
||||
declared = set(re.findall(r"--bp-[a-z]+:\s*([\d.]+rem)", TOKENS))
|
||||
assert declared <= _breakpoints_used(), (
|
||||
f"declared and unused: {sorted(declared - _breakpoints_used())}"
|
||||
)
|
||||
|
||||
@@ -165,3 +165,149 @@ def test_the_mic_appears_only_when_dictation_is_configured(
|
||||
key=settings_store.AUDIO,
|
||||
)
|
||||
assert "data-mic" in client.get("/chat").text
|
||||
|
||||
|
||||
# --- The manifest, beyond the installability minimum -------------------------
|
||||
def test_the_manifest_offers_launcher_shortcuts(client: TestClient):
|
||||
"""A long-press on the launcher icon should reach the three places worth
|
||||
going to directly. Absent, it offers nothing."""
|
||||
payload = client.get("/manifest.webmanifest").json()
|
||||
urls = {s["url"] for s in payload["shortcuts"]}
|
||||
assert urls == {"/chat", "/messages", "/scheduled"}
|
||||
|
||||
|
||||
def test_the_manifest_identity_matches_where_it_starts(client: TestClient):
|
||||
"""`id` was "/", which serves nothing but a redirect, while the app started
|
||||
at /chat. Legal, and it reads as a mistake to anyone comparing the two."""
|
||||
payload = client.get("/manifest.webmanifest").json()
|
||||
assert payload["id"] == payload["start_url"]
|
||||
|
||||
|
||||
def test_the_manifest_declares_the_rest_of_the_quality_set(client: TestClient):
|
||||
payload = client.get("/manifest.webmanifest").json()
|
||||
for key in ("orientation", "categories", "lang", "dir",
|
||||
"display_override", "launch_handler"):
|
||||
assert key in payload, key
|
||||
|
||||
|
||||
def test_the_splash_follows_the_instance_theme(client: TestClient, monkeypatch):
|
||||
"""It was Moria's near-black whatever the instance was set up in, so a
|
||||
parchment instance installed to a phone flashed dark and opened light --
|
||||
and `THEME_COLOUR["shire"]` sat beside it, defined and read by nothing."""
|
||||
from lembas.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "default_theme", "shire")
|
||||
payload = client.get("/manifest.webmanifest").json()
|
||||
assert payload["theme_color"] == "#F6F1E4"
|
||||
assert payload["background_color"] == payload["theme_color"]
|
||||
|
||||
|
||||
def test_the_page_paints_the_right_chrome_before_any_script_runs(client, registered):
|
||||
"""One unscoped `theme-color` meant a light-theme reader got dark browser
|
||||
chrome on every load until the deferred script corrected it."""
|
||||
page = client.get("/chat").text
|
||||
assert 'media="(prefers-color-scheme: dark)"' in page
|
||||
assert 'media="(prefers-color-scheme: light)"' in page
|
||||
|
||||
|
||||
# --- The worker --------------------------------------------------------------
|
||||
def test_the_worker_does_not_take_over_a_page_being_read():
|
||||
"""It called skipWaiting() unconditionally, so a release replaced the
|
||||
assets under an open tab mid-session. It waits to be asked now."""
|
||||
import re
|
||||
|
||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||
# Comments stripped first: the install handler explains at length that it
|
||||
# deliberately does not call this, and a test that reads prose would fail
|
||||
# on the explanation for the fix.
|
||||
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
|
||||
code = re.sub(r"//[^\n]*", "", code)
|
||||
install = code.split('addEventListener("install"', 1)[1].split("addEventListener(", 1)[0]
|
||||
assert "skipWaiting" not in install
|
||||
assert 'event.data.type === "SKIP_WAITING"' in code
|
||||
|
||||
|
||||
def test_the_worker_survives_a_rotated_subscription():
|
||||
"""A browser replacing a subscription on its own is the normal way push
|
||||
stops working, and nothing anywhere said so."""
|
||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||
assert "pushsubscriptionchange" in source
|
||||
|
||||
|
||||
def test_the_badge_is_not_the_full_colour_icon():
|
||||
"""A badge is drawn as a mask -- the device keeps the alpha and throws the
|
||||
colour away -- so an icon opaque to its edges renders as a grey square."""
|
||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||
assert 'badge: "/static/img/badge-72.png"' in source
|
||||
badge = Path(STATIC_DIR) / "img" / "badge-72.png"
|
||||
assert badge.exists() and badge.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_the_two_icons_a_device_crops_are_cached():
|
||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||
shell = source.split("var SHELL = [", 1)[1].split("];", 1)[0]
|
||||
assert "icon-maskable-512.png" in shell
|
||||
assert "apple-touch-icon-180.png" in shell
|
||||
|
||||
|
||||
# --- The window's own edges --------------------------------------------------
|
||||
def test_the_page_asks_for_the_whole_screen_and_then_pays_for_it(client, registered):
|
||||
"""`viewport-fit=cover` is what makes `env(safe-area-inset-*)` resolve to
|
||||
anything but zero, and `black-translucent` below it is what puts the page
|
||||
under the status bar in the first place. One without the other is a topbar
|
||||
beneath the clock."""
|
||||
page = client.get("/chat").text
|
||||
assert "viewport-fit=cover" in page
|
||||
|
||||
css = (STATIC_DIR / "css" / "tokens.css").read_text()
|
||||
assert "safe-area-inset-top" in css
|
||||
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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""The sidebar as a drawer: closed by default where it covers the page.
|
||||
|
||||
Below the phone breakpoint the sidebar is a fixed 280px overlay. It was
|
||||
rendered with no `hidden` attribute at any width and nothing ever set one on
|
||||
load, so on a 390px phone it covered the page from first paint -- with the only
|
||||
control that could close it, the topbar's toggle, underneath it. And that toggle
|
||||
existed on `/chat` alone: the seven other pages carrying the sidebar had no
|
||||
dismiss control of any kind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import lembas
|
||||
|
||||
ROOT = Path(lembas.__file__).parent
|
||||
TEMPLATES = ROOT / "web/templates"
|
||||
APP_CSS = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||
APP_JS = (ROOT / "web/static/js/app.js").read_text(encoding="utf-8")
|
||||
|
||||
# Every page that renders the sidebar.
|
||||
CARRIERS = [
|
||||
"settings.html",
|
||||
"chat/index.html",
|
||||
"reports/_layout.html",
|
||||
"messages/index.html",
|
||||
"schedules/_layout.html",
|
||||
"library/_layout.html",
|
||||
"agents/_layout.html",
|
||||
"folders/edit.html",
|
||||
]
|
||||
|
||||
|
||||
def test_every_page_with_a_sidebar_has_a_way_to_close_it():
|
||||
"""`grep -rn 'data-toggle="#sidebar"'` returned exactly one hit, and the
|
||||
other seven pages were unusable on a phone because of it."""
|
||||
missing = [
|
||||
name
|
||||
for name in CARRIERS
|
||||
if "partials/_sidebar_toggle.html" not in (TEMPLATES / name).read_text(encoding="utf-8")
|
||||
]
|
||||
assert not missing, f"no sidebar toggle on: {missing}"
|
||||
|
||||
|
||||
def test_the_toggle_is_one_partial_and_not_eight_copies():
|
||||
"""The next control added to a topbar should not have to be added eight
|
||||
times, which is how the first one came to exist once."""
|
||||
toggle = (TEMPLATES / "partials/_sidebar_toggle.html").read_text(encoding="utf-8")
|
||||
assert 'data-toggle="#sidebar"' in toggle
|
||||
assert 'aria-label="Toggle sidebar"' in toggle
|
||||
|
||||
|
||||
def test_the_toggle_does_not_claim_to_be_open():
|
||||
"""It rendered `aria-expanded="true"` from the template -- a fact nobody
|
||||
checked and one that was false on every phone. `syncToggles` writes it."""
|
||||
toggle = (TEMPLATES / "partials/_sidebar_toggle.html").read_text(encoding="utf-8")
|
||||
# The element, not the file: the comment above it explains at length why
|
||||
# the attribute is absent, and a test reading the file fails on the
|
||||
# explanation for the fix.
|
||||
button = toggle.split("<button", 1)[1].split(">", 1)[0]
|
||||
assert "aria-expanded" not in button
|
||||
assert 'syncToggles("#sidebar"' in APP_JS
|
||||
|
||||
|
||||
def test_the_drawer_is_closed_by_default_only_where_it_is_a_drawer():
|
||||
"""Three states, and the third is the one that matters: absent means
|
||||
"follow the width", which is what the server renders because the server
|
||||
does not know the width."""
|
||||
assert 'data-sidebar="open"' in APP_CSS
|
||||
assert 'data-sidebar="closed"' in APP_CSS
|
||||
assert 'return !window.matchMedia(NARROW).matches;' in APP_JS
|
||||
|
||||
|
||||
def test_the_close_button_is_reachable_inside_the_open_drawer():
|
||||
"""`.sidebar__close` is `display: none` at width and turned back on inside
|
||||
the media query. Both rules are one class deep, so the order decides -- and
|
||||
written the other way round the button is invisible at every width,
|
||||
including inside the drawer it exists for."""
|
||||
base = APP_CSS.index("\n.sidebar__close {")
|
||||
inside = APP_CSS.index(" .sidebar__close {")
|
||||
assert base < inside, "the base rule must come first or it wins everywhere"
|
||||
|
||||
|
||||
def test_nothing_behind_the_drawer_can_be_tabbed_into():
|
||||
assert 'toggleAttribute("inert"' in APP_JS
|
||||
|
||||
|
||||
def test_inert_is_never_left_behind_on_a_widened_window():
|
||||
"""An `inert` left on a window somebody widened is a page that has stopped
|
||||
responding, which is worse than the bug it is here to fix."""
|
||||
assert 'matchMedia(NARROW).addEventListener("change"' in APP_JS
|
||||
|
||||
|
||||
def test_the_drawer_is_dismissible_without_finding_a_button():
|
||||
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8")
|
||||
assert 'class="sidebar-scrim"' in sidebar
|
||||
assert 'data-toggle="#sidebar"' in sidebar
|
||||
assert ".sidebar-scrim" in APP_CSS
|
||||
|
||||
|
||||
def test_the_sidebar_does_not_go_through_setpanel():
|
||||
"""The other three panels use the `hidden` attribute, which is one value
|
||||
for both widths -- the thing this panel cannot use."""
|
||||
assert 'if (selector === "#sidebar") return setSidebar(open);' in APP_JS
|
||||
|
||||
|
||||
def test_the_toggle_is_a_real_target(client: TestClient, registered):
|
||||
"""44px comes from `--control-h` under the coarse-pointer block, so this
|
||||
only holds while `.btn--icon` keeps taking its size from that token."""
|
||||
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
||||
assert "--tap-min: 2.75rem" in tokens
|
||||
assert "--control-h: var(--tap-min)" in tokens
|
||||
+13
-3
@@ -163,10 +163,15 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
||||
either alone leaves the bug.
|
||||
"""
|
||||
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||
|
||||
# The scroller rule names where the tabs must be, not just the class.
|
||||
assert ".main > .tabs > .tabs__body" in admin
|
||||
# The scroller rule names where the tabs must be, not just the class. Which
|
||||
# stylesheet it is written in is not the point and is not asserted -- the
|
||||
# four declarations that make something a scroller now live once, in
|
||||
# app.css, and this selector is listed there with the rest.
|
||||
assert ".main > .tabs > .tabs__body" in admin + app
|
||||
assert "\n.tabs__body {" not in admin
|
||||
assert "\n.tabs__body {" not in app
|
||||
|
||||
assert "overflowY" in SOURCE
|
||||
assert "scrollHeight > " in SOURCE
|
||||
@@ -319,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