diff --git a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md index f328fbfa83..956ddbda34 100644 --- a/apps/web/tests/expected/plugin-manager/header-responsive.expected.md +++ b/apps/web/tests/expected/plugin-manager/header-responsive.expected.md @@ -1,18 +1,18 @@ # Plugin header responsive layout -en 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained -en 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 1680px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 768px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 520px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 440px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 390px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 430px: actions single-line and contained; heading separate; information follows subtitle; focus retained -zh 1024px: actions single-line and contained; heading separate; information follows subtitle; focus retained +en 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +en 768px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 520px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 768px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 520px: actions beside the heading, single-line; contained; information follows subtitle; focus retained +zh 440px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained +zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained Add menu dismisses on Escape and returns focus in both locales. diff --git a/apps/web/tests/expected/schedule-header.expected.md b/apps/web/tests/expected/schedule-header.expected.md index 7ac291a79d..96bff76bee 100644 --- a/apps/web/tests/expected/schedule-header.expected.md +++ b/apps/web/tests/expected/schedule-header.expected.md @@ -1,17 +1,17 @@ # Automation tasks header -en 1680px: title and New single-line; no overlap or overflow; New retains focus -en 800px: title and New single-line; no overlap or overflow; New retains focus -en 520px: title and New single-line; no overlap or overflow; New retains focus -en 440px: title and New single-line; no overlap or overflow; New retains focus -en 390px: title and New single-line; no overlap or overflow; New retains focus -en 430px: title and New single-line; no overlap or overflow; New retains focus -en 1024px: title and New single-line; no overlap or overflow; New retains focus +en 1680px: title and New single-line, New beside the title; contained; New retains focus +en 800px: title and New single-line, New beside the title; contained; New retains focus +en 520px: title and New single-line, New beside the title; contained; New retains focus +en 440px: title and New single-line, New beside the title; contained; New retains focus +en 390px: title and New single-line, New beside the title; contained; New retains focus +en 430px: title and New single-line, New beside the title; contained; New retains focus +en 1024px: title and New single-line, New beside the title; contained; New retains focus en 509px with sidebar expanded: title single-line; New moves below the title -zh 1680px: title and New single-line; no overlap or overflow; New retains focus -zh 800px: title and New single-line; no overlap or overflow; New retains focus -zh 520px: title and New single-line; no overlap or overflow; New retains focus -zh 440px: title and New single-line; no overlap or overflow; New retains focus -zh 390px: title and New single-line; no overlap or overflow; New retains focus -zh 430px: title and New single-line; no overlap or overflow; New retains focus -zh 1024px: title and New single-line; no overlap or overflow; New retains focus +zh 1680px: title and New single-line, New beside the title; contained; New retains focus +zh 800px: title and New single-line, New beside the title; contained; New retains focus +zh 520px: title and New single-line, New beside the title; contained; New retains focus +zh 440px: title and New single-line, New beside the title; contained; New retains focus +zh 390px: title and New single-line, New beside the title; contained; New retains focus +zh 430px: title and New single-line, New beside the title; contained; New retains focus +zh 1024px: title and New single-line, New beside the title; contained; New retains focus diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 20a45f3abe..15937b97ff 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -98,80 +98,98 @@ describe('web e2e: plugin manager', () => { it('keeps plugin header actions on one line as the available width changes', async () => { const facts: string[] = [] - for (const language of ['en', 'zh'] as const) { - const context = await browser.newContext({ - viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, - reducedMotion: 'reduce', - }) - try { - const probe = await context.newPage() - const consoleWatch = watchConsole(probe) - await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - const title = language === 'en' ? 'Plugins' : '插件' - await probe.getByRole('button', { name: title, exact: true }).click() - const panel = probe.locator('[data-plugin-panel]') - const header = panel.locator(':scope > header') - await header.getByRole('heading', { name: title, exact: true }).waitFor() - await probe.evaluate(() => document.fonts.ready) - const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) - await expect.poll(() => add.isEnabled()).toBe(true) - await add.focus() - for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { - await probe.setViewportSize({ width, height: 1000 }) - const measure = () => header.evaluate((element) => { - const heading = element.firstElementChild! - const actions = element.lastElementChild! - const headingRect = heading.getBoundingClientRect() - const actionRect = actions.getBoundingClientRect() - const headerRect = element.getBoundingClientRect() - const subtitle = heading.querySelector('h1 + div > span')! - const range = document.createRange() - range.selectNodeContents(subtitle) - const textRects = Array.from(range.getClientRects()) - const lastText = textRects.at(-1)! - const info = heading.querySelector('button')!.getBoundingClientRect() - const buttons = Array.from(actions.querySelectorAll('button')) - const singleLine = buttons.every((button) => { - const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) - const tops: number[] = [] - while (walker.nextNode()) { - if (!walker.currentNode.textContent?.trim()) continue - range.selectNodeContents(walker.currentNode) - for (const rect of range.getClientRects()) tops.push(rect.top) + // A dedicated scaffold keeps the measured header deterministic: an install + // task left by an earlier test swaps the toolbar's add control for the + // view-installation button, which moves the wrap threshold. + const headerScaffold = await launchWebScaffold() + try { + // The widths where the header must have wrapped or not are pinned; the + // remaining widths assert only that the action group is placed on one side + // of the heading, and the golden records which. + const PINNED_PLACEMENT: Record = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' } + for (const language of ['en', 'zh'] as const) { + const context = await browser.newContext({ + viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE, + reducedMotion: 'reduce', + }) + try { + const probe = await context.newPage() + const consoleWatch = watchConsole(probe) + await probe.goto(headerScaffold.authenticatedUrl, { waitUntil: 'load' }) + const title = language === 'en' ? 'Plugins' : '插件' + await probe.getByRole('button', { name: title, exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const header = panel.locator(':scope > header') + await header.getByRole('heading', { name: title, exact: true }).waitFor() + await probe.evaluate(() => document.fonts.ready) + const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true }) + await expect.poll(() => add.isEnabled()).toBe(true) + await add.focus() + for (const width of [1680, 768, 520, 440, 390, 430, 1024]) { + await probe.setViewportSize({ width, height: 1000 }) + const measure = () => header.evaluate((element) => { + const heading = element.firstElementChild! + const actions = element.lastElementChild! + const headingRect = heading.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerRect = element.getBoundingClientRect() + const subtitle = heading.querySelector('h1 + div > span')! + const range = document.createRange() + range.selectNodeContents(subtitle) + const textRects = Array.from(range.getClientRects()) + const lastText = textRects.at(-1)! + const info = heading.querySelector('button')!.getBoundingClientRect() + const buttons = Array.from(actions.querySelectorAll('button')) + const singleLine = buttons.every((button) => { + const walker = document.createTreeWalker(button, NodeFilter.SHOW_TEXT) + const tops: number[] = [] + while (walker.nextNode()) { + if (!walker.currentNode.textContent?.trim()) continue + range.selectNodeContents(walker.currentNode) + for (const rect of range.getClientRects()) tops.push(rect.top) + } + return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 + }) + return { + singleLine, + placement: actionRect.left >= headingRect.right - 1 + ? 'beside' as const + : actionRect.top >= headingRect.bottom - 1 ? 'below' as const : 'overlapping' as const, + contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, + infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 + && info.top < lastText.bottom && info.bottom > lastText.top, + focused: buttons.includes(document.activeElement as HTMLButtonElement), } - return tops.length === 0 || Math.max(...tops) - Math.min(...tops) < 1 }) - return { - singleLine, - separated: actionRect.top >= headingRect.bottom - 1 || actionRect.left >= headingRect.right - 1, - contained: actionRect.left >= headerRect.left - 1 && actionRect.right <= headerRect.right + 1, - infoFollowsText: info.left >= lastText.right && info.left - lastText.right <= 5 - && info.top < lastText.bottom && info.bottom > lastText.top, - focused: buttons.includes(document.activeElement as HTMLButtonElement), - } - }) - await expect.poll(measure).toEqual({ - singleLine: true, separated: true, contained: true, infoFollowsText: true, focused: true, - }) - facts.push(`${language} ${width}px: actions single-line and contained; heading separate; information follows subtitle; focus retained`) + const pinned = PINNED_PLACEMENT[width] + await expect.poll(measure).toMatchObject({ + singleLine: true, contained: true, infoFollowsText: true, focused: true, + }) + const placed = (await measure()).placement + if (pinned === undefined) expect(['beside', 'below']).toContain(placed) + else expect(placed).toBe(pinned) + facts.push(`${language} ${width}px: actions ${placed} the heading, single-line; contained; information follows subtitle; focus retained`) + } + await probe.setViewportSize({ width: 390, height: 1000 }) + const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) + await menuTrigger.click() + await probe.getByRole('menu').waitFor() + await probe.keyboard.press('Escape') + await expect.poll(() => probe.getByRole('menu').count()).toBe(0) + expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() } - await probe.setViewportSize({ width: 390, height: 1000 }) - const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' }) - await menuTrigger.click() - await probe.getByRole('menu').waitFor() - await probe.keyboard.press('Escape') - await expect.poll(() => probe.getByRole('menu').count()).toBe(0) - expect(await menuTrigger.evaluate(element => element === document.activeElement)).toBe(true) - expect(consoleWatch.pageErrors).toEqual([]) - } finally { - await context.close() } + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'header-responsive.expected.md'), + `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`, + MODE, + ) + } finally { + await headerScaffold.close() } - await compareOrRefreshGolden( - join(SNAPSHOT_DIR, 'header-responsive.expected.md'), - `# Plugin header responsive layout\n\n${facts.join('\n')}\n\nAdd menu dismisses on Escape and returns focus in both locales.`, - MODE, - ) }) it('aligns the first-read skeleton with the loaded plugin cards', async () => { diff --git a/apps/web/tests/schedule-header.e2e.ts b/apps/web/tests/schedule-header.e2e.ts index 54435bd98c..fc79fd571c 100644 --- a/apps/web/tests/schedule-header.e2e.ts +++ b/apps/web/tests/schedule-header.e2e.ts @@ -31,6 +31,8 @@ it('keeps the automation title intact before moving New to another line', async await create.focus() for (const width of [1680, 800, 520, 440, 390, 430, 1024]) { await page.setViewportSize({ width, height: 1000 }) + // The title keeps its natural width at every viewport width, so New + // stays beside it here; the squeeze case below is what moves it down. await expect.poll(() => header.evaluate((element) => { const title = element.querySelector('h1')! const button = element.querySelector('button')! @@ -43,29 +45,37 @@ it('keeps the automation title intact before moving New to another line', async return { titleSingleLine: titleLines.length === 1, buttonSingleLine: button.scrollHeight <= button.clientHeight, - separated: buttonRect.left >= titleRect.right - 1 || buttonRect.top >= titleRect.bottom - 1, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, contained: titleRect.left >= headerRect.left - 1 && titleRect.right <= headerRect.right + 1 && buttonRect.left >= headerRect.left - 1 && buttonRect.right <= headerRect.right + 1, focused: document.activeElement === button, } })).toEqual({ - titleSingleLine: true, buttonSingleLine: true, separated: true, contained: true, focused: true, + titleSingleLine: true, buttonSingleLine: true, placement: 'beside', contained: true, focused: true, }) - facts.push(`${language} ${width}px: title and New single-line; no overlap or overflow; New retains focus`) + facts.push(`${language} ${width}px: title and New single-line, New beside the title; contained; New retains focus`) } if (language === 'en') { + // 509px collapses the left sidebar to its rail; expanding it leaves the + // page column narrow enough (measured 172px) to force the wrap. await page.setViewportSize({ width: 509, height: 1000 }) await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() await expect.poll(() => header.evaluate((element) => { const title = element.querySelector('h1')! const button = element.querySelector('button')! + const titleRect = title.getBoundingClientRect() + const buttonRect = button.getBoundingClientRect() const range = document.createRange() range.selectNodeContents(title) return { titleSingleLine: range.getClientRects().length === 1, - actionBelow: button.getBoundingClientRect().top >= title.getBoundingClientRect().bottom, + placement: buttonRect.left >= titleRect.right - 1 + ? 'beside' as const + : buttonRect.top >= titleRect.bottom - 1 ? 'below' as const : 'overlapping' as const, } - })).toEqual({ titleSingleLine: true, actionBelow: true }) + })).toEqual({ titleSingleLine: true, placement: 'below' }) facts.push('en 509px with sidebar expanded: title single-line; New moves below the title') } expect(consoleWatch.pageErrors).toEqual([]) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 77ce98c504..dda60f3c91 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -53,6 +53,9 @@ } .pageIntro { + display: flex; + align-items: center; + gap: 4px; margin: 4px 0 0; font-size: 13px; line-height: 20px; @@ -63,9 +66,7 @@ flex: none; width: 20px; height: 20px; - margin-inline-start: 4px; padding: 0; - vertical-align: middle; color: var(--dsw-alias-label-caption); }