gui: Providers, Library and Plugins no longer scroll 12-16px sideways in macOS 15 with scroll bars shown (#1236, pineog)
Library's and Plugins' sticky tab strip (.lib-head) and Providers' sticky Add bar (#view-providers .after-list) reached over the view's side padding by a negative margin (-12px, -16px in the window, the view's padding in the narrow browser page), so their boxes ran 12-16px past the view's content end edge. macOS 15's WebKit (Safari 18) takes a flex scroller's scrollable width as the furthest in-flow child's right edge plus max(0, margin-end), then adds the padding-end (RenderBlock::computeOverflow's includePaddingEnd, before WebKit 290159@main / bug 218579, which shipped in macOS 26): the negative margin is left out, the page is 12px (Providers) or 16px (Library, Plugins) wider than the view, and with classic scrollbars a draggable horizontal bar shows. That is the reporter's three pages and only those, at every text size and width. Chromium and macOS 26's WebKit count the margin, so neither scrolls. The strips' boxes now keep to the content box; a ::before paints the frosted strip / the fade over the padding (--bleed-l/-r), the hairline moves to it, and the pre-color-mix fallback too. Looks the same (before/after screenshots, light, window and browser page). New internal/gui/tests/view-no-sideways.test.cjs visits every page, en/zh/ ja/de, the window (body.mac) at 560 (windowMinW) and 1000px and the browser page at 360/440/1000px, Chromium with its scrollbars shown and WebKit, and checks both macOS 15 WebKit's sum, worked out from the layout, and the engine's own scrollWidth. Red before: 40 of 40 cases in each engine, on providers (12px, div.after-list), library and plugins (12/16px, div.lib-head) and no other page. Green after, 42/42. Also run, BROWSER=chromium and BROWSER=webkit (712 of 714 pass; the 2 are library-servers-bulk in Chromium, the same 2px flake on the old assets, #1322): click-scroll, gui-ja, gui-de, add-button, deprecated-subscriptions, plugin-market, provider-discovery, every library-*, plugin-* and provider(s)-* test. GOOS=linux and GOOS=windows go vet -tags nogui ./internal/gui pass. Not tried in a real macOS 15 WKWebView: this Mac runs macOS 26, whose WebKit has the fix; the rule was read from WebKit's source at 2025-01.
This commit is contained in:
1 parent
3856a9a6d8
commit
0d8f85e47b
3 files changed
+142
-9
No files matched your search
@@ -2164,8 +2164,12 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.fbrow .grow { flex: 1; }
|
||||
.fallback .mchips:empty { display: none; }
|
||||
.after-list { display: flex; align-items: center; gap: 8px; padding: 8px 4px 0; flex: none; }
|
||||
/* the Providers page's add button stays at the view's foot over a long list */
|
||||
#view-providers .after-list { position: sticky; bottom: -8px; z-index: 2; margin: 0 -12px -8px; padding: 14px 16px 12px; background: linear-gradient(to bottom, transparent, var(--bg) 12px); transition: opacity .18s ease, visibility 0s; }
|
||||
/* the Providers page's add button stays at the view's foot over a long list;
|
||||
its fade reaches 12px past each side from a ::before, the bar's own box
|
||||
in the content box (a negative margin-end was sideways scroll in macOS
|
||||
15's WebKit: see .lib-head, #1236) */
|
||||
#view-providers .after-list { position: sticky; bottom: -8px; z-index: 2; margin: 0 0 -8px; padding: 14px 4px 12px; transition: opacity .18s ease, visibility 0s; }
|
||||
#view-providers .after-list::before { content: ""; position: absolute; z-index: -1; inset: 0 -12px; pointer-events: none; background: linear-gradient(to bottom, transparent, var(--bg) 12px); }
|
||||
/* stepping aside it fades, then goes; back, it is there at once, so the
|
||||
focus can go to it as the sheet closes */
|
||||
#view-providers .after-list.away { opacity: 0; visibility: hidden; transition: opacity .18s ease, visibility 0s .18s; }
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
/* Library: instructions, MCP servers and skills for every agent. */
|
||||
#view-library { gap: 0; }
|
||||
/* The tabs stay at the top while the page scrolls under them, on a frosted
|
||||
strip as wide as the view; a hairline shows once something is beneath. */
|
||||
strip as wide as the view; a hairline shows once something is beneath.
|
||||
The strip's own box keeps to the view's content box and its ::before
|
||||
reaches out over the view's padding (--bleed-l/-r): a box that ran past
|
||||
the content's end edge, by a negative margin, was 16px of sideways
|
||||
scroll in macOS 15's WebKit, which leaves a negative margin-end out of
|
||||
a flex scroller's scrollable width (#1236). */
|
||||
.lib-head {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
display: flex; align-items: center; gap: 8px; flex: none;
|
||||
margin: 0 -12px; padding: 6px 12px 4px;
|
||||
margin: 0; padding: 6px 0 4px;
|
||||
--bleed-l: 12px; --bleed-r: 12px;
|
||||
}
|
||||
.lib-head::before {
|
||||
content: ""; position: absolute; z-index: -1; pointer-events: none;
|
||||
top: 0; bottom: 0; left: calc(-1 * var(--bleed-l)); right: calc(-1 * var(--bleed-r));
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
-webkit-backdrop-filter: saturate(1.6) blur(16px); backdrop-filter: saturate(1.6) blur(16px);
|
||||
box-shadow: 0 1px 0 transparent; transition: box-shadow .18s var(--ease);
|
||||
}
|
||||
/* the window's view is padded 2px at the top: the strip reaches over it */
|
||||
body.window .lib-head { top: -2px; margin: -2px -16px 0; padding: 8px 16px 4px; }
|
||||
body.window .lib-head { top: -2px; margin: -2px 0 0; padding: 8px 0 4px; --bleed-l: 16px; --bleed-r: 16px; }
|
||||
@media (max-width: 760px), (pointer: coarse) and (max-width: 1024px) {
|
||||
body.web .lib-head {
|
||||
flex-wrap: wrap;
|
||||
margin: -2px calc(-1 * var(--view-right)) 0 calc(-1 * var(--view-left));
|
||||
padding: 8px var(--view-right) 4px var(--view-left);
|
||||
margin: -2px 0 0; padding: 8px 0 4px;
|
||||
--bleed-l: var(--view-left); --bleed-r: var(--view-right);
|
||||
}
|
||||
body.web .lib-tabs { max-width: 100%; flex-wrap: wrap; }
|
||||
body.web .lib-more { flex: none; }
|
||||
}
|
||||
.lib-head.stuck { box-shadow: 0 1px 0 var(--line-2); }
|
||||
.lib-head.stuck::before { box-shadow: 0 1px 0 var(--line-2); }
|
||||
.lib-tabs .opt { height: 24px; padding: 0 12px; font-size: 12.5px; }
|
||||
.lib-more { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.lib-mini { display: inline-grid; place-items: center; color: var(--muted); }
|
||||
@@ -516,7 +526,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
|
||||
.lib-subhead .lib-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; }
|
||||
.lib-row.lib-subhead:hover { background: color-mix(in srgb, var(--card-2) 70%, var(--pill)); }
|
||||
/* no color-mix() before Safari 16.2 (#220): the sticky head stays opaque */
|
||||
@supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head { background: var(--bg); } }
|
||||
@supports not (color: color-mix(in srgb, red 50%, blue)) { .lib-head::before { background: var(--bg); } }
|
||||
/* skills picked to turn on or off together (#791): a box on each row and
|
||||
heading, and a bar that stays in view under the list while it's long */
|
||||
.lib-pickbox { margin: 0; flex: none; }
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// #1236 (pineog): on macOS 15 with scroll bars shown (a mouse, or "Always"),
|
||||
// Providers, Library and Plugins had a horizontal scrollbar that moved the
|
||||
// page 12-16px sideways, at every text size and window width. Each of those
|
||||
// pages has a strip that reaches over the view's side padding (Library's
|
||||
// and Plugins' sticky tabs, Providers' Add bar) by a negative margin, so
|
||||
// its box ran 12-16px past the content's end edge. macOS 15's WebKit
|
||||
// (before WebKit 290159@main / bug 218579, which shipped with macOS 26)
|
||||
// takes a flex scroller's scrollable width as the furthest in-flow child's
|
||||
// right edge plus max(0, its margin-end), then adds the padding-end: the
|
||||
// negative margin is left out and the page is that much wider than the view.
|
||||
// Chromium and today's WebKit count the margin, so neither scrolls; the
|
||||
// first check here is that older WebKit's sum, worked out from the layout,
|
||||
// for every page, and fails on the old strips. The second is the engine's
|
||||
// own scrollWidth, with the scrollbars shown. The strips' boxes now keep
|
||||
// to the content box and their ::before reaches over the padding.
|
||||
// Every page, en/zh/ja/de, the desktop window (body.mac) at 560px (its least
|
||||
// width, windowMinW) and 1000px and the browser page at 360/440/1000px, in
|
||||
// Chromium and WebKit. No backend: the API is faked.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const HOME = "/Users/a";
|
||||
const lib = {
|
||||
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
|
||||
agents: [{ id: "codex", name: "Codex", icon: "", skills: `${HOME}/.codex/skills`, mcp: `${HOME}/.codex/mcp.json` }],
|
||||
instructions: { agents: [], sets: [] }, foundServers: [], projects: [], foundSkills: [], skills: [], servers: [],
|
||||
};
|
||||
const rtk = { path: "", version: "", agents: [{ id: "claude", name: "Claude Code", icon: "", on: false }, { id: "codex", name: "Codex", icon: "", on: false }, { id: "pi", name: "Pi", icon: "", on: false }] };
|
||||
|
||||
function server(lang, web) {
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (d) => route.fulfill({ json: d });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:${web}};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/library") return json(lib);
|
||||
if (url.pathname === "/api/library/rtk") return json(rtk);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
// how far the shown view runs past its own width: in macOS 15's WebKit
|
||||
// (in-flow children's right edge + max(0, margin-end), then the padding-end)
|
||||
// and in the engine running the test
|
||||
const sideways = () => {
|
||||
const v = [...document.querySelectorAll("main.view")].find((m) => !m.hidden && m.getClientRects().length);
|
||||
if (!v) return null;
|
||||
const cs = getComputedStyle(v), box = v.getBoundingClientRect();
|
||||
const left = box.left + parseFloat(cs.borderLeftWidth) - v.scrollLeft;
|
||||
let right = 0, by = "";
|
||||
for (const c of v.children) {
|
||||
const ccs = getComputedStyle(c);
|
||||
if (ccs.position === "absolute" || ccs.position === "fixed" || !c.getClientRects().length) continue;
|
||||
const r = c.getBoundingClientRect().right - left + Math.max(0, parseFloat(ccs.marginRight));
|
||||
if (r > right) { right = r; by = c.tagName.toLowerCase() + (c.className ? "." + String(c.className).trim().split(/\s+/).join(".") : ""); }
|
||||
}
|
||||
return {
|
||||
id: v.id, flex: /flex|grid/.test(cs.display),
|
||||
old: Math.round((right + parseFloat(cs.paddingRight) - v.clientWidth) * 10) / 10, by,
|
||||
now: v.scrollWidth - v.clientWidth,
|
||||
};
|
||||
};
|
||||
|
||||
const VIEWS = ["agents", "providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"];
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": no page scrolls sideways (#1236)", async (t) => {
|
||||
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
|
||||
// the scrollbars drawn and taking their room, as with a mouse on a Mac
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium", ignoreDefaultArgs: ["--hide-scrollbars"] }));
|
||||
const errors = [];
|
||||
t.after(async () => {
|
||||
if (errors.length) console.log(errors);
|
||||
await browser.close();
|
||||
});
|
||||
for (const lang of ["en", "zh", "ja", "de"]) {
|
||||
for (const web of [false, true]) {
|
||||
for (const width of web ? [360, 440, 1000] : [560, 1000]) {
|
||||
await t.test(`${lang} ${web ? "browser" : "window"} ${width}px`, async () => {
|
||||
const ctx = await browser.newContext({ viewport: { width, height: 720 } });
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "rtk"); } catch {} });
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, web));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator("#view-agents").waitFor();
|
||||
const bad = [];
|
||||
for (const v of VIEWS) {
|
||||
if (v === "settings") await page.evaluate(() => document.querySelector("#prefs").click());
|
||||
else await page.evaluate((v) => document.querySelector(`#nav [data-view="${v}"]`).click(), v);
|
||||
await page.waitForFunction((v) => { const m = document.getElementById("view-" + v); return m && !m.hidden && m.children.length; }, v);
|
||||
await page.waitForTimeout(250);
|
||||
const s = await page.evaluate(sideways);
|
||||
assert.equal(s && s.id, "view-" + v, "the page is shown: " + v);
|
||||
if (s.flex && s.old > 0.5) bad.push(`${v}: ${s.old}px past the view in macOS 15's WebKit, by ${s.by}`);
|
||||
if (s.now > 0) bad.push(`${v}: scrolls ${s.now}px sideways here`);
|
||||
}
|
||||
await ctx.close();
|
||||
assert.deepEqual(bad, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user