diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 86e4b9ed..524cb295 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -201,7 +201,7 @@ body.window:not(.mac):not(.linux):not(.web) .brand { display: none; } /* Windows .seg { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: inline-flex; padding: 2px; border-radius: 8px; - background: var(--seg-track); + /* no grey track behind the tabs: only the open one's thumb */ } .seg > .thumb { border-radius: 6px; background: var(--seg-thumb); box-shadow: var(--seg-edge); } body.panel .seg { display: none; } @@ -329,28 +329,25 @@ body.window .row .ic { width: 22px; height: 22px; } .agent-fold .row { opacity: 0; transform: translateY(-10px) scale(.985); transform-origin: 50% 0; transition: opacity .22s ease, transform .32s cubic-bezier(.4, 0, .2, 1); transition-delay: calc((var(--n) - 1 - var(--i)) * 22ms); } .agent-fold.open .row { opacity: 1; transform: none; transition: opacity .42s ease, transform .56s cubic-bezier(.22, 1, .36, 1); transition-delay: calc(70ms + var(--i) * 48ms); } -/* the logo is the row's handle: hovered, it turns into a grip to drag the row - by, and a click opens the row's menu (Move up, Move down, Hide). It takes - no more room than the logo did. */ +/* the logo is the row's handle: drag it to move the row, and a click opens + the row's menu (Move up, Move down, Hide). It stays the logo under the + pointer; the grip that says the row can be taken hold of is in its margin. */ .ag-handle { display: inline-grid; place-items: center; flex: none; width: 24px; height: 24px; margin: -3px; padding: 0; border: 0; border-radius: 6px; background: none; color: inherit; cursor: grab; touch-action: none; transition: background-color .15s; } body.window .ag-handle { width: 28px; height: 28px; } -.ag-handle > .ic, .ag-handle > .grip { grid-area: 1 / 1; transition: opacity .15s, transform .2s cubic-bezier(.22, 1, .36, 1); } -.ag-handle > .grip { display: grid; place-items: center; color: var(--muted); opacity: 0; transform: scale(.8); } +.ag-handle > .ic { grid-area: 1 / 1; transition: opacity .15s; } .ag-handle:hover, .ag-handle.open { background: var(--pill); } .ag-handle:focus-visible { background: var(--pill); outline: 1.5px solid var(--fg-2); outline-offset: 0; } .agent-fold .ag-handle { cursor: default; } -#agents > .row .ag-handle:is(:hover, .open) > .ic, #agents > .row.dragging .ag-handle > .ic { opacity: 0; transform: scale(.8); } -#agents > .row .ag-handle:focus-visible > .ic { opacity: 0; transform: scale(.8); } -#agents > .row .ag-handle:is(:hover, .open) > .grip, #agents > .row.dragging .ag-handle > .grip { opacity: 1; transform: none; } -#agents > .row .ag-handle:focus-visible > .grip { opacity: 1; transform: none; } -/* the logo's grip comes up only on hover, so a row in the list also carries a - faint one in its left margin, always there, that says the row can be taken - hold of (#479): it is the handle's own, so a drag or a click on it does what - the logo does, and it takes no room from the row */ +/* a row in the list carries a grip in its left margin (#479), drawn only + while the row is under the pointer, dragged, or its handle has the + keyboard or its menu open: always there, it was noise down every row. It + is the handle's own, so a drag or a click on it does what the logo does, + and it takes no room from the row */ #agents > .row .ag-handle { position: relative; } -#agents > .row .ag-handle::before { content: ""; position: absolute; left: -6px; top: 50%; width: 5px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--grip-dot) .9px, transparent 1.2px) 0 0 / 3.5px 3.75px; --grip-dot: var(--faint); } +#agents > .row .ag-handle::before { content: ""; position: absolute; left: -6px; top: 50%; width: 5px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--muted) .9px, transparent 1.2px) 0 0 / 3.5px 3.75px; opacity: 0; transition: opacity .15s; } body.panel #agents > .row .ag-handle::before { left: -9px; } -#agents > .row:is(:hover, .dragging) .ag-handle::before { --grip-dot: var(--muted); } +#agents > .row:is(:hover, .dragging) .ag-handle::before, #agents > .row .ag-handle.open::before { opacity: 1; } +#agents > .row .ag-handle:focus-visible::before { opacity: 1; } /* one hidden by hand reads as put away, with its way back beside its name */ .agent-fold .row.put-away .ag-handle > .ic, .agent-fold .row.put-away .name { opacity: .6; } .agent-fold .row.put-away .who { display: flex; align-items: center; gap: 8px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 45fe384d..84988f2d 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -901,22 +901,20 @@ function setAgentHidden(a, hide) { } const ALT = /^Mac/.test(navigator.platform) ? "⌥" : "Alt+"; -const GRIP = "M6 4h.01M10 4h.01M6 8h.01M10 8h.01M6 12h.01M10 12h.01"; const EYE_OFF = "M6.6 3.7A6.9 6.9 0 0 1 8 3.5c3.75 0 6.25 4.5 6.25 4.5a11 11 0 0 1-1.5 2M4.4 4.4C2.7 5.55 1.75 8 1.75 8S4.25 12.5 8 12.5c1.2 0 2.25-.45 3.1-1.05M6.75 6.75a1.75 1.75 0 0 0 2.5 2.5M2 2l12 12"; const EYE = "M1.75 8S4.25 3.5 8 3.5 14.25 8 14.25 8 11.75 12.5 8 12.5 1.75 8 1.75 8ZM8 9.75a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5Z"; // agentHandle is the row's logo, which is also its handle: drag it to move // the row, click it (or right-click the row) for Move up, Move down and -// Hide; Alt+↑/↓ moves it from the keyboard. +// Hide; Alt+↑/↓ moves it from the keyboard. The grip that says so is drawn +// in the row's margin on hover (app.css), not over the logo. function agentHandle(a, row, inFold) { const b = el("button", "ag-handle"); b.type = "button"; b.setAttribute("aria-label", t("Arrange {agent}", { agent: a.name })); b.setAttribute("aria-haspopup", "menu"); b.title = inFold ? t(isHidden(a) ? "Show {agent}" : "Hide {agent}", { agent: a.name }) : t("Drag to reorder · click to move or hide"); - const grip = el("span", "grip"); - grip.append(svg(GRIP, 14, 2.4)); - b.append(icon(a.icon), grip); + b.append(icon(a.icon)); b.onkeydown = (e) => { if (inFold || !e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; e.preventDefault(); diff --git a/internal/gui/assets/plugins.css b/internal/gui/assets/plugins.css index a41877b1..23eafaa4 100644 --- a/internal/gui/assets/plugins.css +++ b/internal/gui/assets/plugins.css @@ -88,7 +88,6 @@ .pm-act.done:hover:not(:disabled) { background: var(--green-soft); color: var(--green); } .pm-act.busy { color: var(--muted); font-weight: 500; } .pm-act:disabled:not(.busy):not(.done) { opacity: .55; } -.pm-card > .pm-act.move { height: auto; min-height: 26px; padding: 4px 12px; white-space: normal; text-align: center; } .pm-act .spin { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid currentColor; border-right-color: transparent; animation: spin .7s linear infinite; } /* loading: cards drawn as grey bars */ diff --git a/internal/gui/assets/plugins.js b/internal/gui/assets/plugins.js index acaf2eb2..784dec40 100644 --- a/internal/gui/assets/plugins.js +++ b/internal/gui/assets/plugins.js @@ -201,9 +201,13 @@ } } const moveButton = (b, c, here) => { - b.classList.add("go", "move"); - b.textContent = here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts }); - b.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name }); + // one word beside the name, as Install is: the card already says whose + // accounts, the title says how many and what it does (a whole sentence + // was a solid bar under the card that broke onto two lines) + b.classList.add("get", "move"); + b.textContent = t("Move"); + b.title = (here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts })) + + " — " + t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name }); b.disabled = busy.size > 0; b.onclick = (ev) => { ev.stopPropagation(); move(c); }; return b; @@ -299,11 +303,7 @@ by.append(v); } else by.append(el("span", "", l.npm?.publisher || l.package)); who.append(by); - const act = actionFor(l.package, l.name, l); - // a move says whose accounts: too long for the top row, it has one of its own - const own = act.classList.contains("move"); - top.append(logo(l.icon), who); - if (!own) top.append(act); + top.append(logo(l.icon), who, actionFor(l.package, l.name, l)); const sum = el("p", "pm-sum", summary(l)); const meta = el("div", "pm-meta"); if (l.npm?.weekly) { @@ -322,7 +322,6 @@ meta.append(b); } c.append(top, sum, meta); - if (own) c.append(act); c.onclick = () => detail(l); c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } }; return c; diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index ac95557b..d05619f8 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -104,10 +104,10 @@ code the browser page shows; an API-key way opens the account. English and Chinese, Chromium and WebKit, with the API faked. `plugin-move-overflow.test.cjs` checks a Plugins card that offers a -built-in's accounts ("Move my 1 Grok (SuperGrok) account"): the button has a -row of its own at the card's foot, so it stays inside the card and the -plugin's name and "magpie community" aren't cut, in a three-, two- and -one-column window. English and Chinese, Chromium and WebKit, with the API +built-in's accounts: the button is one word beside the name, as Install is +("Move", its title saying whose accounts and how many), on one line, and it +stays inside the card and the plugin's name and "magpie community" aren't +cut, in a three-, two- and one-column window. English and Chinese, Chromium and WebKit, with the API faked. `plugin-key-hint.test.cjs` checks a plugin's API-key way: its label titles @@ -1291,9 +1291,10 @@ node --test internal/gui/tests/seg-contrast.test.cjs ``` `agent-grip.test.cjs` checks that hiding an agent can be found without a -right-click (#479): with the pointer away, every row in the Agents list draws -a faint grip in its left margin, beside its logo; dragging the grip moves the -row, and clicking it opens the row's menu, whose Hide puts the row in the fold +right-click (#479): a row in the Agents list under the pointer draws a grip +in its left margin, beside its logo, and no row does with the pointer away; +the logo stays the logo on hover (no second grip over it); dragging the grip +moves the row, and clicking it opens the row's menu, whose Hide puts the row in the fold at the foot of the list. Opened, the fold's Show button is there without the pointer on its row, and brings the row back; no click moves the page. It runs in the window and the tray panel, in Chromium and WebKit, in English and diff --git a/internal/gui/tests/agent-grip.test.cjs b/internal/gui/tests/agent-grip.test.cjs index 7c48ced3..3c6f44fb 100644 --- a/internal/gui/tests/agent-grip.test.cjs +++ b/internal/gui/tests/agent-grip.test.cjs @@ -1,12 +1,13 @@ // Run with Node's test runner and Playwright on the module path; see README.md. -// #479: an agent row's handle (its logo, which turns into a grip on hover; -// a click opens Move up, Move down, Hide) showed nothing until the pointer -// was on it, so hiding an agent was found only by right-clicking, and a -// hidden one's way back, the Show button in the fold at the foot of the -// list, came up only with the pointer on its row. Now every row in the list -// carries a faint grip in its left margin, there with the pointer away: it is -// the handle's own, so dragging it moves the row and clicking it opens the -// menu. Hidden from that menu, the row goes into the fold, whose Show button +// #479: an agent row's handle (its logo; a click opens Move up, Move down, +// Hide) showed nothing until the pointer was on it, so hiding an agent was +// found only by right-clicking, and a hidden one's way back, the Show button +// in the fold at the foot of the list, came up only with the pointer on its +// row. Every row in the list then carried a faint grip in its left margin, +// always there; down every row that was noise, and the logo also turned into +// a second grip on hover. Now the margin grip is drawn only with the pointer +// on its row, and the logo stays the logo: the grip is the handle's own, so +// dragging it moves the row and clicking it opens the menu. Hidden from that menu, the row goes into the fold, whose Show button // is there without the pointer on it and brings the row back. No click moves // the page. In the window and the tray panel, Chromium and WebKit, English // and Chinese; no backend, the API is faked here. @@ -46,7 +47,7 @@ function fixture(lang) { } const order = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => r.dataset.id)); -// the faint grip of each row in the list, as drawn with the pointer away +// the margin grip of each row in the list, where it is drawn const grips = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => { const h = r.querySelector(".ag-handle"), s = getComputedStyle(h, "::before"), hb = h.getBoundingClientRect(), rb = r.getBoundingClientRect(); const left = hb.left + parseFloat(s.left), w = parseFloat(s.width), ht = parseFloat(s.height); @@ -76,14 +77,28 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator("#agents > .row.agent").nth(2).waitFor(); await page.mouse.move(1, 1); - // with the pointer away, every row's grip is drawn, in its margin + // with the pointer away no row's grip is drawn; it is there, in the + // row's margin, for the row under the pointer, and the logo stays as + // it was + const shown = (page) => page.locator("#agents > .row.agent").evaluateAll((rs) => rs.map((r) => getComputedStyle(r.querySelector(".ag-handle"), "::before").opacity)); + await page.waitForFunction(() => [...document.querySelectorAll("#agents > .row.agent .ag-handle")].every((h) => getComputedStyle(h, "::before").opacity === "0")); let g = await grips(page); for (const x of g) { - assert.notEqual(x.content, "none", `${x.id} has no grip with the pointer away`); + assert.notEqual(x.content, "none", `${x.id} has no grip`); assert.match(x.image, /gradient/, `${x.id}'s grip draws nothing`); - assert(x.opacity > 0 && x.visibility === "visible" && x.w >= 4 && x.ht >= 8, `${x.id}'s grip can't be seen: ${JSON.stringify(x)}`); + assert(x.visibility === "visible" && x.w >= 4 && x.ht >= 8, `${x.id}'s grip can't be seen: ${JSON.stringify(x)}`); assert(x.inRow, `${x.id}'s grip is off its row or over its logo: ${JSON.stringify(x)}`); } + assert.equal(await page.locator("#agents .ag-handle .grip").count(), 0, "the logo still has a grip of its own"); + const row1 = page.locator("#agents > .row.agent").nth(1); + await row1.locator(".name").hover(); + await page.waitForFunction(() => { + const rs = [...document.querySelectorAll("#agents > .row.agent .ag-handle")].map((h) => getComputedStyle(h, "::before").opacity); + return rs[1] === "1" && rs.every((o, i) => i === 1 || o === "0"); + }); + await row1.locator(".ag-handle").hover(); + assert.equal(await row1.locator(".ag-handle > .ic").evaluate((i) => getComputedStyle(i).opacity), "1", "the logo changes on hover"); + assert.equal((await shown(page))[1], "1"); // dragging by the grip moves the row const before = await order(page); diff --git a/internal/gui/tests/plugin-move-overflow.test.cjs b/internal/gui/tests/plugin-move-overflow.test.cjs index 8b3e1b20..ddd0e86c 100644 --- a/internal/gui/tests/plugin-move-overflow.test.cjs +++ b/internal/gui/tests/plugin-move-overflow.test.cjs @@ -2,8 +2,10 @@ // A Plugins card that offers a built-in's accounts keeps its button inside // the card: "Move my Grok (SuperGrok) accounts (1)" ran past the card's right // edge, and Kiro's shorter one squeezed its name to "K…" and cut "magpie -// community". The button now has a row of its own at the card's foot and -// says how many accounts it moves ("Move my 1 Grok (SuperGrok) account"). +// community". It then had a solid row of its own at the card's foot, which +// broke onto two lines ("Move my 2 Command Code Plan accounts"); it is now +// one word beside the name, as Install is ("Move"), its title saying whose +// accounts and how many, and stays on one line. // Every card stays whole, its name and byline uncut, in a three-, two- and // one-column window. In English and Chinese; the API is faked here. const assert = require("node:assert/strict"); @@ -46,7 +48,7 @@ function serve(lang) { }; } -const card = { en: "Move my 1 Grok (SuperGrok) account", zh: "迁移我的 1 个 Grok (SuperGrok) 账号" }; +const card = { en: "Move", zh: "迁移" }; const launch = (engine) => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }); for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -61,7 +63,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", page.on("pageerror", (e) => errors.push(e.message)); await page.route("**/*", serve(lang)); await page.goto("http://magpie.test/?view=plugins"); - await page.locator("#view-plugins button", { hasText: card[lang] }).waitFor(); + await page.locator("#view-plugins button", { hasText: card[lang] }).first().waitFor(); if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `plugin-move-overflow-${engine}-${lang}-${width}.png`) }); const cards = await page.locator("#view-plugins .pm-card").evaluateAll((cs) => cs.map((c) => { @@ -74,6 +76,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", return { name: c.querySelector(".pm-name b")?.textContent, out, cut }; })); assert.ok(cards.length >= listings.length, "the market's cards aren't there"); + // the move is one line, in the card's top row + const moves = await page.locator("#view-plugins .pm-card .pm-act.move").evaluateAll((bs) => bs.map((b) => [b.parentElement.className, Math.round(b.getBoundingClientRect().height)])); + assert.ok(moves.length >= 1); + for (const [row, h] of moves) { assert.equal(row, "pm-top"); assert.equal(h, 26, "one line"); } const lefts = await page.locator("#view-plugins .pm-card").evaluateAll((cs) => new Set(cs.map((c) => Math.round(c.getBoundingClientRect().left))).size); assert.equal(lefts, cols, `not ${cols} columns at ${width}px`); const wrong = cards.flatMap((c) => [...(c.out.length ? [`${c.name}: runs past its card`] : []), ...c.cut.map((x) => `${c.name}: "${x}" is cut`)]); diff --git a/internal/gui/tests/plugin-move.test.cjs b/internal/gui/tests/plugin-move.test.cjs index a7c5985e..a6f5078e 100644 --- a/internal/gui/tests/plugin-move.test.cjs +++ b/internal/gui/tests/plugin-move.test.cjs @@ -75,12 +75,12 @@ const L = { move: "Move to the plugin", busy: "Installing the plugin and checking each account…", again: "Try again", back: "Use the built-in again", failed: "It stays built-in: magpie couldn't reach npm to install the plugin. Check the network or proxy, then try again.", onPlugin: "The community Zed plugin", builtin: "magpie's built-in · or the community Zed plugin", done: "Zed now runs on its plugin — 2 accounts, 1 model.", - subs: "Subscriptions", card: "Move my 2 Zed accounts", own: "Zed itself stays signed in as it is." }, + subs: "Subscriptions", card: "Move", own: "Zed itself stays signed in as it is." }, zh: { runs: "运行方式", line: "Zed 可以改由社区插件运行,账号不变。", look: "查看", move: "迁移到插件", busy: "正在安装插件并逐个检查账号…", again: "重试", back: "改回内置", failed: "仍使用内置:无法连接 npm 安装插件,请检查网络或代理后重试。", onPlugin: "社区 Zed 插件", builtin: "magpie 内置 · 也可改用社区 Zed 插件", done: "Zed 现在由插件运行——2 个账号,1 个模型。", - subs: "订阅", card: "迁移我的 2 个 Zed 账号", own: "Zed 本身的登录保持不变。" }, + subs: "订阅", card: "迁移", own: "Zed 本身的登录保持不变。" }, }; const launch = (engine) => engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" });