An admin area a phone could reach and not navigate
Administration 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. No id for the toggle to resolve, no toggle, no close, no scrim: it sat at left:-280 with nothing in the application able to open it. The close button and the scrim are partials now, used by both, and the test that guards it *finds* sidebars by scanning the templates rather than working from a list, which is exactly why this one was missed. The chat, measured at 390px, spent forty pixels of side padding and a forty-four pixel avatar column before drawing a word -- close to a quarter of the screen on margin, so anything that could not wrap had to be reached sideways. Padding halved and the avatar moved above the turn; a code block gained about sixty pixels. Worse in the same row: `.topbar__actions` asked for 317px of a 390px bar, because the control that used to give in that row is display:none below a tablet width, so the group went rigid and the title -- flex: 1 -- was squeezed to exactly zero. And `.btn--icon` sets a width with no `flex: none`, so the row shrank the button instead of the text: the sidebar toggle measured eighteen pixels across. The picker gives now, and shows its avatar rather than its name on a phone. Also the instrument, which lied twice more: it could not see horizontal overflow at all, because `.shell` is overflow:hidden and its "is this contained" test therefore answered yes for everything on the page; and run from a copy it resolved `STATIC` to a directory that did not exist, rewrote every asset URL to a dead file:// path and reported the whole application overflowing by thirty thousand pixels. It resolves from the imported package now and asserts that what it rewrote to is really there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,36 @@ for 1.0.0 have something to be assembled from.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SRC = REPO / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
sys.path.insert(0, str(REPO / "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")
|
||||
|
||||
# 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'),
|
||||
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,
|
||||
never scrolls *for the reader*. A document taller than the window is the
|
||||
/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,
|
||||
smallTargets: small.slice(0, 40),
|
||||
smallCount: small.length,
|
||||
@@ -219,6 +237,18 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""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. */
|
||||
.btn--icon {
|
||||
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;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
@@ -354,12 +360,28 @@ button, input, textarea, select {
|
||||
}
|
||||
.checkbox input {
|
||||
accent-color: var(--accent);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: var(--check-size);
|
||||
height: var(--check-size);
|
||||
flex: none;
|
||||
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. */
|
||||
.grid { display: grid; gap: var(--sp-4); }
|
||||
.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;
|
||||
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.
|
||||
@@ -1243,6 +1306,22 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
||||
}
|
||||
|
||||
@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 {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
|
||||
@@ -1726,3 +1726,52 @@
|
||||
.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; }
|
||||
|
||||
/*
|
||||
--- 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
|
||||
what `--control-h` exists for. */
|
||||
--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 ---------------------------------------------
|
||||
Installed on a phone, the page runs under the notch and the home
|
||||
@@ -421,9 +424,16 @@
|
||||
@media (pointer: coarse), (max-width: 48rem) {
|
||||
:root {
|
||||
--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-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 %}
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar__header">
|
||||
{{ brandlink(uid="admin") }}
|
||||
</div>
|
||||
{#
|
||||
`id="sidebar"` and the drawer's furniture, because below the phone
|
||||
breakpoint `.sidebar` is a fixed overlay that starts closed -- and this one
|
||||
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">
|
||||
<div class="nav-group">
|
||||
@@ -106,8 +116,11 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{% include "partials/_sidebar_scrim.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
{% include "partials/_sidebar_toggle.html" %}
|
||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||
<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") }}
|
||||
</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>
|
||||
{% include "partials/_sidebar_close.html" %}
|
||||
</header>
|
||||
|
||||
{% include "partials/_sidebar_actions.html" %}
|
||||
@@ -126,13 +106,4 @@
|
||||
</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>
|
||||
{% include "partials/_sidebar_scrim.html" %}
|
||||
|
||||
@@ -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():
|
||||
sidebar = (TEMPLATES / "partials/sidebar.html").read_text(encoding="utf-8")
|
||||
assert 'class="sidebar-scrim"' in sidebar
|
||||
assert 'data-toggle="#sidebar"' in sidebar
|
||||
"""The scrim is a partial because there are two sidebars, so the markup is
|
||||
asserted where it is defined and its *inclusion* is asserted per sidebar by
|
||||
`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
|
||||
|
||||
|
||||
@@ -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")
|
||||
assert "--tap-min: 2.75rem" 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