Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7bf7d728b
|
@@ -16,6 +16,36 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
Two things a phone found that 1.1.0's phone pass had not.
|
||||||
|
|
||||||
|
- Fixed: **the administration area could not be navigated on a phone.** Admin
|
||||||
|
has a nav of its own rather than the chat sidebar, and 1.1.0 gave every
|
||||||
|
sidebar the drawer behaviour — starts closed, slides in — without giving that
|
||||||
|
one any of the drawer's furniture. So it sat off-screen with no button to open
|
||||||
|
it, no close, and nothing to tap beside it: every administration page was
|
||||||
|
reachable and then a dead end. It now opens, closes and dims the page like the
|
||||||
|
other one, and a test refuses any future sidebar that cannot be opened.
|
||||||
|
- Fixed: **the chat gave nearly a quarter of a phone screen to margins**, so
|
||||||
|
anything that could not wrap had to be scrolled to sideways. The thread's side
|
||||||
|
padding is halved, and the speaker's avatar moves above the turn instead of
|
||||||
|
sitting in a 44px column beside every line of it — a code block gained about
|
||||||
|
sixty pixels of readable width.
|
||||||
|
- Fixed: **the chat's title was squeezed to nothing.** The row's designated
|
||||||
|
shrinker is hidden below a tablet width, so on a phone the controls went rigid
|
||||||
|
and asked for 317 pixels of a 390 pixel bar; the heading was not truncated, it
|
||||||
|
simply stopped occupying space. The model picker gives now, and on a phone it
|
||||||
|
shows its avatar rather than its name — the name is one tap away and the
|
||||||
|
title is not.
|
||||||
|
- Tick boxes and the smaller buttons are big enough to hit on a phone. A
|
||||||
|
checkbox is drawn by the browser at about sixteen pixels whatever the type
|
||||||
|
around it, which made it the smallest target in the application by some way,
|
||||||
|
and the admin lists are mostly checkboxes.
|
||||||
|
- Fixed: **icon buttons could be squashed below their own size.** The sidebar
|
||||||
|
toggle measured eighteen pixels across on a phone, under half its target,
|
||||||
|
because a full row shrank the button rather than the text beside it.
|
||||||
|
|
||||||
## 1.1.1
|
## 1.1.1
|
||||||
|
|
||||||
One bug, and it is the one that made 1.1.0 look broken the moment you updated to
|
One bug, and it is the one that made 1.1.0 look broken the moment you updated to
|
||||||
|
|||||||
+35
-5
@@ -29,10 +29,19 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
SRC = REPO / "src"
|
sys.path.insert(0, str(REPO / "src"))
|
||||||
sys.path.insert(0, str(SRC))
|
|
||||||
|
|
||||||
STATIC = SRC / "lembas/web/static"
|
# Resolved from the package that actually got imported, not from where this
|
||||||
|
# file happens to sit. A copy of this script run from somewhere else silently
|
||||||
|
# pointed STATIC at a directory that did not exist, every asset URL was
|
||||||
|
# rewritten to a file:// path with nothing behind it, and the run measured an
|
||||||
|
# unstyled document -- reporting that every page in the application overflowed
|
||||||
|
# by thirty thousand pixels. The guard below only asked whether the URLs had
|
||||||
|
# been rewritten, which they had.
|
||||||
|
import lembas # noqa: E402
|
||||||
|
|
||||||
|
SRC = Path(lembas.__file__).resolve().parent.parent
|
||||||
|
STATIC = Path(lembas.__file__).resolve().parent / "web/static"
|
||||||
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
|
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
|
||||||
|
|
||||||
# Routes that are served by the app rather than mounted, so the rewrite has to
|
# Routes that are served by the app rather than mounted, so the rewrite has to
|
||||||
@@ -131,8 +140,17 @@ window.__measure = function () {
|
|||||||
tallCulprits: culprits('y'),
|
tallCulprits: culprits('y'),
|
||||||
wideCulprits: culprits('x'),
|
wideCulprits: culprits('x'),
|
||||||
/* The invariant: the application shell fills the window and the DOCUMENT
|
/* The invariant: the application shell fills the window and the DOCUMENT
|
||||||
never scrolls. A document taller than the window is the /settings bug. */
|
never scrolls *for the reader*. A document taller than the window is the
|
||||||
documentScrolls: de.scrollHeight > window.innerHeight + 1,
|
/settings bug -- but only when the reader can actually move it. `overflow:
|
||||||
|
hidden` blocks a wheel and a finger while still permitting an assignment
|
||||||
|
to scrollTop, so a page whose shell clips a tall descendant reports a
|
||||||
|
scrollHeight of thousands and scrolls for nobody. /admin/prompts does
|
||||||
|
exactly that, and reading the raw height called it a bug four times. */
|
||||||
|
documentScrolls:
|
||||||
|
de.scrollHeight > window.innerHeight + 1 &&
|
||||||
|
["visible", "auto", "scroll"].indexOf(
|
||||||
|
getComputedStyle(document.documentElement).overflowY
|
||||||
|
) !== -1,
|
||||||
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
|
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
|
||||||
smallTargets: small.slice(0, 40),
|
smallTargets: small.slice(0, 40),
|
||||||
smallCount: small.length,
|
smallCount: small.length,
|
||||||
@@ -219,6 +237,18 @@ def rewrite(html: str, client, assets: Path) -> str:
|
|||||||
f"{sorted(set(blocking))[:8]}"
|
f"{sorted(set(blocking))[:8]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# And that what they were rewritten *to* is really there. A rewrite that
|
||||||
|
# matches and produces a dead path is indistinguishable, from inside the
|
||||||
|
# browser, from no stylesheet at all -- and it is the failure that actually
|
||||||
|
# happened, twice.
|
||||||
|
missing = [
|
||||||
|
url
|
||||||
|
for url in re.findall(r'(?:href|src)="file://([^"?]+)"', html)
|
||||||
|
if not Path(url).exists()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
|
||||||
|
|
||||||
# The one-time notifications offer is a modal over the very page we came
|
# 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
|
# 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.
|
# it runs before the deferred script that reads it.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.1.1"
|
__version__ = "1.1.2"
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ button, input, textarea, select {
|
|||||||
/* Square, and the same height as everything beside it. */
|
/* Square, and the same height as everything beside it. */
|
||||||
.btn--icon {
|
.btn--icon {
|
||||||
width: var(--control-h);
|
width: var(--control-h);
|
||||||
|
/* Square, and it stays square. Without this a flex row that runs out of room
|
||||||
|
shrinks it instead of its neighbours -- the sidebar toggle measured 18px
|
||||||
|
across on a 390px chat, less than half the target it is supposed to be,
|
||||||
|
while the row beside it kept every pixel it had asked for. A control's
|
||||||
|
size is not the give in a layout; text is. */
|
||||||
|
flex: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
@@ -354,12 +360,28 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
.checkbox input {
|
.checkbox input {
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
width: 1rem;
|
width: var(--check-size);
|
||||||
height: 1rem;
|
height: var(--check-size);
|
||||||
flex: none;
|
flex: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Every tick box, not only the ones inside a `.checkbox` label -- the admin
|
||||||
|
lists put bare ones in a row and those were 16px square on a phone. */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
}
|
||||||
|
/* Except the ones that are deliberately 1px: a visually-hidden radio is the
|
||||||
|
state behind a label, and the label is the target. */
|
||||||
|
input.visually-hidden[type="radio"],
|
||||||
|
input.visually-hidden[type="checkbox"] {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Multi-column form layout, one definition. */
|
/* Multi-column form layout, one definition. */
|
||||||
.grid { display: grid; gap: var(--sp-4); }
|
.grid { display: grid; gap: var(--sp-4); }
|
||||||
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
||||||
@@ -953,7 +975,48 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
/*
|
||||||
|
The controls on the right of the topbar.
|
||||||
|
|
||||||
|
`flex: none` on the group with `min-width: 0` inside it: the group keeps the
|
||||||
|
width its controls need, and the one child whose width is a *name* rather
|
||||||
|
than a control -- the model picker -- is the thing allowed to give. Without
|
||||||
|
the second half the group asked for 317px of a 390px bar and the chat's
|
||||||
|
title, which is `flex: 1`, was squeezed to exactly zero: a heading that had
|
||||||
|
not been shortened or truncated but had simply ceased to occupy space.
|
||||||
|
*/
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
/* Allowed to give, which it was not. `--topbar__where` used to be the
|
||||||
|
designated shrinker in this row, and it is `display: none` below 64rem --
|
||||||
|
so on a phone the group became rigid, asked for 317px of a 390px bar, and
|
||||||
|
the title (`flex: 1`) was squeezed to exactly zero: a heading that had not
|
||||||
|
been truncated but had ceased to occupy space.
|
||||||
|
|
||||||
|
Nothing inside it shrinks except the model picker: every button here is
|
||||||
|
`flex: none` because a control's size is not the give in a layout. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* A title identifies the page, so it gets a floor and truncates rather than
|
||||||
|
disappearing. */
|
||||||
|
.topbar__title { min-width: 4rem; }
|
||||||
|
|
||||||
|
/* The one control in this row whose width is somebody else's decision -- a
|
||||||
|
model's label is whatever an administrator called it -- so it is the one
|
||||||
|
that gives, and it gives by truncating its name rather than its avatar or
|
||||||
|
its chevron. */
|
||||||
|
.topbar__actions .picker { min-width: 0; }
|
||||||
|
.topbar__actions .picker__button { max-width: 100%; }
|
||||||
|
.topbar__actions .picker__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.picker__avatar, .picker__chevron { flex: none; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Which machine an agent chat runs on, and where.
|
Which machine an agent chat runs on, and where.
|
||||||
@@ -1243,6 +1306,22 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
/* The bar is the densest row in the application and the one with the least
|
||||||
|
room: a toggle, a title, a model, and up to four panel buttons. Tighter
|
||||||
|
padding and a smaller gap buy back about 24px, which is the difference
|
||||||
|
between a title that truncates and one there is no room for at all. */
|
||||||
|
.topbar {
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding-right: max(var(--sp-2), var(--safe-right));
|
||||||
|
padding-left: max(var(--sp-2), var(--safe-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The model's name costs about a hundred pixels and its avatar does not,
|
||||||
|
and the picker opens onto a list of full names the moment it is touched.
|
||||||
|
So on a phone the avatar carries the identity and the chat's own title --
|
||||||
|
which nothing else on the screen tells you -- gets the room back. */
|
||||||
|
.topbar__actions .picker__label { display: none; }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
|
|||||||
@@ -1726,3 +1726,52 @@
|
|||||||
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
|
.thread__intro > * { animation: intro-rise var(--dur-3) var(--ease-out) both; }
|
||||||
.thread__intro > *:nth-child(2) { animation-delay: 60ms; }
|
.thread__intro > *:nth-child(2) { animation-delay: 60ms; }
|
||||||
.thread__intro > *:nth-child(3) { animation-delay: 120ms; }
|
.thread__intro > *:nth-child(3) { animation-delay: 120ms; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
--- A phone ----------------------------------------------------------------
|
||||||
|
|
||||||
|
The one width-aware block in this file, and the reason the blanket ban on
|
||||||
|
`@media` here was lifted: everything below is a *size*, and there is no
|
||||||
|
intrinsic-sizing trick that makes 24px of thread padding the right amount on
|
||||||
|
a 390px screen. The ban existed to stop the composer toolbar being "fixed"
|
||||||
|
with a breakpoint instead of by saying which child gives, and that guarantee
|
||||||
|
is asserted directly now (`tests/test_chat.py`) -- so this block may not touch
|
||||||
|
`.composer__toolbar` or `.composer__actions`, and a test refuses it if it
|
||||||
|
does.
|
||||||
|
|
||||||
|
What was wrong: a 390px screen spent 40px of its width on thread padding and
|
||||||
|
another 44 on the avatar gutter before a single word was drawn, which is
|
||||||
|
nearly a quarter of the screen given over to margin -- so anything that could
|
||||||
|
not wrap had to be scrolled to sideways.
|
||||||
|
*/
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
/* Half the horizontal padding. The vertical stays: it is what separates one
|
||||||
|
turn from the next, and turns are no closer together on a phone. */
|
||||||
|
.thread {
|
||||||
|
padding-left: var(--sp-3);
|
||||||
|
padding-right: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The avatar goes to the top of the turn rather than beside it, so the body
|
||||||
|
gets the whole width. The gutter is what identifies the speaker and it
|
||||||
|
still does; it simply stops costing 44px of every line. */
|
||||||
|
.msg {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.msg__gutter {
|
||||||
|
width: var(--control-h-sm);
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
}
|
||||||
|
.msg__meta { gap: var(--sp-2); }
|
||||||
|
|
||||||
|
/* A bubble against the edge of the screen wants less inside it. */
|
||||||
|
.msg--user .msg__body--plain { padding: var(--sp-2) var(--sp-3); }
|
||||||
|
|
||||||
|
/* The composer is the other thing pressed against both edges. */
|
||||||
|
.composer { padding-left: var(--sp-2); padding-right: var(--sp-2); }
|
||||||
|
|
||||||
|
/* A hint that runs to four lines on a phone is a hint nobody reads, and it
|
||||||
|
sits directly under the thing a thumb is reaching for. */
|
||||||
|
.composer__hint { font-size: var(--text-xs); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,9 @@
|
|||||||
Raising the token is the only version that reaches all of them, and it is
|
Raising the token is the only version that reaches all of them, and it is
|
||||||
what `--control-h` exists for. */
|
what `--control-h` exists for. */
|
||||||
--tap-min: 2.75rem;
|
--tap-min: 2.75rem;
|
||||||
|
/* A tick box, which does not take its size from `--control-h`: the browser
|
||||||
|
draws it and only `width`/`height` move it. */
|
||||||
|
--check-size: 1rem;
|
||||||
|
|
||||||
/* --- The window's own edges ---------------------------------------------
|
/* --- The window's own edges ---------------------------------------------
|
||||||
Installed on a phone, the page runs under the notch and the home
|
Installed on a phone, the page runs under the notch and the home
|
||||||
@@ -421,9 +424,16 @@
|
|||||||
@media (pointer: coarse), (max-width: 48rem) {
|
@media (pointer: coarse), (max-width: 48rem) {
|
||||||
:root {
|
:root {
|
||||||
--control-h: var(--tap-min);
|
--control-h: var(--tap-min);
|
||||||
--control-h-sm: 2.25rem;
|
/* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
|
||||||
|
action, not an unimportant one -- Edit, Enable and Use default are all
|
||||||
|
`.btn--sm`, and on a phone they are the whole interaction. */
|
||||||
|
--control-h-sm: 2.5rem;
|
||||||
--control-px: var(--sp-4);
|
--control-px: var(--sp-4);
|
||||||
--control-px-sm: var(--sp-3);
|
--control-px-sm: var(--sp-3);
|
||||||
|
/* A native checkbox is 13-16px whatever the surrounding type is, and no
|
||||||
|
amount of padding on its label changes the box itself. It is the
|
||||||
|
smallest target in the application on a phone by some margin. */
|
||||||
|
--check-size: 1.375rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 33 KiB |
@@ -14,10 +14,20 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
{#
|
||||||
<div class="sidebar__header">
|
`id="sidebar"` and the drawer's furniture, because below the phone
|
||||||
{{ brandlink(uid="admin") }}
|
breakpoint `.sidebar` is a fixed overlay that starts closed -- and this one
|
||||||
</div>
|
had neither an id for `data-toggle="#sidebar"` to find nor any control to
|
||||||
|
open it. The administration area was reachable on a phone and then
|
||||||
|
unnavigable once you arrived.
|
||||||
|
#}
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<header class="sidebar__header">
|
||||||
|
<div class="sidebar__brand-slot">
|
||||||
|
{{ brandlink(uid="admin") }}
|
||||||
|
</div>
|
||||||
|
{% include "partials/_sidebar_close.html" %}
|
||||||
|
</header>
|
||||||
|
|
||||||
<nav class="sidebar__scroll" aria-label="Administration">
|
<nav class="sidebar__scroll" aria-label="Administration">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
@@ -106,8 +116,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{% include "partials/_sidebar_scrim.html" %}
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
{% include "partials/_sidebar_toggle.html" %}
|
||||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
The way out of the drawer, and the reason it is *inside* it.
|
||||||
|
|
||||||
|
Below the phone breakpoint the sidebar is a fixed overlay and the toggle that
|
||||||
|
opens it is in the topbar underneath -- so once open, the control for closing
|
||||||
|
it is behind it. Its own partial because there are two sidebars in this
|
||||||
|
application, the chat one and the admin one, and the second was given the
|
||||||
|
drawer behaviour without the drawer's furniture: at a phone width it was
|
||||||
|
hidden off-screen with no toggle and no close anywhere, which is an admin area
|
||||||
|
that simply could not be navigated on a phone.
|
||||||
|
|
||||||
|
Hidden above that breakpoint, where the sidebar is an ordinary column.
|
||||||
|
#}
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{#
|
||||||
|
The scrim behind an open drawer. It carries the same `data-toggle` as every
|
||||||
|
other control that closes it, so tapping beside the drawer goes through one
|
||||||
|
code path rather than a second 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>
|
||||||
@@ -27,27 +27,7 @@
|
|||||||
{{ brandlink(uid="side") }}
|
{{ brandlink(uid="side") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#
|
{% include "partials/_sidebar_close.html" %}
|
||||||
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>
|
</header>
|
||||||
|
|
||||||
{% include "partials/_sidebar_actions.html" %}
|
{% include "partials/_sidebar_actions.html" %}
|
||||||
@@ -126,13 +106,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#
|
{% include "partials/_sidebar_scrim.html" %}
|
||||||
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>
|
|
||||||
|
|||||||
@@ -95,9 +95,12 @@ def test_inert_is_never_left_behind_on_a_widened_window():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_drawer_is_dismissible_without_finding_a_button():
|
def test_the_drawer_is_dismissible_without_finding_a_button():
|
||||||
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8")
|
"""The scrim is a partial because there are two sidebars, so the markup is
|
||||||
assert 'class="sidebar-scrim"' in sidebar
|
asserted where it is defined and its *inclusion* is asserted per sidebar by
|
||||||
assert 'data-toggle="#sidebar"' in sidebar
|
`test_every_sidebar_carries_the_way_out_and_the_scrim`."""
|
||||||
|
scrim = (TEMPLATES / "partials/_sidebar_scrim.html").read_text(encoding="utf-8")
|
||||||
|
assert 'class="sidebar-scrim"' in scrim
|
||||||
|
assert 'data-toggle="#sidebar"' in scrim
|
||||||
assert ".sidebar-scrim" in APP_CSS
|
assert ".sidebar-scrim" in APP_CSS
|
||||||
|
|
||||||
|
|
||||||
@@ -113,3 +116,67 @@ def test_the_toggle_is_a_real_target(client: TestClient, registered):
|
|||||||
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
||||||
assert "--tap-min: 2.75rem" in tokens
|
assert "--tap-min: 2.75rem" in tokens
|
||||||
assert "--control-h: var(--tap-min)" in tokens
|
assert "--control-h: var(--tap-min)" in tokens
|
||||||
|
|
||||||
|
|
||||||
|
# --- Any sidebar, not only the one this was written for ----------------------
|
||||||
|
def _sidebar_templates() -> list[str]:
|
||||||
|
"""Every template that renders a sidebar of its own, found rather than
|
||||||
|
listed -- the admin one was missed precisely because it was not on a list."""
|
||||||
|
return [
|
||||||
|
str(p.relative_to(TEMPLATES))
|
||||||
|
for p in TEMPLATES.rglob("*.html")
|
||||||
|
if '<aside class="sidebar"' in p.read_text(encoding="utf-8")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sidebar_is_one_the_toggle_can_find():
|
||||||
|
"""`data-toggle="#sidebar"` resolves by id, and below the phone breakpoint
|
||||||
|
`.sidebar` is a fixed overlay that starts closed. A sidebar without that id
|
||||||
|
is one nothing can open: the admin area shipped that way in 1.1.0 and 1.1.1
|
||||||
|
-- reachable on a phone, and unnavigable the moment you arrived."""
|
||||||
|
without = [
|
||||||
|
name
|
||||||
|
for name in _sidebar_templates()
|
||||||
|
if '<aside class="sidebar" id="sidebar"' not in (TEMPLATES / name).read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert not without, f"sidebar with no id, so nothing can open it: {without}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sidebar_carries_the_way_out_and_the_scrim():
|
||||||
|
missing = []
|
||||||
|
for name in _sidebar_templates():
|
||||||
|
text = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
if "partials/_sidebar_close.html" not in text:
|
||||||
|
missing.append(f"{name}: no close button")
|
||||||
|
if "partials/_sidebar_scrim.html" not in text:
|
||||||
|
missing.append(f"{name}: no scrim")
|
||||||
|
assert not missing, missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_admin_area_can_be_navigated_on_a_phone(client: TestClient, registered):
|
||||||
|
"""The whole of administration is in that nav and nowhere else."""
|
||||||
|
page = client.get("/admin/models").text
|
||||||
|
assert '<aside class="sidebar" id="sidebar"' in page
|
||||||
|
assert 'data-toggle="#sidebar"' in page
|
||||||
|
assert "sidebar-scrim" in page
|
||||||
|
|
||||||
|
|
||||||
|
# --- Controls do not shrink below their own size -----------------------------
|
||||||
|
def test_an_icon_button_keeps_its_size_in_a_tight_row():
|
||||||
|
"""`.btn--icon` sets a width and, without `flex: none`, a row that runs out
|
||||||
|
of room shrinks it instead of the text beside it -- the sidebar toggle
|
||||||
|
measured 18px across on a 390px chat, well under half its target."""
|
||||||
|
rule = APP_CSS.split(".btn--icon {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "flex: none" in rule
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_topbar_can_give_somewhere(client: TestClient, registered):
|
||||||
|
"""`.topbar__where` was the designated shrinker in that row and it is
|
||||||
|
`display: none` below 64rem, so on a phone the group went rigid and the
|
||||||
|
title -- which is `flex: 1` -- was squeezed to exactly zero width."""
|
||||||
|
actions = APP_CSS.split(".topbar__actions {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "flex: 0 1 auto" in actions
|
||||||
|
assert "min-width: 0" in actions
|
||||||
|
assert "min-width" in APP_CSS.split(".topbar__title {", 1)[1].split("}", 1)[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user