diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 873ba051..626fb60b 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 1f9198cc..94350793 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -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; } diff --git a/internal/gui/tests/view-no-sideways.test.cjs b/internal/gui/tests/view-no-sideways.test.cjs new file mode 100644 index 00000000..9ee983b1 --- /dev/null +++ b/internal/gui/tests/view-no-sideways.test.cjs @@ -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, []); + }); + } + } + } + }); +}