* fix(ui-primitives): keep tall menus inside the mobile viewport * fix(client): anchor model picker with Floating UI Use Floating UI for fixed model-menu placement and automatic viewport updates. Preserve top-end alignment and defer automatic focus until placement is ready. Guard asynchronous results across updates and menu teardown. * fix(client): preserve menu blur under pinned group titles Keep the current provider title outside the native scroll viewport so model rows cannot bleed through during fast scrolling. Preserve the enclosing menu fill and backdrop filter, and update both model pickers, lifecycle tests and ARIA goldens. Validation: doc-sync, full build, final type checks, focused lint and all 12 model-picker browser checks pass. The full unit run has 42,597 passing tests and four failures in unchanged gateway, third-party- notices and Inspector tests. The Inspector case passes in isolation. * fix(client): stabilize floating panels during browser zoom * fix(client): keep portal menus aligned during Safari zoom * fix(client): commit floating menu positions before paint * fix(client): unify floating panel positioning * fix(client): validate grouped menu headings * fix(client): hide inactive pinned menu headings * test(client): await floating menu layout across consumers * test(ui-chat): await folded reply menu positioning * test(client): align current consumers with asynchronous panel layout * fix(client): prevent mobile model search focus zoom * refactor(client): inject mobile device state from layout * fix(client): keep anchored panel layout synchronous * fix(client): address panel review coverage and documentation * fix(client): keep viewport changes focused on iOS zoom * fix(client): cover anchored panel lifecycle and scrollbar colors * fix(client): preserve zoomed model menu scrolling and alignment * fix(client): hide empty model menus and stabilize viewport pan tests * test(web): use native wheel input for zoomed viewport panning * fix(client): keep automation pickers scrollable when zoomed * fix(client): allow horizontal gestures across clock columns * fix(client): ellipsize overflowing floating-panel labels * test(web): make clock overflow fixture independent of system fonts --------- Co-authored-by: Zrs000 <[email protected]>
205 lines
11 KiB
TypeScript
205 lines
11 KiB
TypeScript
/** Account and Session menus share material and keep macOS backings aligned and scoped. */
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import { chromium, webkit, type Locator } from 'playwright'
|
|
import sharp from 'sharp'
|
|
import { expect, it, onTestFinished } from 'vitest'
|
|
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts'
|
|
|
|
const expected = fileURLToPath(new URL('./expected/menu-material', import.meta.url))
|
|
const seed = new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)
|
|
|
|
it('keeps the view options menu anchored and dismissible through WebKit window resizing', async () => {
|
|
const scaffold = await launchWebScaffold({})
|
|
onTestFinished(() => scaffold.close())
|
|
const browser = await webkit.launch()
|
|
onTestFinished(() => browser.close())
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' })
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
const anchor = page.getByRole('button', { name: 'View options', exact: true })
|
|
await anchor.click()
|
|
const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }) })
|
|
await menu.waitFor()
|
|
for (const viewport of [{ width: 1440, height: 1000 }, { width: 1200, height: 700 }]) {
|
|
await page.setViewportSize(viewport)
|
|
await expect.poll(async () => {
|
|
const trigger = await anchor.boundingBox()
|
|
const panel = await menu.boundingBox()
|
|
if (trigger === null || panel === null) return null
|
|
return {
|
|
// The menu can be wider than the sidebar, so its left margin takes precedence over end alignment.
|
|
horizontalOffset: Math.round(panel.x - Math.max(12, trigger.x + trigger.width - panel.width)),
|
|
topGap: Math.round(panel.y - trigger.y - trigger.height),
|
|
insideViewport: panel.x >= 12 && panel.y >= 12
|
|
&& panel.x + panel.width <= viewport.width - 12
|
|
&& panel.y + panel.height <= viewport.height - 12,
|
|
}
|
|
}).toEqual({ horizontalOffset: 0, topGap: 4, insideViewport: true })
|
|
}
|
|
await page.keyboard.press('Escape')
|
|
await menu.waitFor({ state: 'hidden' })
|
|
await anchor.click()
|
|
await menu.waitFor()
|
|
await page.mouse.click(800, 500)
|
|
await menu.waitFor({ state: 'hidden' })
|
|
await anchor.click()
|
|
await page.getByRole('menuitem', { name: 'In one list', exact: true }).click()
|
|
await menu.waitFor({ state: 'hidden' })
|
|
})
|
|
|
|
async function material(menu: Locator) {
|
|
return menu.evaluate((node) => {
|
|
const style = getComputedStyle(node)
|
|
const background = getComputedStyle(node.querySelector(':scope > [aria-hidden="true"]')!)
|
|
return { fill: background.backgroundColor, blur: background.backdropFilter, radius: style.borderRadius, shadow: style.boxShadow }
|
|
})
|
|
}
|
|
|
|
it('shares menu transparency and blur across palettes and follows native menu bounds', async () => {
|
|
const scaffold = await launchWebScaffold({})
|
|
onTestFinished(() => scaffold.close())
|
|
await seedSession(scaffold, await readFile(seed, 'utf8'), 'menu-material-session')
|
|
const browser = await chromium.launch()
|
|
onTestFinished(() => browser.close())
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' })
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
|
|
})
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
const account = page.getByRole('button', { name: 'Account menu', exact: true })
|
|
await account.waitFor()
|
|
|
|
const results: Record<string, Awaited<ReturnType<typeof material>>> = {}
|
|
const masks: Record<string, { fill: string; blur: string }> = {}
|
|
for (const platform of ['web', 'darwin']) {
|
|
for (const dark of [false, true]) {
|
|
await page.evaluate(({ platform, dark }) => {
|
|
if (platform === 'darwin') document.documentElement.dataset.platform = platform
|
|
else delete document.documentElement.dataset.platform
|
|
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
|
}, { platform, dark })
|
|
const overlayFill = await page.evaluate(() => {
|
|
const overlay = document.createElement('div')
|
|
overlay.style.background = 'var(--dsw-specific-menu)'
|
|
document.body.appendChild(overlay)
|
|
try { return getComputedStyle(overlay).backgroundColor } finally { overlay.remove() }
|
|
})
|
|
expect(overlayFill).toBe(platform === 'darwin'
|
|
? dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)'
|
|
: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)')
|
|
await account.click()
|
|
const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Settings', exact: true }) })
|
|
await menu.waitFor()
|
|
const appearance = await material(menu)
|
|
expect(appearance).toMatchObject({
|
|
fill: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)',
|
|
blur: 'blur(40px) saturate(1.5)',
|
|
})
|
|
expect(appearance.shadow).toContain(`${dark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.04)'} 0px 0px 0px 0.5px`)
|
|
results[`${platform}-${dark ? 'dark' : 'light'}`] = appearance
|
|
const backing = page.locator('[data-menu-backing]')
|
|
if (platform === 'darwin') {
|
|
await backing.waitFor({ state: 'visible' })
|
|
for (const height of [950, 1000]) {
|
|
await page.setViewportSize({ width: 1440, height })
|
|
await expect.poll(async () => {
|
|
const card = await menu.boundingBox()
|
|
const base = await backing.boundingBox()
|
|
return card !== null && base !== null
|
|
&& Math.abs(card.x - base.x) < 1 && Math.abs(card.y - base.y) < 1
|
|
&& Math.abs(card.width - base.width) < 1 && Math.abs(card.height - base.height) < 1
|
|
}).toBe(true)
|
|
}
|
|
expect(await backing.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('none')
|
|
} else {
|
|
expect(await backing.isVisible()).toBe(false)
|
|
}
|
|
await page.keyboard.press('Escape')
|
|
await expect.poll(() => backing.count()).toBe(0)
|
|
await account.click()
|
|
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
|
|
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
await settings.waitFor()
|
|
const mask = await settings.locator('..').locator(':scope > [aria-hidden="true"]').evaluate((node) => {
|
|
const style = getComputedStyle(node)
|
|
return { fill: style.backgroundColor, blur: style.backdropFilter }
|
|
})
|
|
expect(mask).toEqual({ fill: dark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.24)', blur: 'none' })
|
|
masks[`${platform}-${dark ? 'dark' : 'light'}`] = mask
|
|
await page.keyboard.press('Escape')
|
|
await settings.waitFor({ state: 'hidden' })
|
|
}
|
|
}
|
|
await compareOrRefreshGolden(join(expected, 'materials.expected.json'), JSON.stringify(results, null, 2), webSnapshotMode())
|
|
await compareOrRefreshGolden(join(expected, 'modal-masks.expected.json'), JSON.stringify(masks, null, 2), webSnapshotMode())
|
|
|
|
const ungrouped = page.getByText('Ungrouped', { exact: true })
|
|
const group = ungrouped.locator('xpath=ancestor::*[@aria-expanded][1]')
|
|
await group.waitFor()
|
|
if (await group.getAttribute('aria-expanded') !== 'true') await ungrouped.click()
|
|
const action = page.locator('button[aria-label^="Session actions for "]').first()
|
|
await action.locator('xpath=ancestor::*[@role="treeitem"][1]').hover()
|
|
await action.click()
|
|
const sessionMenu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Rename', exact: true }) })
|
|
await sessionMenu.waitFor()
|
|
expect(await material(sessionMenu)).toEqual(results['darwin-dark'])
|
|
await compareOrRefreshGolden(join(expected, 'session-menu.expected.md'),
|
|
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
|
|
await page.keyboard.press('Escape')
|
|
await expect.poll(() => page.locator('[data-menu-backing]').count()).toBe(0)
|
|
|
|
await action.locator('xpath=ancestor::*[@role="treeitem"][1]').click({ button: 'right' })
|
|
await sessionMenu.waitFor()
|
|
const popup = page.locator('[data-context-menu]')
|
|
const stripes = await page.evaluateHandle(() => {
|
|
const element = document.createElement('div')
|
|
element.style.cssText = 'position:fixed;inset:0;z-index:1099;pointer-events:none;background:repeating-linear-gradient(90deg,#000 0 4px,#fff 4px 8px)'
|
|
document.body.appendChild(element)
|
|
return element
|
|
})
|
|
try {
|
|
// Hide text while preserving layout so pixel contrast measures only the material.
|
|
await popup.evaluate((node) => {
|
|
for (const child of node.children) {
|
|
if (child instanceof HTMLElement && child.getAttribute('aria-hidden') !== 'true') child.style.visibility = 'hidden'
|
|
}
|
|
})
|
|
const box = (await popup.boundingBox())!
|
|
const clip = { x: Math.floor(box.x + box.width / 2 - 32), y: Math.floor(box.y + box.height / 2 - 16), width: 64, height: 32 }
|
|
for (const phase of ['enter', 'dismiss', 'replace']) {
|
|
await popup.evaluate((node, phase) => {
|
|
node.removeAttribute('data-menu-phase')
|
|
void (node as HTMLElement).offsetWidth
|
|
node.setAttribute('data-menu-phase', phase)
|
|
for (const animation of node.getAnimations({ subtree: true })) {
|
|
animation.pause()
|
|
animation.currentTime = Number(animation.effect!.getComputedTiming().duration) / 2
|
|
}
|
|
}, phase)
|
|
const stats = await sharp(await page.screenshot({ clip })).stats()
|
|
// During a fade the unpainted fraction exposes stripes; a working blur still
|
|
// removes substantially more contrast than opacity without backdrop sampling.
|
|
expect(stats.channels[0]!.stdev, phase).toBeLessThan(90)
|
|
}
|
|
await popup.evaluate((node) => {
|
|
node.setAttribute('data-menu-phase', 'enter')
|
|
for (const animation of node.getAnimations({ subtree: true })) animation.finish()
|
|
})
|
|
const settled = await sharp(await page.screenshot({ clip })).stats()
|
|
expect(settled.channels[0]!.stdev).toBeLessThan(3)
|
|
await popup.evaluate((node) => {
|
|
node.style.opacity = '0.999'
|
|
// The negative control measures backdrop isolation independently of the theme's fill alpha.
|
|
const material = node.querySelector<HTMLElement>(':scope > [aria-hidden="true"]')!
|
|
material.style.backgroundColor = 'transparent'
|
|
})
|
|
const isolated = await sharp(await page.screenshot({ clip })).stats()
|
|
expect(isolated.channels[0]!.stdev).toBeGreaterThan(90)
|
|
} finally {
|
|
await stripes.evaluate((node) => { node.remove() })
|
|
await stripes.dispose()
|
|
await page.keyboard.press('Escape')
|
|
}
|
|
})
|