From efa47ee86ce9b826e1974a2f74791491fd18d10d Mon Sep 17 00:00:00 2001 From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:20:11 +0000 Subject: [PATCH] Align open-age charts with list controls and preserve scroll position --- docs/dashboard-pages.md | 2 +- static/app.js | 46 +++++++++++------- static/board.js | 9 ++-- static/index.html | 8 ++-- static/style.css | 19 +++++++- test/journey-board.spec.cjs | 95 ++++++++++++++++++++++++++++++++----- 6 files changed, 139 insertions(+), 40 deletions(-) diff --git a/docs/dashboard-pages.md b/docs/dashboard-pages.md index e1a2ef81b..905ca905a 100644 --- a/docs/dashboard-pages.md +++ b/docs/dashboard-pages.md @@ -40,7 +40,7 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为 - **口径**:按 UTC 日期统计。存量按每个 Issue/PR 当前的创建、关闭时间回推,关闭后又重开的从创建起都算开放。历史尚未回填完时,说明里会提示较早日期可能偏少。 - 范围和开关只保存在当前页面,重新打开回到最近 30 天;定时刷新数据时保留。 -列表正上方是**开启时长**饼图:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。 +**开启时长**饼图与开放列表标题、切换及筛选条同一行(窄屏上下堆叠),四桶使用分类色 `--s2`、`--s4`、`--s7`、`--s3`:按创建时间把当前开放项分进 `<7d`、`7-30d`、`30-90d`、`>90d` 四桶(边界与采集器的年龄统计一致),图例同时给出数量和占比;`age_days` 缺失的项不计入,数量为 0 的桶只在图例里列出、不可点。点击扇区或图例只筛选本页下方的开放列表(表格和看板都吃同一个筛选),再点一次选中的扇区取消;筛选与搜索、标签、认领人等条件同时生效(取交集),Issue 与 PR 的选择互不影响。筛选生效时筛选条出现当前时长桶,可点 × 清除;结果为 0 时列表显示空状态,饼图保持可见。“最近更新”“最久未动”“最近合并”等旁路表不受这个筛选影响。Issue 页用这张图代替原来的“年龄分布”条形图。 数据由看板仓的采集在 Actions 中算好写进快照,不读 Bot 的数据库,计算方式见[增量同步与历史](incremental-history.md)。 diff --git a/static/app.js b/static/app.js index a29290906..b04e970ff 100644 --- a/static/app.js +++ b/static/app.js @@ -332,7 +332,7 @@ // Open-age pie above each open list: same bucket boundaries as the collector's aging // summary ([0,7), [7,30), [30,90), [90,∞)), computed from the items the list shows. const AGE_BUCKETS = [['<7d', 0, 7], ['7-30d', 7, 30], ['30-90d', 30, 90], ['>90d', 90, null]]; - const AGE_BUCKET_COLOR = ['seq-3', 'seq-4', 'seq-5', 'seq-6']; + const AGE_BUCKET_COLOR = ['s2', 's4', 's7', 's3']; const ageBucketOf = (age) => { if (age == null || Number.isNaN(Number(age))) return -1; return AGE_BUCKETS.findIndex(([, lo, hi]) => age >= lo && (hi == null || age < hi)); @@ -367,8 +367,7 @@ const [x1, y1] = point(rO, a1), [u1, v1] = point(rI, a1), large = span > Math.PI ? 1 : 0; d = `M${x0.toFixed(2)},${y0.toFixed(2)} A${rO},${rO} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} L${u1.toFixed(2)},${v1.toFixed(2)} A${rI},${rI} 0 ${large} 0 ${u0.toFixed(2)},${v0.toFixed(2)} Z`; } - const mid = (a0 + a1) / 2, offset = on ? ` transform="translate(${(Math.cos(mid) * 5).toFixed(2)},${(Math.sin(mid) * 5).toFixed(2)})"` : ''; - sectors.push(``); + sectors.push(``); }); const center = `${n(total)}开放${unit}`; const legend = AGE_BUCKETS.map(([label], k) => { @@ -383,6 +382,23 @@ const sel = STATE.ageFilter[tab]; return sel == null ? '' : `开启时长:${esc(AGE_BUCKETS[sel][0])} `; }; + const openListHeader = (tab, items, unit, sub, filters = '') => `
+

开启时长

点击扇区或图例筛选,再次点击取消

${agePie(tab, items, unit)}
+
${listHead(tab, `开放 ${unit}`, sub)} + ${STATE.views[tab] === 'board' ? '
' : filters} +
${ageChip(tab)}
+
+
`; + function setAgeFilter(tab, value, focusBucket) { + const { scrollX, scrollY } = window, panel = $(`#tab-${tab}`); + // Shorter results must still leave enough document height to restore this viewport. + const top = panel.getBoundingClientRect().top + scrollY; + panel.style.minHeight = `${Math.max(0, scrollY + window.innerHeight - top)}px`; + STATE.ageFilter[tab] = value; + renderTabs(); + if (focusBucket != null) document.querySelector(`.age-key[data-age-tab="${tab}"][data-age-filter="${focusBucket}"]`)?.focus({ preventScroll: true }); + window.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' }); + } // Totals across test reports, or null when any report has no readable counts. const sumCounts = (reports) => (!reports.length || reports.some((r) => !r.counts) ? null : Object.fromEntries(['tests', 'passed', 'failed', 'skipped', 'flaky'].map((k) => [k, reports.reduce((sum, r) => sum + (r.counts[k] || 0), 0)]))); @@ -651,15 +667,14 @@ else if (f.issueAssignee) rows = rows.filter((i) => i.assignees.includes(f.issueAssignee)); if (STATE.ageFilter.issues != null) rows = rows.filter((i) => ageBucketOf(i.age_days) === STATE.ageFilter.issues); const preview = d.preview_total > d.items.length ? ` · 列表显示最近更新的 ${d.items.length} / ${d.preview_total} 个` : ''; - html += card('开启时长', agePie('issues', d.items, 'Issue'), { sub: STATE.ageFilter.issues != null ? '只筛选下方开放 Issue;再点一次选中的扇区或图例取消' : '点击扇区或图例筛选下方开放 Issue' }); - html += listHead('issues', '开放 Issue', STATE.views.issues === 'board' ? `看板含最近关闭的 Issue${preview}` : `${rows.length} / ${d.items.length}${preview}`); - if (STATE.views.issues === 'board') return html + boardMount('issue'); - html += `
+ const filters = `
- ${ageChip('issues')} -
${table('iss-all', [ +
`; + html += openListHeader('issues', d.items, 'Issue', STATE.views.issues === 'board' ? `看板含最近关闭的 Issue${preview}` : `${rows.length} / ${d.items.length}${preview}`, filters); + if (STATE.views.issues === 'board') return html + boardMount('issue'); + html += `
${table('iss-all', [ { key: 'number', label: '#', num: true, render: (i) => link(i.url, `#${i.number}`) }, { key: 'title', label: '标题', render: (i) => `${link(i.url, esc(i.title))}
${labelChips(i.labels)}${i.milestone ? ` · ${esc(i.milestone)}` : ''}
` }, { key: 'author', label: '作者' }, @@ -703,10 +718,9 @@
`; let rows = d.items; if (STATE.ageFilter.prs != null) rows = rows.filter((p) => ageBucketOf(p.age_days) === STATE.ageFilter.prs); - html += card('开启时长', agePie('prs', d.items, 'PR'), { sub: STATE.ageFilter.prs != null ? '只筛选下方开放 PR;再点一次选中的扇区或图例取消' : '点击扇区或图例筛选下方开放 PR' }); - html += listHead('prs', '开放 PR', STATE.views.prs === 'board' ? '看板含最近关闭与合并的 PR' : STATE.ageFilter.prs != null ? `${rows.length} / ${d.items.length} 个` : `${d.items.length} 个`); + html += openListHeader('prs', d.items, 'PR', STATE.views.prs === 'board' ? '看板含最近关闭与合并的 PR' : `${rows.length} / ${d.items.length} 个`); if (STATE.views.prs === 'board') html += boardMount('pr'); - else html += `
${STATE.ageFilter.prs != null ? `
${ageChip('prs')}
` : ''}${table('pr-open', [ + else html += `
${table('pr-open', [ { key: 'number', label: '#', num: true, render: (p) => link(p.url, `#${p.number}`) }, { key: 'title', label: '标题', render: (p) => `${link(p.url, esc(p.title))}
${esc(p.author)} · ${esc(p.head)} → ${esc(p.base)}${p.labels.length ? ' · ' + labelChips(p.labels) : ''}
` }, { key: 'age_days', label: '年龄', num: true, render: (p) => days(p.age_days) }, @@ -1582,10 +1596,8 @@ if (age) { const tab = age.dataset.ageTab, prev = STATE.ageFilter[tab]; const next = 'ageClear' in age.dataset ? null : Number(age.dataset.ageFilter); - STATE.ageFilter[tab] = prev === next ? null : next; - renderTabs(); - const keep = STATE.ageFilter[tab] ?? prev; - document.querySelector(`.age-key[data-age-tab="${tab}"][data-age-filter="${keep}"]`)?.focus(); + const value = prev === next ? null : next; + setAgeFilter(tab, value, value ?? prev); return; } const lineButton = ev.target.closest('[data-line]'); @@ -1694,7 +1706,7 @@ }, true); // Shared helpers for the board module (static/board.js). - window.GSB = { esc, ago, days, date, n, badge, labelChips, link, codeify, conclusionBadge, tip, empty, kv, CONCLUSION_NAME, refresh, snapshot:()=>STATE.snap, ageBucketOf, AGE_BUCKETS, ageFilter:()=>STATE.ageFilter, setAgeFilter:(tab,value)=>{ STATE.ageFilter[tab] = value; renderTabs(); } }; + window.GSB = { esc, ago, days, date, n, badge, labelChips, link, codeify, conclusionBadge, tip, empty, kv, CONCLUSION_NAME, refresh, snapshot:()=>STATE.snap, ageBucketOf, AGE_BUCKETS, ageFilter:()=>STATE.ageFilter, setAgeFilter }; STATE.tab = location.hash.slice(1) || 'overview'; load(); })(); diff --git a/static/board.js b/static/board.js index 724b22962..5cf5e4a45 100644 --- a/static/board.js +++ b/static/board.js @@ -142,10 +142,6 @@ const d = B.data, f = B.prefs.filters, p = B.prefs; const sel = (name, list, cur, allLabel, noneLabel) => ``; const chips = Object.entries(f).filter(([k, v]) => v).map(([k, v]) => `${esc({ q: '搜索', state: '状态', label: '标签', assignee: '负责人', author: '作者', priority: '优先级', iteration: '迭代', milestone: '里程碑' }[k])}: ${esc(v === NONE ? '无' : v)} `).join(''); - // The age filter lives on the page (pie above the list); mirror it here so board readers - // see the active bucket and can clear it. The × is wired by the page's own delegation. - const ageSel = H.ageFilter ? H.ageFilter()[B.scope === 'issue' ? 'issues' : 'prs'] : null; - const ageChip = ageSel == null ? '' : `开启时长:${esc(H.AGE_BUCKETS[ageSel][0])} `; const summary = p.group === 'status' ? columns(items).map((c) => `${esc(c.label)} ${c.items.length}${c.limit ? `/${c.limit}` : ''}`).join('') : ''; return `
分组${Object.entries(GROUPS).map(([k, l]) => ``).join('')}
@@ -163,7 +159,7 @@ ${sel('iteration', d.facets.iterations, f.iteration, '全部迭代', '未设置')}${sel('milestone', d.facets.milestones, f.milestone, '全部里程碑', '无里程碑')} ${items.length} / ${d.items.filter((i) => i.kind === B.scope).length} 张卡
- ${chips || ageChip ? `
${ageChip}${chips}
` : ''} + ${chips ? `
${chips}
` : ''} ${summary ? `
${summary}规则:加入→${esc(d.rules.default_status)} · 指派→${esc(d.rules.doing_status)} · 关联 PR→${esc(d.rules.review_status)} · 关闭/合并→${esc(d.rules.done_status)}
` : ''} `; } @@ -190,6 +186,9 @@ if (B.error) html += ``; if (B.historyOpen) html += historyHtml(); html += '
'; + // The page owns the shared chart/list header; keep the board itself full width. + const controls = $(`#tab-${SCOPES[B.scope].tab} .open-board-controls`); + if (controls) { controls.innerHTML = html; html = ''; } const cols = columns(items); html += `
${cols.map((c) => columnHtml(c, B.prefs.group)).join('')}
`; html += `
数据:快照 ${ago(B.data.snapshot_generated_at)} 的 ${SCOPES[B.scope].name} + 本地字段(${B.scope === 'pr' ? B.data.counts.prs : B.data.counts.issues} 个,含最近 ${esc(String(B.data.rules.closed_window_days))} 天关闭的)。拖拽只改本地字段,不写回 GitHub。
`; diff --git a/static/index.html b/static/index.html index 00c6f2d19..09fb19206 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ GitHub 状态看板 - +
@@ -48,8 +48,8 @@ - - - + + + diff --git a/static/style.css b/static/style.css index ce6a95d17..449d3bfdc 100644 --- a/static/style.css +++ b/static/style.css @@ -163,12 +163,19 @@ td .sub { color: var(--muted); font-size: 12px; } .legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); margin-top: 6px; } .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; } /* Open-age donut on the Issue / PR pages; clicking a sector or legend entry filters the list below. */ -.age-pie { display: flex; align-items: center; gap: 12px 22px; flex-wrap: wrap; } +.open-list-header { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 24px; margin: 12px 0; align-items: start; } +.age-overview, .open-list-controls { min-width: 0; } +.age-overview > .sub { color: var(--muted); font-size: 12px; margin: 0 0 8px; } +.open-list-controls .list-head { margin: 0 0 12px; } +.open-list-controls .filters { margin: 0 0 8px; } +.open-list-controls input, .open-list-controls select { max-width: 100%; } +.age-filter-slot { min-height: 30px; display: flex; align-items: center; } +.age-pie { display: flex; align-items: center; gap: 12px; } .age-pie-svg { width: 160px; height: 160px; flex: none; } .age-sector { cursor: pointer; stroke: var(--surface); stroke-width: 1.5; } .age-sector:hover { filter: brightness(1.07); } .age-sector:focus-visible { outline: 2px solid var(--link); outline-offset: 1px; } -.age-sector.on { stroke: var(--ink); stroke-width: 2.5; } +.age-sector.on { stroke: #0b0b0b; stroke-width: 3; } .age-total { fill: var(--ink); font-size: 24px; font-weight: 650; font-variant-numeric: tabular-nums; } .age-total-sub { fill: var(--muted); font-size: 11px; } .age-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; min-width: 190px; } @@ -181,6 +188,14 @@ button.age-key:hover { filter: brightness(0.97); } .age-key .age-name { flex: 1 1 auto; text-align: left; } .age-key b { font-variant-numeric: tabular-nums; } .age-key .muted { font-variant-numeric: tabular-nums; } +@media (max-width: 1000px) { + .open-list-header { grid-template-columns: minmax(0, 1fr); gap: 16px; } +} +@media (max-width: 500px) { + .age-pie-svg { width: 120px; height: 120px; } + .age-legend { min-width: 0; flex: 1; } + .age-key { padding: 3px 7px; gap: 5px; } +} /* CI history lanes. Every row repeats one grid template, so day columns line up across lanes; a day's runs stack top-down and scroll inside that column. */ /* Tagged tests: dimension × profile matrix. */ diff --git a/test/journey-board.spec.cjs b/test/journey-board.spec.cjs index b303d8f29..0ded172fd 100644 --- a/test/journey-board.spec.cjs +++ b/test/journey-board.spec.cjs @@ -139,7 +139,39 @@ const withAgedSnapshot = async (page) => { }; test('open-age pie filters only the open list on its own page', async ({page}) => { + test.setTimeout(60000); await withAgedSnapshot(page); + const header = tab => page.locator(`#tab-${tab} .open-list-header`); + const stableClick = async (tab, target, below = false) => { + await header(tab).evaluate((el, below) => { + const rect = el.getBoundingClientRect(); + window.scrollTo(0, window.scrollY + (below ? rect.bottom + 20 : rect.top - 120)); + }, below); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))); + const before = await page.evaluate(() => window.scrollY); + expect(before).toBeGreaterThan(0); + const height = (await header(tab).boundingBox()).height; + const list = page.locator(`#tab-${tab} .work-table, #tab-${tab} .work-board`); + const listTop = await list.evaluate(el => el.getBoundingClientRect().top + window.scrollY); + // Playwright click would scroll an offscreen sector into view before the handler. + // Dispatch its click while below the chart to isolate the application's focus/scroll behavior. + if (below) await target.dispatchEvent('click'); else await target.click(); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + expect(Math.abs(await page.evaluate(() => window.scrollY) - before)).toBeLessThanOrEqual(2); + expect(Math.abs((await header(tab).boundingBox()).height - height)).toBeLessThanOrEqual(2); + expect(Math.abs(await list.evaluate(el => el.getBoundingClientRect().top + window.scrollY) - listTop)).toBeLessThanOrEqual(2); + }; + const checkLayout = async (tab, width) => { + const chart = await page.locator(`#tab-${tab} .age-overview`).boundingBox(); + const controls = await page.locator(`#tab-${tab} .open-list-controls`).boundingBox(); + if (width === 1440) { + expect(controls.x).toBeGreaterThanOrEqual(chart.x + chart.width); + expect(Math.abs(controls.y - chart.y)).toBeLessThanOrEqual(2); + } else { + expect(controls.y).toBeGreaterThanOrEqual(chart.y + chart.height); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + }; await page.goto('/github-status-board/#issues'); const pie = page.locator('#tab-issues [data-age-pie="issues"]'); await expect(pie).toBeVisible(); @@ -157,7 +189,7 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(key('30-90d')).toContainText('22.2%'); await expect(key('>90d')).toContainText('22.2%'); // Legend click filters the open Issue table; side cards keep their own rows. - await key('7-30d').click(); + await stableClick('issues', key('7-30d')); await expect(rows).toHaveCount(2); await expect(page.locator('#tab-issues .list-head')).toContainText('2 / 10'); await expect(page.locator('#tab-issues .work-table')).toContainText('样本 7d'); @@ -166,7 +198,7 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(page.locator('#tab-issues .work-table')).not.toContainText('样本 无年龄'); await expect(key('7-30d')).toHaveAttribute('aria-pressed', 'true'); await expect(pie.locator('.age-sector[data-age-filter="1"]')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('#tab-issues .filters .chip')).toContainText('开启时长:7-30d'); + await expect(page.locator('#tab-issues .age-filter-slot .chip')).toContainText('开启时长:7-30d'); await expect(page.locator('#tab-issues .grid.wide')).toContainText('处理超时问题'); await page.screenshot({path:shot('issue-age-pie'),fullPage:true}); // The age bucket intersects with the text search; an empty result keeps the pie. @@ -179,16 +211,20 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(pie).toBeVisible(); await page.locator('[data-filter="issueQ"]').fill(''); // Clicking the selected legend entry again clears the filter. - await key('7-30d').click(); + await stableClick('issues', key('7-30d')); await expect(rows).toHaveCount(10); - await expect(page.locator('#tab-issues .filters .chip')).toHaveCount(0); + await expect(page.locator('#tab-issues .age-filter-slot .chip')).toHaveCount(0); await expect(key('7-30d')).toHaveAttribute('aria-pressed', 'false'); // A sector click works too, and the chip clears it. - await pie.locator('.age-sector[data-age-filter="3"]').click(); + await stableClick('issues', pie.locator('.age-sector[data-age-filter="3"]')); await expect(rows).toHaveCount(2); await expect(page.locator('#tab-issues .work-table')).toContainText('样本 90d'); await expect(page.locator('#tab-issues .work-table')).toContainText('样本 365d'); - await page.locator('#tab-issues .filters .chip button').click(); + await stableClick('issues', pie.locator('.age-sector[data-age-filter="3"]'), true); + await expect(rows).toHaveCount(10); + await stableClick('issues', pie.locator('.age-sector[data-age-filter="3"]'), true); + await expect(rows).toHaveCount(2); + await stableClick('issues', page.locator('#tab-issues .age-filter-slot .chip button')); await expect(rows).toHaveCount(10); // Sectors are keyboard-operable controls. await pie.locator('.age-sector[data-age-filter="2"]').focus(); @@ -205,10 +241,10 @@ test('open-age pie filters only the open list on its own page', async ({page}) = await expect(pPie.locator('button.age-key')).toHaveCount(3); await expect(pPie.locator('span.age-key.zero')).toHaveCount(1); await expect(pPie.locator('span.age-key.zero')).toContainText('7-30d'); - await pPie.locator('button.age-key', {hasText: '>90d'}).click(); + await stableClick('prs', pPie.locator('button.age-key', {hasText: '>90d'})); await expect(pRows).toHaveCount(1); await expect(page.locator('#tab-prs .work-table')).toContainText('PR 样本 100d'); - await expect(page.locator('#tab-prs .filters .chip')).toContainText('开启时长:>90d'); + await expect(page.locator('#tab-prs .age-filter-slot .chip')).toContainText('开启时长:>90d'); // Recently merged PRs are not touched by the open-list filter. await expect(page.locator('#tab-prs')).toContainText('早前合并的 PR'); // Back on Issues the earlier selection still holds; the PR choice did not follow. @@ -220,15 +256,52 @@ test('open-age pie filters only the open list on its own page', async ({page}) = // The board view of the same page reads the very same filter. await showBoard(page, 'issues'); await expect(page.locator('#tab-issues .bcard')).toHaveCount(2); - await expect(page.locator('#tab-issues .chips .chip')).toContainText('开启时长:30-90d'); - await page.locator('#tab-issues .chips .chip button').click(); + await expect(page.locator('#tab-issues .age-filter-slot .chip')).toContainText('开启时长:30-90d'); + await stableClick('issues', page.locator('#tab-issues .age-filter-slot .chip button')); await expect(page.locator('#tab-issues .bcard')).toHaveCount(10); await page.locator('[data-tab="prs"]').click(); await showBoard(page, 'prs'); await expect(page.locator('#tab-prs .bcard')).toHaveCount(1); await expect(page.locator('#tab-prs .bcard')).toContainText('PR 样本 100d'); - await page.locator('#tab-prs .chips .chip button').click(); + await stableClick('prs', page.locator('#tab-prs .age-filter-slot .chip button')); await expect(page.locator('#tab-prs .bcard')).toHaveCount(3); + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 1000}); + for (const tab of ['issues', 'prs']) { + await page.locator(`[data-tab="${tab}"]`).click(); + for (const view of ['table', 'board']) { + await page.locator(`[data-work-view="${tab}"] [data-val="${view}"]`).click(); + await checkLayout(tab, width); + for (const colorScheme of ['light', 'dark']) { + await page.emulateMedia({colorScheme}); + const chart = page.locator(`#tab-${tab} [data-age-pie]`); + const expected = ['--s2', '--s4', '--s7', '--s3']; + const colors = await chart.locator('.age-key i').evaluateAll((els, vars) => els.map((el, k) => { + const probe = document.createElement('i'); + probe.style.backgroundColor = `var(${vars[k]})`; el.append(probe); + const matches = getComputedStyle(el).backgroundColor === getComputedStyle(probe).backgroundColor; + probe.remove(); return {matches, color: getComputedStyle(el).backgroundColor}; + }), expected); + expect(colors.every(c => c.matches)).toBe(true); + expect(new Set(colors.map(c => c.color)).size).toBe(4); + const sector = chart.locator('.age-sector[data-age-filter="0"]'); + await stableClick(tab, sector, true); + await expect(sector).toHaveAttribute('aria-pressed', 'true'); + await expect(sector).not.toHaveAttribute('transform', /translate/); + await expect(sector).toHaveCSS('stroke', 'rgb(11, 11, 11)'); + await page.evaluate(() => document.activeElement?.blur()); + await page.mouse.move(0, 0); + await header(tab).evaluate(el => window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - document.querySelector('.topbar').getBoundingClientRect().height - 12)); + await header(tab).screenshot({path: shot(`age-layout-${tab}-${view}-${width}-${colorScheme}`)}); + await stableClick(tab, sector, true); + await expect(sector).toHaveAttribute('aria-pressed', 'false'); + await stableClick(tab, chart.locator('button.age-key').first(), true); + await stableClick(tab, page.locator(`#tab-${tab} .age-filter-slot button`), true); + await checkLayout(tab, width); + } + } + } + } }); test('drag a card, configure renamed column and WIP, preserve manual status',async({page})=>{