From 790a8f21565fea180545cfd6fd7408bd8fc19674 Mon Sep 17 00:00:00 2001 From: Zhaoji Wang Date: Thu, 5 Nov 2026 13:49:07 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(agent-office):=20replace=20nat?= =?UTF-8?q?ive=20 elements in the Settings menu (#optLang, #optScene, #optQuality) with Magpie-style listbox app menus: - Turn #optLang, #optScene, and #optQuality into button triggers (aria-haspopup="listbox") carrying the shared inline SVG chevron (.chev) that rotates 180deg while open, removing the light/dark data-URI background-image select workarounds. - Pair each trigger with a fixed-positioned role="listbox" menu (#optLangList, #optSceneList, #optQualityList) clamped within the viewport (opening below by default and flipping above when bottom space is tight) so #settings's overflow:auto container never clips options. - Support keyboard navigation (ArrowUp/Down, Home, End, Enter, Space, Tab, and Escape closing the open picker first without closing #settings), capture-phase outside pointerdown, #settings scroll, and window resize dismissal, plus live label updates across all nine languages. - Update test/browser.mjs and agent-office.test.mjs, and re-stamp asset fingerprints in page/index.html. Closes #5 --- packages/agent-office/agent-office.test.mjs | 19 +- packages/agent-office/page/app.js | 237 ++++++++++++++++---- packages/agent-office/page/index.html | 29 ++- packages/agent-office/page/style.css | 18 +- packages/agent-office/test/browser.mjs | 25 ++- 5 files changed, 259 insertions(+), 69 deletions(-) diff --git a/packages/agent-office/agent-office.test.mjs b/packages/agent-office/agent-office.test.mjs index 079c2e132..9c9eaeb93 100644 --- a/packages/agent-office/agent-office.test.mjs +++ b/packages/agent-office/agent-office.test.mjs @@ -288,18 +288,27 @@ test("words, in English and Chinese", () => { expect(M.duration(130e3, "en")).toBe("2m 10s") }) -test("the Settings chevron and select dropdowns draw SVG chevrons, not the ⌄ character", () => { +test("the Settings chevron and Language, Scene and Quality pickers are SVG app menus, with no native + Language +
+ + +
- Scene - + Scene +
+ + +
- Quality - + Quality +
+ + +
Leave the office after diff --git a/packages/agent-office/page/style.css b/packages/agent-office/page/style.css index c86e5e30d..7344bce4b 100644 --- a/packages/agent-office/page/style.css +++ b/packages/agent-office/page/style.css @@ -58,8 +58,8 @@ h1 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 21px; .btn:hover { border-color: var(--muted); } .btn.on { background: #1f1f22; color: #fff; } .chev { display: block; flex: none; opacity: .55; transition: transform .16s ease, opacity .16s ease; } -.btn:hover .chev, #settingsBtn[aria-expanded="true"] .chev { opacity: .9; } -#settingsBtn[aria-expanded="true"] .chev { transform: rotate(180deg); } +.btn:hover .chev, .lang-select:hover .chev, #settingsBtn[aria-expanded="true"] .chev, .lang-select[aria-expanded="true"] .chev { opacity: .9; } +#settingsBtn[aria-expanded="true"] .chev, .lang-select[aria-expanded="true"] .chev { transform: rotate(180deg); } .menu-wrap { position: relative; } .menu { position: fixed; right: auto; top: 0; z-index: 50; width: 260px; padding: 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 30px rgba(0, 0, 0, .16); } .menu .row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 8px; cursor: pointer; } @@ -200,9 +200,17 @@ footer { padding-top: 12px; display: flex; flex-direction: column; gap: 8px; } .agent-filter .af input { margin: 0; accent-color: var(--accent); } .agent-filter .afn { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .agent-filter .afc { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 11.5px; } -.lang-select { width: 100%; margin-top: 6px; padding: 6px 28px 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' width='10' height='6'%3E%3Cpath d='M1.25 1.25L5 4.75L8.75 1.25' fill='none' stroke='%236e6e73' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center / 10px 6px; color: var(--fg); font: inherit; font-size: 13px; cursor: pointer; -webkit-appearance: none; appearance: none; } -.lang-select:hover { border-color: var(--muted); } -[data-theme="dark"] .lang-select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' width='10' height='6'%3E%3Cpath d='M1.25 1.25L5 4.75L8.75 1.25' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); } +.picker { position: relative; width: 100%; margin-top: 6px; } +.lang-select { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg); font: inherit; font-size: 13px; text-align: left; cursor: pointer; } +.lang-select:hover, .lang-select[aria-expanded="true"] { border-color: var(--muted); } +.picker-val { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picker-list { position: fixed; z-index: 60; display: flex; flex-direction: column; gap: 1px; max-width: min(320px, calc(100vw - 16px)); max-height: 240px; overflow: auto; padding: 4px; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 30px rgba(0, 0, 0, .2); outline: none; } +.picker-opt { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 6px 8px; border: 0; border-radius: 7px; background: none; color: var(--fg); font: inherit; font-size: 13px; text-align: left; cursor: pointer; } +.picker-opt:hover, .picker-opt.active { background: var(--card); } +.picker-opt[aria-selected="true"] { font-weight: 600; } +.picker-opt-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picker-check { flex: none; width: 12px; height: 12px; color: var(--accent); visibility: hidden; } +.picker-opt[aria-selected="true"] .picker-check { visibility: visible; } /* the list's three groups, as sticky-ish heading rows */ tr.group td { background: var(--bg); color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; padding: 12px 14px 5px; border-bottom: 1px solid var(--line); } diff --git a/packages/agent-office/test/browser.mjs b/packages/agent-office/test/browser.mjs index a10d89368..7e6285c9c 100644 --- a/packages/agent-office/test/browser.mjs +++ b/packages/agent-office/test/browser.mjs @@ -60,6 +60,15 @@ for(const engine of (process.env.OFFICE_BROWSERS||'chromium,webkit').split(',')) await frame.locator(id==='settings'?'#settingsBtn':'#todayBtn').click() const bounds=await frame.locator('#'+id).evaluate(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,w:innerWidth,h:innerHeight}}) assert(bounds.left>=0&&bounds.right<=bounds.w+1&&bounds.top>=0&&bounds.bottom<=bounds.h+1,`${engine} menu ${JSON.stringify(bounds)}`) + if(id==='settings') { + await frame.locator('#optQuality').scrollIntoViewIfNeeded() + await frame.locator('#optQuality').click() + const pb=await frame.locator('#optQualityList').evaluate(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,w:innerWidth,h:innerHeight}}) + assert(pb.left>=0&&pb.right<=pb.w+1&&pb.top>=0&&pb.bottom<=pb.h+1,`${engine} picker ${JSON.stringify(pb)}`) + await page.keyboard.press('Escape') + assert.equal(await frame.locator('#optQualityList').isVisible(),false) + assert.equal(await frame.locator('#settings').isVisible(),true) + } await page.screenshot({path:`${output}/${engine}-${width}-${id}.png`}) await frame.locator(id==='settings'?'#settingsBtn':'#todayBtn').click() } @@ -76,15 +85,23 @@ for(const engine of (process.env.OFFICE_BROWSERS||'chromium,webkit').split(',')) } if(width===1440) { await frame.locator('#settingsBtn').click() + const pick=async(btn,val)=>{await frame.locator(btn).click();await frame.locator(`${btn}List [data-value="${val}"]`).click()} + await pick('#optLang','en') + await frame.locator('#optQuality').focus() + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Home') + await page.keyboard.press('Enter') + assert.equal((await frame.locator('#optQuality .picker-val').textContent())?.trim(),'Low') + await pick('#optLang','auto') for(const scene of ['office','station','cafe','castle','pirate','graveyard','library','cyberlab','greenhouse','arctic','rainynight']) { - await frame.locator('#optScene').selectOption(scene) + await pick('#optScene',scene) for(const quality of ['low','medium','high']) { - await frame.locator('#optQuality').selectOption(quality) + await pick('#optQuality',quality) await page.waitForTimeout(80) } } - await frame.locator('#optScene').selectOption('office') - await frame.locator('#optQuality').selectOption('medium') + await pick('#optScene','office') + await pick('#optQuality','medium') await frame.locator('#settingsBtn').click() } await frame.locator('[data-mode="list"]').click()