Make shared History window controls accessible on phones

Name each chart window and expose its selected period without cross-chart changes. Keep native keyboard and touch controls out of enclosing form submission, and wrap all existing choices inside narrow charts. Exact-parent browser controls reproduce missing semantics, fixture form submission and phone overflow; current-source Chromium and WebKit verify scope, selection, paint and layout. Preserve History ownership, samples, periods and the unchanged dependency graph; full committed-source validation follows.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-06 05:42:17 +01:00
parent e78de344ea
commit c95dee3ec6
7 changed files with 356 additions and 5 deletions
@@ -8439,6 +8439,23 @@ for missing data. Tooltips reserve room for a wrapped local timestamp and readin
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
browser proof does not establish native collector or assistive-device acceptance.
### Shared History window control access
Each visible shared chart window selector is a named group for its metric (or
"History window" when no label is supplied). Exactly the current period carries
`aria-pressed=true`; switching one chart must not change another chart's selected
state. Period buttons retain native keyboard/touch behaviour and explicit
`type=button`, so changing the window cannot submit an enclosing form. A hidden
selector exposes neither a group nor controls.
The header, summary and existing period choices wrap within their chart on
narrow screens instead of forcing horizontal overflow. This changes neither
available periods, subscriptions, History ownership nor sample interpretation.
Mounted `HistoryChart.test.tsx` and the production-component
`browser-tests/history-window-controls.cjs` cover selection, scope, native input,
form non-submission and phone layout. Synthetic observations are not native
PBS/NAS, physical device or assistive-device acceptance.
### Shared History request-state honesty
An empty successful response states only that the selected time range has no
@@ -0,0 +1,126 @@
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const crypto = require('node:crypto');
const assert = require('node:assert/strict');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const parent = Boolean(global.__historyWindowParentControl);
const output = '/workspace/tmp/history-window-controls/' + (parent ? 'parent' : 'final');
const preview = output + '/preview';
const origin = 'http://127.0.0.1:5332';
const runtime = 'frontend-modern/src/components/shared/HistoryChartHeader.tsx';
const parentFile = '/workspace/tmp/history-window-controls/parent.tsx';
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const load = name => import(require.resolve(name, { paths: [root] }));
(async () => {
fs.mkdirSync(output, {recursive:true});
const result = { result:'incomplete', control:parent ? 'exact parent expected defects' : 'final',
playwright:require('playwright/package.json').version,
runtime_hash:hash(parent ? parentFile : '/workspace/' + runtime),
script_hash:hash(root + '/browser-tests/history-window-controls.cjs'),
cases:[], captures:[], cleanup:{browser:false,server:false},
limits:'Production header and CSS with synthetic state. No native PBS/NAS, collector, physical device, assistive-device, installed or release acceptance.' };
let server, browser;
try {
assert.equal(result.playwright,JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version);
const {build} = await load('vite');
const solidModule = await load('vite-plugin-solid');
const solid = solidModule.default.default || solidModule.default;
const {default:tailwind} = await load('@tailwindcss/vite');
const plugins = [];
if(parent) plugins.push({name:'exact-parent-header-control',enforce:'pre',load(id) {
if(id.split('?')[0] === '/workspace/' + runtime) return fs.readFileSync(parentFile,'utf8');
}});
plugins.push(solid(),tailwind());
await build({root,configFile:false,logLevel:'warn',plugins,resolve:{alias:{'@':root + '/src'}},
build:{target:'esnext',outDir:preview,emptyOutDir:true,minify:false,rollupOptions:{input:root + '/browser-tests/history-window-controls.html'}}});
server = http.createServer((req,res) => {
const url=new URL(req.url,origin);
let file=path.resolve(preview,'.' + decodeURIComponent(url.pathname));
if(file!==preview && !file.startsWith(preview + '/')) {res.writeHead(403);return res.end();}
if(!fs.existsSync(file)||fs.statSync(file).isDirectory()) file=preview+'/browser-tests/history-window-controls.html';
res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css'})[path.extname(file)]||'application/octet-stream');
res.end(fs.readFileSync(file));
});
await new Promise(resolve=>server.listen(5332,'127.0.0.1',resolve));
for(const [name,engine,width,dark] of [['chromium-desktop',chromium,1280,false],['webkit-phone',webkit,390,true]]) {
browser=await engine.launch(engine===chromium ? {headless:true,channel:'chromium',args:['--no-sandbox']} : {headless:true});
const context=await browser.newContext({viewport:{width,height:760},isMobile:width<=768,hasTouch:width<=768,locale:'en-GB'});
const page=await context.newPage(); page.setDefaultTimeout(10000);
const record={name,browser:browser.version(),width,errors:[],blocked:[]}; result.cases.push(record);
page.on('pageerror',error=>record.errors.push(error.message));
await page.route('**/*',route=> {
if(new URL(route.request().url()).origin!==origin) {record.blocked.push(route.request().url());return route.abort();}
return route.continue();
});
await page.addInitScript(dark=> {
window.__trustedHistoryClicks=[];
document.addEventListener('click',event=>window.__trustedHistoryClicks.push(event.isTrusted),true);
document.addEventListener('DOMContentLoaded',()=>{if(dark)document.documentElement.classList.add('dark');});
},dark);
await page.goto(origin,{waitUntil:'domcontentloaded'});
await page.waitForFunction(()=>window.__historyWindows);
const cpu=page.locator('[data-chart="CPU"]');
const memory=page.locator('[data-chart="Memory"]');
record.initialLayout=await cpu.evaluate(el=>({width:el.clientWidth,scroll:el.scrollWidth,document:document.documentElement.clientWidth,documentScroll:document.documentElement.scrollWidth}));
if(parent) {
assert.equal(await cpu.getByRole('group').count(),0);
assert.equal(await cpu.locator('[aria-pressed]').count(),0);
await cpu.getByRole('button',{name:'24h',exact:true}).click();
assert.equal(await page.evaluate(()=>window.__historyWindows.submissions()),1);
if(width<=768) assert.ok(record.initialLayout.scroll>record.initialLayout.width+1,JSON.stringify(record.initialLayout));
record.expectedDefects={noNamedGroup:true,noSelectionState:true,nativeFormSubmitted:true,phoneOverflow:width<=768};
} else {
const cpuGroup=cpu.getByRole('group',{name:'CPU history window'});
const memoryGroup=memory.getByRole('group',{name:'Memory history window'});
assert.equal(await cpuGroup.getByRole('button',{pressed:true}).count(),1);
assert.equal(await cpuGroup.getByRole('button',{name:'1h',exact:true}).getAttribute('aria-pressed'),'true');
if(width<=768) {
await cpuGroup.getByRole('button',{name:'7d',exact:true}).tap();
assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true');
await memoryGroup.getByRole('button',{name:'6h',exact:true}).tap();
await page.waitForFunction(()=>Array.from(document.querySelectorAll('[data-chart="Memory"] button')).some(button=>button.textContent.trim()==='6h' && button.getAttribute('aria-pressed')==='true'));
assert.equal(await memoryGroup.getByRole('button',{name:'6h',exact:true}).getAttribute('aria-pressed'),'true');
assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true');
} else {
const button=cpuGroup.getByRole('button',{name:'24h',exact:true});
await button.focus();await page.keyboard.press('Enter');
assert.equal(await button.getAttribute('aria-pressed'),'true');
assert.equal(await button.evaluate(el=>el===document.activeElement),true);
await page.keyboard.press('Tab');
assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).evaluate(el=>el===document.activeElement),true);
await page.keyboard.press('Space');
assert.equal(await cpuGroup.getByRole('button',{name:'7d',exact:true}).getAttribute('aria-pressed'),'true');
assert.equal(await memoryGroup.getByRole('button',{name:'1h',exact:true}).getAttribute('aria-pressed'),'true');
}
assert.equal(await cpuGroup.getByRole('button',{pressed:true}).count(),1);
assert.equal(await memoryGroup.getByRole('button',{pressed:true}).count(),1);
assert.equal(await page.evaluate(()=>window.__historyWindows.submissions()),0);
assert.equal(await page.locator('[data-chart="hidden"]').getByRole('group').count(),0);
assert.ok(record.initialLayout.scroll<=record.initialLayout.width+1,JSON.stringify(record.initialLayout));
assert.ok(record.initialLayout.documentScroll<=record.initialLayout.document+1,JSON.stringify(record.initialLayout));
record.selection=await page.locator('[data-chart] button').evaluateAll(buttons=>buttons.map(button=>({text:button.textContent.trim(),pressed:button.getAttribute('aria-pressed'),class:button.className})));
for(const button of record.selection) assert.equal(button.class.includes('bg-surface text-base-content'),button.pressed==='true');
record.calls=await page.evaluate(()=>window.__historyWindows.calls);
record.trusted=await page.evaluate(()=>window.__trustedHistoryClicks);
assert.ok(record.trusted.length>=2 && record.trusted.every(Boolean));
}
assert.deepEqual(record.errors,[]);assert.deepEqual(record.blocked,[]);
await page.evaluate(async()=> {
// Observe completed finite colour transitions, not a transient tap frame.
await Promise.all(document.getAnimations().filter(animation=>Number.isFinite(animation.effect?.getComputedTiming().endTime)).map(animation=>animation.finished.catch(()=>{})));
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
});
const capture=name+'.png';await page.screenshot({path:output+'/'+capture,fullPage:true});
result.captures.push({file:capture,sha256:hash(output+'/'+capture)});
await browser.close();browser=undefined;
}
result.result='passed';
} finally {
if(browser)await browser.close(); result.cleanup.browser=true;
if(server)await new Promise(resolve=>server.close(resolve)); result.cleanup.server=true;
fs.writeFileSync(output+'/result.json',JSON.stringify(result,null,2)+'\n');
}
console.log(JSON.stringify(result));
})().catch(error=>{console.error(error);process.exitCode=1;});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>History window control verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./history-window-controls.tsx"></script>
</body>
</html>
@@ -0,0 +1,56 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { HistoryChartHeader } from '../src/components/shared/HistoryChartHeader';
import { HISTORY_CHART_RANGES } from '../src/components/shared/historyChartModel';
import type { HistoryChartState } from '../src/components/shared/useHistoryChartState';
import type { HistoryTimeRange } from '../src/api/charts';
import '../src/index.css';
const calls: Array<{ chart: string; range: HistoryTimeRange }> = [];
let submissions = 0;
const makeChart = (name: string) => {
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
return {
ranges: HISTORY_CHART_RANGES,
range,
updateRange: (next: HistoryTimeRange) => {
calls.push({ chart: name, range: next });
setRange(next);
},
dataMin: () => 0,
dataMax: () => 75,
source: () => 'store',
} as unknown as HistoryChartState;
};
const cpu = makeChart('CPU');
const memory = makeChart('Memory');
Object.assign(window, { __historyWindows: { calls, submissions: () => submissions } });
render(
() => (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">History window controls</h1>
<p>Production header and styles; synthetic readings. No collector or API is contacted.</p>
<form
onSubmit={(event) => {
event.preventDefault();
submissions += 1;
}}
>
<section data-chart="CPU" class="mb-4 max-w-xl rounded-md border border-border p-3">
<HistoryChartHeader chart={cpu} label="CPU" unit="%" />
</section>
<section data-chart="Memory" class="mb-4 max-w-xl rounded-md border border-border p-3">
<HistoryChartHeader chart={memory} label="Memory" unit="%" />
</section>
<section data-chart="hidden" class="max-w-xl rounded-md border border-border p-3">
<HistoryChartHeader chart={memory} label="Disk I/O" hideSelector />
</section>
</form>
<button id="after-chart" type="button" class="border border-border px-3 py-1">
After charts
</button>
</main>
),
document.getElementById('root')!,
);
@@ -0,0 +1,64 @@
{
"version": 1,
"base_sha": "e78de344ea5e2d3211149c2a845cdff90432adf2",
"verified_at": "2026-10-06T04:42:13.612566Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/HistoryChartHeader.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/HistoryChartHeader.tsx": "e74f8058a9f26bdd6d906152f2af68139e3b0966f8b6c860e33d3915ad15999e"
},
"routes": [
"Shared History header used by Storage/PBS/NAS metric panels (production-component preview, synthetic state)"
],
"viewports": [
{
"width": 1280,
"height": 760
},
{
"width": 390,
"height": 760
}
],
"states": [
"Two independently scoped named History window groups, exactly one pressed period in each",
"Eight existing periods, zero/max summaries and hidden selector",
"Phone dark layout wraps without horizontal overflow",
"ARIA selection and active styling agree after finite colour transitions"
],
"interactions": [
"Trusted desktop Enter, Tab and Space; focus retained and second chart unchanged",
"Trusted phone CPU 7d and Memory 6h taps; independent selection",
"Native form owns zero submissions when period buttons activate"
],
"evidence": {
"script": "frontend-modern/browser-tests/history-window-controls.cjs",
"script_sha256": "c8fe327e9f0d4377008283a76723f14712532af3b8a34af37150794d8ae2b8b8",
"playwright": "1.56.1",
"browsers": [
{
"name": "chromium-desktop",
"browser": "141.0.7390.37",
"width": 1280
},
{
"name": "webkit-phone",
"browser": "26.0",
"width": 390
}
],
"captures": [
{
"file": "chromium-desktop.png",
"sha256": "4ddf8126d67b69eec339d13f2701b08fbb722e70baf8bb92b37dc1aed2b6e8b4"
},
{
"file": "webkit-phone.png",
"sha256": "06f5c326641853f78153e9404f227f669ab925d10c0e73f181795118a105eaea"
}
],
"limits": "Production header and CSS with synthetic state. No native PBS/NAS, collector, physical device, assistive-device, installed or release acceptance."
}
}
@@ -12,8 +12,10 @@ interface HistoryChartHeaderProps {
export const HistoryChartHeader: Component<HistoryChartHeaderProps> = (props) => {
return (
<div class={`flex items-center justify-between ${props.compact ? 'mb-2' : 'mb-4'}`}>
<div class="flex items-center gap-2">
<div
class={`flex flex-wrap items-center justify-between gap-2 ${props.compact ? 'mb-2' : 'mb-4'}`}
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content">{props.label || 'History'}</span>
<Show when={props.unit}>
<span class="text-xs text-slate-400">({props.unit})</span>
@@ -36,7 +38,7 @@ export const HistoryChartHeader: Component<HistoryChartHeaderProps> = (props) =>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-3">
<Show when={props.chart.dataMin() !== null && props.chart.dataMax() !== null}>
<div class="flex items-center gap-2 text-[10px]">
<span>
@@ -54,9 +56,15 @@ export const HistoryChartHeader: Component<HistoryChartHeaderProps> = (props) =>
</div>
</Show>
<Show when={!props.hideSelector}>
<div class="flex bg-surface-hover rounded-md p-0.5">
<div
role="group"
aria-label={props.label ? `${props.label} history window` : 'History window'}
class="flex max-w-full flex-wrap bg-surface-hover rounded-md p-0.5"
>
{props.chart.ranges.map((range) => (
<button
type="button"
aria-pressed={props.chart.range() === range}
onClick={() => props.chart.updateRange(range)}
class={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
props.chart.range() === range
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { ChartsAPI } from '@/api/charts';
import { eventBus } from '@/stores/events';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw';
import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw';
import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.tsx?raw';
@@ -10,6 +10,8 @@ import historyChartSource from '@/components/shared/HistoryChart.tsx?raw';
import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw';
import historyChartStateSource from '@/components/shared/useHistoryChartState.ts?raw';
import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw';
import { HistoryChartHeader } from '@/components/shared/HistoryChartHeader';
import type { HistoryChartState } from '@/components/shared/useHistoryChartState';
import { HistoryChart, HistoryChartHoverGroup } from '@/components/shared/HistoryChart';
import {
formatHistoryChartTooltipValue,
@@ -708,3 +710,69 @@ describe('History access boundary rendering', () => {
},
);
});
describe('History window controls', () => {
afterEach(cleanup);
const makeChart = () => {
const [range, setRange] = createSignal<
'1h' | '6h' | '12h' | '24h' | '7d' | '14d' | '30d' | '90d'
>('1h');
const updateRange = vi.fn(setRange);
const chart = {
ranges: HISTORY_CHART_RANGES,
range,
updateRange,
dataMin: () => 0,
dataMax: () => 75,
source: () => 'store',
} as unknown as HistoryChartState;
return { chart, updateRange };
};
it('names each chart window and announces only its selected period', () => {
const cpu = makeChart();
const memory = makeChart();
render(() => (
<>
<HistoryChartHeader chart={cpu.chart} label="CPU" unit="%" />
<HistoryChartHeader chart={memory.chart} label="Memory" unit="%" />
</>
));
const cpuGroup = within(screen.getByRole('group', { name: 'CPU history window' }));
const memoryGroup = within(screen.getByRole('group', { name: 'Memory history window' }));
expect(cpuGroup.getAllByRole('button', { pressed: true })).toHaveLength(1);
expect(cpuGroup.getByRole('button', { name: '1h' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(cpuGroup.getByRole('button', { name: '24h' }));
expect(cpu.updateRange).toHaveBeenCalledExactlyOnceWith('24h');
expect(cpuGroup.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true');
expect(cpuGroup.getByRole('button', { name: '1h' })).toHaveAttribute('aria-pressed', 'false');
expect(cpuGroup.getAllByRole('button', { pressed: true })).toHaveLength(1);
expect(memoryGroup.getByRole('button', { name: '1h' })).toHaveAttribute('aria-pressed', 'true');
expect(memory.updateRange).not.toHaveBeenCalled();
});
it('keeps range changes out of surrounding form submission', () => {
const { chart } = makeChart();
const onSubmit = vi.fn((event: SubmitEvent) => event.preventDefault());
render(() => (
<form onSubmit={onSubmit}>
<HistoryChartHeader chart={chart} />
</form>
));
const group = within(screen.getByRole('group', { name: 'History window' }));
for (const button of group.getAllByRole('button'))
expect(button).toHaveAttribute('type', 'button');
fireEvent.click(group.getByRole('button', { name: '7d' }));
expect(group.getByRole('button', { name: '7d' })).toHaveAttribute('aria-pressed', 'true');
expect(onSubmit).not.toHaveBeenCalled();
});
it('does not expose a phantom window group when the caller hides it', () => {
const { chart } = makeChart();
render(() => <HistoryChartHeader chart={chart} label="Disk I/O" hideSelector />);
expect(screen.queryByRole('group')).not.toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('Disk I/O')).toBeInTheDocument();
});
});