Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd8418d95
|
||
|
|
db22962164
|
||
|
|
793f9c8cad
|
@@ -16,6 +16,60 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.8.5
|
||||||
|
|
||||||
|
- **No more grey slivers at the ends of the tab bars.** Tab bars fade at an edge
|
||||||
|
to show there are more tabs to scroll to. The fade was only partly hidden
|
||||||
|
when there was nothing to scroll, so a shadow always showed at both ends. It
|
||||||
|
was invisible on the dark theme and a grey sliver on Shire, on Administration
|
||||||
|
→ Prompts, Settings and every other tabbed page. The fade now appears only
|
||||||
|
on the side where tabs are actually hidden.
|
||||||
|
|
||||||
|
## 1.8.4
|
||||||
|
|
||||||
|
Two things that kept showing up after they should have gone away.
|
||||||
|
|
||||||
|
- **"A new version is ready" no longer appears on a page that is already the
|
||||||
|
new version.** After an update the toast showed up on every page, even one just
|
||||||
|
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
|
||||||
|
whenever a new service worker was waiting. But a page loaded after the update
|
||||||
|
already *is* the update: pages always come from the server, and every
|
||||||
|
stylesheet and script they name carries the release in its address. The
|
||||||
|
worker that waits is nearly always the one from the previous release, still
|
||||||
|
holding the tab, because a reload opens the new page before the old one goes
|
||||||
|
away. The toast now compares the waiting worker's release with the page's
|
||||||
|
own, so it only appears in a tab that was opened before the update. For the
|
||||||
|
same reason, pressing Reload in one tab no longer reloads the other tabs that
|
||||||
|
are already up to date. That matters when one of them has a reply streaming
|
||||||
|
into it.
|
||||||
|
|
||||||
|
- **Switching tabs on Administration → Prompts no longer lifts the page.**
|
||||||
|
Choosing any tab but the first pushed the whole window up by the height of
|
||||||
|
the title bar and left a blank strip along the bottom, under the sidebar too.
|
||||||
|
The prompt cards' hidden labels were positioned against the page instead of
|
||||||
|
the panel. That made the page 6,771px tall behind a window that cannot scroll
|
||||||
|
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
|
||||||
|
contains what is inside it, and a tab switch moves only the panel that
|
||||||
|
scrolls. Administration → General on a small phone had the same leak and is
|
||||||
|
fixed with it.
|
||||||
|
|
||||||
|
## 1.8.3
|
||||||
|
|
||||||
|
The model picker on a phone, which could not be read once a chat was open.
|
||||||
|
|
||||||
|
- **The model menu no longer runs off the left of the screen.** Inside a chat
|
||||||
|
the picker sits in the middle of the top bar, with the panel buttons to its
|
||||||
|
right, and its menu opened from the picker's right edge — so on a phone most
|
||||||
|
of it was off the screen and every model's name was cut off. On a narrow
|
||||||
|
screen the menu now hangs from the bar itself, edge to edge, and every name is
|
||||||
|
whole. Wider screens are unchanged.
|
||||||
|
|
||||||
|
- **Opening it on a touchscreen no longer raises the keyboard.** With more than
|
||||||
|
eight models the menu has a filter box, and it took the focus on opening — so
|
||||||
|
the keyboard came up and covered half the list you had opened it to choose
|
||||||
|
from. On a touchscreen the chosen model takes the focus instead; the filter is
|
||||||
|
one tap away. With a mouse, typing straight into the filter works as before.
|
||||||
|
|
||||||
## 1.8.2
|
## 1.8.2
|
||||||
|
|
||||||
The model lists, made readable. Both printed every capability switch as a tag —
|
The model lists, made readable. Both printed every capability switch as a tag —
|
||||||
|
|||||||
@@ -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.8.2"
|
__version__ = "1.8.5"
|
||||||
|
|||||||
@@ -167,16 +167,24 @@ a.tabs__tab { text-decoration: none; }
|
|||||||
pinned to the scrollport with `background-attachment: local`, which is the old
|
pinned to the scrollport with `background-attachment: local`, which is the old
|
||||||
trick and works everywhere -- the `local` layers scroll with the content and
|
trick and works everywhere -- the `local` layers scroll with the content and
|
||||||
cover the `scroll` ones exactly when there is nothing more to see.
|
cover the `scroll` ones exactly when there is nothing more to see.
|
||||||
|
|
||||||
|
⚠ "Cover" has to mean all of it. The covers used to be as wide as the
|
||||||
|
shadows and solid for only 40% of that width, so the other 60% of every
|
||||||
|
shadow always showed through, with nothing to scroll to. On Moria that is
|
||||||
|
near-black on near-black and nobody saw it. On Shire it was a grey sliver at
|
||||||
|
both ends of every tab bar. Each cover is now twice the shadow's width and
|
||||||
|
solid across the first half, which is the whole shadow. It fades only past
|
||||||
|
the shadow's end, so once content is scrolled the shadow shows as before.
|
||||||
*/
|
*/
|
||||||
.tabs__bar {
|
.tabs__bar {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, var(--bg) 40%, transparent),
|
linear-gradient(to right, var(--bg) 50%, transparent),
|
||||||
linear-gradient(to left, var(--bg) 40%, transparent),
|
linear-gradient(to left, var(--bg) 50%, transparent),
|
||||||
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
linear-gradient(to right, var(--scrim), transparent 1.5rem),
|
||||||
linear-gradient(to left, 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-position: left center, right center, left center, right center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: 1.5rem 100%;
|
background-size: 3rem 100%, 3rem 100%, 1.5rem 100%, 1.5rem 100%;
|
||||||
background-attachment: local, local, scroll, scroll;
|
background-attachment: local, local, scroll, scroll;
|
||||||
/* A tab is a destination, so a flick should land on one rather than between
|
/* A tab is a destination, so a flick should land on one rather than between
|
||||||
two. */
|
two. */
|
||||||
|
|||||||
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
|
|||||||
child will not shrink below its content without it, so a scroller missing it
|
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
|
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
|
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. */
|
is true and is not something the next person should have to know.
|
||||||
|
|
||||||
|
`position: relative` makes each scroller the containing block for what is in
|
||||||
|
it, and without it a `.visually-hidden` label is not in it at all. That class
|
||||||
|
is `position: absolute`, so with no positioned ancestor it is placed against
|
||||||
|
the *page*, at its static position -- six thousand pixels down the Tools
|
||||||
|
panel on /admin/prompts -- and the document grew to 6771px behind a root
|
||||||
|
that is `overflow: hidden`. Nobody can scroll that by hand, but
|
||||||
|
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
|
||||||
|
and the root can. Switching a tab there lifted the whole shell 56px: the
|
||||||
|
topbar gone off the top and a strip of bare background under everything. */
|
||||||
.scroll-region,
|
.scroll-region,
|
||||||
.sidebar__scroll,
|
.sidebar__scroll,
|
||||||
.inspector__body,
|
.inspector__body,
|
||||||
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
|
|||||||
.thread-scroll,
|
.thread-scroll,
|
||||||
.admin-scroll,
|
.admin-scroll,
|
||||||
.main > .tabs > .tabs__body {
|
.main > .tabs > .tabs__body {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1377,6 +1388,23 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
which nothing else on the screen tells you -- gets the room back. */
|
which nothing else on the screen tells you -- gets the room back. */
|
||||||
.topbar__actions .picker__label { display: none; }
|
.topbar__actions .picker__label { display: none; }
|
||||||
|
|
||||||
|
/* And the menu is the bar's, not the picker's. Anchored to the picker it
|
||||||
|
opens `right: 0` of a button that sits mid-bar with the panel buttons to
|
||||||
|
its right, so a 24rem menu ran off the left edge of a 390px phone and cut
|
||||||
|
every name in half. Taking `position` off the picker makes the bar the
|
||||||
|
containing block: the menu spans the bar under it, whatever sits where --
|
||||||
|
up to its usual 24rem, held at the bar's right edge by the auto margin. */
|
||||||
|
.topbar { position: relative; }
|
||||||
|
.topbar__actions .picker { position: static; }
|
||||||
|
.topbar__actions .picker__menu {
|
||||||
|
left: max(var(--sp-2), var(--safe-left));
|
||||||
|
right: max(var(--sp-2), var(--safe-right));
|
||||||
|
width: auto;
|
||||||
|
max-width: 24rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.topbar__actions .picker__list { max-height: min(22rem, 60dvh); }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
|
|||||||
@@ -1099,27 +1099,59 @@
|
|||||||
The worker no longer takes over open pages on its own -- see sw.js -- so
|
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
|
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
|
rather than a reload: an application with a reply streaming into it must
|
||||||
not be navigated out from under somebody. */
|
not be navigated out from under somebody.
|
||||||
function watchForUpdate(registration) {
|
|
||||||
function offer(worker) {
|
🚨 "A worker is waiting" is not the same as "this page is out of date",
|
||||||
if (!worker || !navigator.serviceWorker.controller) return;
|
and the toast used to treat them as one. After a release it offered a
|
||||||
worker.addEventListener("statechange", function () {
|
reload on every page, including one just fetched with Ctrl+Shift+R, and
|
||||||
if (worker.state !== "installed") return;
|
reloading could not make it stop. A page is always fetched from the network
|
||||||
|
and every asset it names carries `?v=<release>`, so a page loaded after
|
||||||
|
the update IS the update, whichever worker happens to control it. And the
|
||||||
|
worker that controls it is nearly always the previous one: a reload
|
||||||
|
creates the new page before the old one goes away, so the old worker
|
||||||
|
never runs out of pages and the new one never stops waiting.
|
||||||
|
|
||||||
|
So the question is asked of the page. The worker's release is in its own
|
||||||
|
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
|
||||||
|
base.html. When the two match there is nothing newer to reload into, and
|
||||||
|
the worker is left to take over once the old tabs are closed. */
|
||||||
|
var PAGE_RELEASE = window.lembasRelease || "";
|
||||||
|
|
||||||
|
function releaseOf(worker) {
|
||||||
|
try {
|
||||||
|
return new URL(worker.scriptURL).searchParams.get("v") || "";
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unknown on either side counts as newer: better an extra offer than a
|
||||||
|
release nobody is told about. */
|
||||||
|
function isNewerThanThisPage(worker) {
|
||||||
|
var release = releaseOf(worker);
|
||||||
|
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offerReload(worker) {
|
||||||
window.lembas.notify(
|
window.lembas.notify(
|
||||||
"A new version is ready. Reload to use it.",
|
"A new version is ready. Reload to use it.",
|
||||||
{ kind: "info", action: { label: "Reload", run: function () {
|
{ kind: "info", action: { label: "Reload", run: function () {
|
||||||
worker.postMessage({ type: "SKIP_WAITING" });
|
worker.postMessage({ type: "SKIP_WAITING" });
|
||||||
} } }
|
} } }
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchForUpdate(registration) {
|
||||||
|
function offer(worker) {
|
||||||
|
if (!worker || !navigator.serviceWorker.controller) return;
|
||||||
|
worker.addEventListener("statechange", function () {
|
||||||
|
if (worker.state !== "installed") return;
|
||||||
|
if (isNewerThanThisPage(worker)) offerReload(worker);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
if (registration.waiting && navigator.serviceWorker.controller &&
|
||||||
window.lembas.notify(
|
isNewerThanThisPage(registration.waiting)) {
|
||||||
"A new version is ready. Reload to use it.",
|
offerReload(registration.waiting);
|
||||||
{ kind: "info", action: { label: "Reload", run: function () {
|
|
||||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
|
||||||
} } }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
registration.addEventListener("updatefound", function () {
|
registration.addEventListener("updatefound", function () {
|
||||||
offer(registration.installing);
|
offer(registration.installing);
|
||||||
@@ -1130,17 +1162,24 @@
|
|||||||
the right answer to it -- the page is now being served by a worker whose
|
the right answer to it -- the page is now being served by a worker whose
|
||||||
cache it did not start from.
|
cache it did not start from.
|
||||||
|
|
||||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
Three guards, and the second is the one that is easy to miss. A flag,
|
||||||
`controllerchange` can fire more than once. And `hadController`, because on
|
because `controllerchange` can fire more than once. And `hadController`,
|
||||||
a *first* visit there is no worker at all: the one that installs then calls
|
because on a *first* visit there is no worker at all: the one that
|
||||||
`clients.claim()`, which fires this event for the first time -- so without
|
installs then calls `clients.claim()`, which fires this event for the
|
||||||
it, the very first page anybody loads reloads itself in front of them for
|
first time -- so without it, the very first page anybody loads reloads
|
||||||
no reason they could possibly work out. */
|
itself in front of them for no reason they could possibly work out.
|
||||||
|
|
||||||
|
The third is the same question as the toast's. Somebody pressing Reload in
|
||||||
|
one tab activates the worker for all of them, and a tab that was already
|
||||||
|
rendered by that release has nothing to gain from a reload -- and may have
|
||||||
|
a reply streaming into it. */
|
||||||
var reloading = false;
|
var reloading = false;
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
var hadController = !!navigator.serviceWorker.controller;
|
var hadController = !!navigator.serviceWorker.controller;
|
||||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||||
if (reloading || !hadController) return;
|
if (reloading || !hadController) return;
|
||||||
|
var controller = navigator.serviceWorker.controller;
|
||||||
|
if (controller && !isNewerThanThisPage(controller)) return;
|
||||||
reloading = true;
|
reloading = true;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,6 +320,11 @@
|
|||||||
if (filter) {
|
if (filter) {
|
||||||
filter.value = "";
|
filter.value = "";
|
||||||
applyFilter(menu, "");
|
applyFilter(menu, "");
|
||||||
|
}
|
||||||
|
// Not on a touchscreen: focusing a text field there raises the keyboard,
|
||||||
|
// which covers half the list the finger came to choose from. The filter
|
||||||
|
// is one tap away for whoever wants it.
|
||||||
|
if (filter && !window.matchMedia("(hover: none)").matches) {
|
||||||
filter.focus();
|
filter.focus();
|
||||||
} else {
|
} else {
|
||||||
var selected = menu.querySelector(".picker__option.is-selected") ||
|
var selected = menu.querySelector(".picker__option.is-selected") ||
|
||||||
@@ -1128,7 +1133,19 @@ document.addEventListener("lembas:notify", function (event) {
|
|||||||
shrinks the document and scrollTop is clamped to the new maximum, which
|
shrinks the document and scrollTop is clamped to the new maximum, which
|
||||||
for a short panel is somewhere below everything. */
|
for a short panel is somewhere below everything. */
|
||||||
var outer = scroller(bar);
|
var outer = scroller(bar);
|
||||||
if (outer && outer !== body) bar.scrollIntoView({ block: "start" });
|
if (!outer || outer === body) return;
|
||||||
|
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
|
||||||
|
ancestor that can scroll, the document included -- and the document
|
||||||
|
could, by the height of whatever leaked out of the scroller, so a tab
|
||||||
|
switch lifted the whole shell and left a strip of background under it.
|
||||||
|
The containing block in app.css stops the leak; this stops a leak
|
||||||
|
anyone adds later from being turned into a visible one.
|
||||||
|
|
||||||
|
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
|
||||||
|
is scrolled past the lede it reports the scroller's own top and the
|
||||||
|
sum below would come out as nothing to do. */
|
||||||
|
var tabs = bar.parentElement;
|
||||||
|
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,9 @@
|
|||||||
what `app.js` turns into a sentence on the settings page.
|
what `app.js` turns into a sentence on the settings page.
|
||||||
#}
|
#}
|
||||||
<script>
|
<script>
|
||||||
|
/* The release this page was rendered by, for `app.js` to hold a waiting
|
||||||
|
worker up against -- see "A release that arrived while you were reading". */
|
||||||
|
window.lembasRelease = {{ version | tojson }};
|
||||||
window.lembasWorker = { state: "unsupported" };
|
window.lembasWorker = { state: "unsupported" };
|
||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
/* Reported separately from an outright failure: the fix is different. */
|
/* Reported separately from an outright failure: the fix is different. */
|
||||||
|
|||||||
@@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared():
|
|||||||
for token in ("--terminal-width-min", "--canvas-width-min"):
|
for token in ("--terminal-width-min", "--canvas-width-min"):
|
||||||
assert f"{token}:" in TOKENS
|
assert f"{token}:" in TOKENS
|
||||||
assert f"var({token})" in APP_CSS
|
assert f"var({token})" in APP_CSS
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_topbar_model_menu_belongs_to_the_bar_on_a_phone():
|
||||||
|
"""1.8.3. Anchored to the picker, the menu opened `right: 0` of a button that
|
||||||
|
sits mid-bar with the panel buttons to its right, so on a 390px phone a 24rem
|
||||||
|
menu started 132px left of the screen and every model's name was cut off.
|
||||||
|
|
||||||
|
Below the phone breakpoint the picker gives up `position`, which makes the bar
|
||||||
|
the containing block, and the menu is pinned between the bar's two edges."""
|
||||||
|
body = _media_body(APP_CSS, "48rem")
|
||||||
|
assert re.search(r"\.topbar\s*\{\s*position:\s*relative", body)
|
||||||
|
assert re.search(r"\.topbar__actions \.picker\s*\{\s*position:\s*static", body)
|
||||||
|
menu = re.search(r"\.topbar__actions \.picker__menu\s*\{([^}]*)\}", body)
|
||||||
|
assert menu, "the topbar's menu is not placed on a phone"
|
||||||
|
for declaration in ("left:", "right:", "width: auto"):
|
||||||
|
assert declaration in menu.group(1), f"{declaration} missing from the phone menu"
|
||||||
|
|||||||
@@ -111,3 +111,22 @@ def test_settings_lists_the_models_with_their_tags_below_the_name(client, models
|
|||||||
assert listing.count("#i-eye") == 1
|
assert listing.count("#i-eye") == 1
|
||||||
assert listing.count('class="model-list__more model-list__tags"') == 2
|
assert listing.count('class="model-list__more model-list__tags"') == 2
|
||||||
assert "tool_fetch" in listing
|
assert "tool_fetch" in listing
|
||||||
|
|
||||||
|
|
||||||
|
def test_opening_the_picker_on_a_touchscreen_does_not_raise_the_keyboard():
|
||||||
|
"""1.8.3. With more than eight models the menu has a filter, and `open()`
|
||||||
|
focused it -- which on a phone raises the keyboard over half the list the
|
||||||
|
finger came to choose from. The focus is gated on `(hover: none)`, the same
|
||||||
|
query the stylesheet uses for touch."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import lembas
|
||||||
|
|
||||||
|
js = (Path(lembas.__file__).parent / "web/static/js/ui.js").read_text(encoding="utf-8")
|
||||||
|
start = js.index("function open(picker)")
|
||||||
|
body = js[start : js.index("function applyFilter", start)]
|
||||||
|
assert "filter.focus()" in body, "the filter is no longer focused anywhere -- test is blind"
|
||||||
|
gated = r'if \(filter && !window\.matchMedia\("\(hover: none\)"\)\.matches\)\s*\{'
|
||||||
|
assert re.search(gated + r"\s*filter\.focus\(\)", body), (
|
||||||
|
"the filter is focused on open without asking whether this is a touchscreen"
|
||||||
|
)
|
||||||
|
|||||||
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
|
|||||||
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
||||||
assert 'path + "?v=" + VERSION' in source
|
assert 'path + "?v=" + VERSION' in source
|
||||||
assert "versioned(path)" in source
|
assert "versioned(path)" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_update_offer_asks_whether_this_page_is_older(client: TestClient, registered):
|
||||||
|
"""After a release the toast offered a reload on every page, including one
|
||||||
|
just fetched with Ctrl+Shift+R, and reloading never made it go away. It
|
||||||
|
fired whenever a worker was waiting. But a page loaded after the update
|
||||||
|
already IS the update: it comes from the network, and every asset it names
|
||||||
|
carries `?v=`. The worker that waits is nearly always the previous one,
|
||||||
|
still holding the tab, because a reload never lets it run out of pages.
|
||||||
|
|
||||||
|
So the offer, and the automatic reload when another tab accepts it, compare
|
||||||
|
the worker's release with the page's own. Driven under Node against stubs
|
||||||
|
before committing: 1.8.3 offered a reload to a current page and to a page
|
||||||
|
that another tab's Reload had just made current. This does neither, and
|
||||||
|
still offers it to a page from an older release.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import lembas
|
||||||
|
|
||||||
|
page = client.get("/chat").text
|
||||||
|
assert f"window.lembasRelease = \"{lembas.__version__}\";" in page
|
||||||
|
|
||||||
|
source = (STATIC_DIR / "js" / "app.js").read_text()
|
||||||
|
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
|
||||||
|
start = code.index("function watchForUpdate")
|
||||||
|
block = code[start : code.index("navigator.serviceWorker.ready.then(watchForUpdate)")]
|
||||||
|
# Both ways a waiting worker is found, and the controllerchange reload.
|
||||||
|
assert block.count("isNewerThanThisPage(") == 3
|
||||||
|
assert 'searchParams.get("v")' in code
|
||||||
|
|||||||
@@ -177,6 +177,48 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
|||||||
assert "scrollHeight > " in SOURCE
|
assert "scrollHeight > " in SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tab_switch_moves_only_the_container_that_scrolls():
|
||||||
|
"""Switching a tab on /admin/prompts lifted the whole shell 56px, with the
|
||||||
|
topbar gone off the top and a strip of bare background under everything.
|
||||||
|
|
||||||
|
Two halves, and either one alone is enough to bring it back. The prompt
|
||||||
|
cards' `.visually-hidden` labels are `position: absolute`. With no
|
||||||
|
positioned ancestor they were placed against the page and stretched the
|
||||||
|
document to 6771px behind an `overflow: hidden` root. And the handler used
|
||||||
|
`scrollIntoView`, which scrolls every ancestor that can scroll, the root
|
||||||
|
included. Measured in headless Chromium at 1640x930 before and after.
|
||||||
|
"""
|
||||||
|
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||||
|
start = app.index(".scroll-region,")
|
||||||
|
rule = app[start : app.index("}", start)]
|
||||||
|
assert ".admin-scroll" in rule
|
||||||
|
assert "position: relative" in rule
|
||||||
|
|
||||||
|
code = re.sub(r"/\*.*?\*/", "", SOURCE, flags=re.S)
|
||||||
|
start = code.index('closest(".tabs__bar")')
|
||||||
|
handler = code[start : code.index("})();", start)]
|
||||||
|
assert "scrollIntoView" not in handler
|
||||||
|
assert "outer.scrollTop" in handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tab_bar_edge_fade_is_covered_when_nothing_overflows():
|
||||||
|
"""The covers were as wide as the shadows and solid for only 40% of that,
|
||||||
|
so 60% of each shadow showed through with nothing to scroll to. On Shire
|
||||||
|
that was a grey sliver at both ends of every tab bar. A cover has to be
|
||||||
|
solid across the whole shadow. Measured in headless Chromium on
|
||||||
|
/admin/prompts and /settings in both themes before and after.
|
||||||
|
"""
|
||||||
|
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||||
|
start = admin.index("background-attachment: local, local, scroll, scroll")
|
||||||
|
block = admin[admin.rindex(".tabs__bar {", 0, start) : start]
|
||||||
|
cover = re.search(r"linear-gradient\(to right, var\(--bg\) (\d+)%, transparent\)", block)
|
||||||
|
shadow = re.search(r"var\(--scrim\), transparent ([\d.]+)rem", block)
|
||||||
|
sizes = re.search(r"background-size: ([\d.]+)rem 100%, [\d.]+rem 100%, ([\d.]+)rem 100%", block)
|
||||||
|
assert cover and shadow and sizes, "the edge-fade rule changed shape; re-check it by eye"
|
||||||
|
solid = float(sizes.group(1)) * int(cover.group(1)) / 100
|
||||||
|
assert solid >= float(shadow.group(1)) == float(sizes.group(2))
|
||||||
|
|
||||||
|
|
||||||
def test_the_two_ends_of_the_shell_stay_level():
|
def test_the_two_ends_of_the_shell_stay_level():
|
||||||
"""The sidebar footer and the composer sit either side of the same vertical
|
"""The sidebar footer and the composer sit either side of the same vertical
|
||||||
edge and are both content-sized, so without a common floor they end at
|
edge and are both content-sized, so without a common floor they end at
|
||||||
|
|||||||
Reference in New Issue
Block a user