mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
web: honour hidden, 44px touch targets, focus ring, named live toggle (#1009)
This commit is contained in:
7 files changed
+468
-44
No files matched your search
@@ -46,6 +46,10 @@
|
||||
* Web: live refreshes keep map overlays, line tooltips and waypoint cards open, and repaint only the table, map or chat whose data changed (#881, SPEC DR2/DR4)
|
||||
* Web: a node role or modem preset named like a built-in object property, such as `constructor`, gets its protocol's base color and its derived preset code instead of an uncolored badge or a `[FU]` tag (SPEC FE1)
|
||||
* Web: a node id containing `"` no longer stops the live-update flashes of a refresh (SPEC FE2)
|
||||
* Web: on touch screens the menu, region, protocol, live and legend toggles, map toolbar, chat tabs, channel select, filter box and node dialog close are at least 44 px tall; the filter's × and the badge popover's close take taps in a 44 px square, and filter text stops short of the ×; each sort button takes taps across its header cell, now 45 px tall; none of these reaches a table row or another button (SPEC CT1)
|
||||
* Web: every button and button-like control without a focus ring of its own shows the accent focus ring when reached by keyboard, and chat tabs show it inside the tab strip (SPEC CT2)
|
||||
* Web: the protocol toggles stay hidden on an instance with one active protocol, the filter shows its × only with text in it, the region selector hides with fewer than two regions, the map's full-screen button hides where the browser has no full-screen support, and the meta row no longer jumps while the dashboard loads (SPEC CT3)
|
||||
* Web: the live toggle reads "● live · pause" and "❚❚ paused HH:MM · resume", framed in the accent while paused; on phones it reads "● live" and "❚❚ paused HH:MM" (SPEC CT4)
|
||||
* Web: the Chromium that renders `/og-image.png` keeps its sandbox unless `OG_IMAGE_NO_SANDBOX=1`, which the Docker images and the Compose default set, and keeps the same-origin policy and site isolation on (SPEC FE3/FE4)
|
||||
* Web: the federation page shows a numeric frequency or version instead of an empty cell (SPEC FE5)
|
||||
* Web: buttons, row toggles, the footer, the badge popover and the nodes table's group header use the dashboard's blue-black palette instead of neutral greys (SPEC GF1/GF2/GF3)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stylesheet checks for the dashboard controls (design review rc3, SPEC
|
||||
* CT1-CT3): 44 px touch targets on coarse pointers, one accent focus ring for
|
||||
* every button without a ring of its own, and meta-row controls that honour
|
||||
* `hidden` in a row whose height is final at first paint.
|
||||
*
|
||||
* These read the rules, through the shared reader `base-css-rules.js` (SPEC
|
||||
* GF7), not the cascade. The rendered half is the browser probe of ACCEPTANCE
|
||||
* CT-A5, which also samples every hit box for what it takes from another
|
||||
* control or a node row: a mock DOM has no stylesheet, which is how the
|
||||
* `hidden` toggles of main-update-counts.test.js passed while the browser
|
||||
* painted every toggle. The Leaflet zoom and the mobile-menu links and close
|
||||
* are not listed: the map and menu rules size them.
|
||||
*
|
||||
* @module app/__tests__/controls-css
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { declarationsFor } from './base-css-rules.js';
|
||||
|
||||
/** The touch-target block. */
|
||||
const COARSE = { media: '(pointer: coarse)' };
|
||||
|
||||
/** The phone layout. */
|
||||
const PHONE = { media: '(max-width: 659px)' };
|
||||
|
||||
/** Standalone controls that grow to 44 x 44 px on coarse pointers. */
|
||||
const GROWN_SQUARE = ['.icon-button:not(.mobile-menu__close)', '.map-toolbar button', '.node-detail-overlay__close'];
|
||||
|
||||
/** Standalone controls that grow to 44 px tall, border-box, on coarse pointers. */
|
||||
const GROWN_TALL = ['.protocol-toggle-btn', '#autorefreshToggle', '.legend-toggle-button', '.chat-tab', '.chat-tab-select', '.filter-input input[type="text"]'];
|
||||
|
||||
/** The column header row, whose cells the sort buttons fill. */
|
||||
const COLUMN_HEADER_CELL = '#nodes thead tr:not(.nodes-group-header) th';
|
||||
|
||||
/** The transparent hit boxes of CT1, each on a control that keeps its box. */
|
||||
const HIT_BOXES = ['.filter-clear::after', '.short-info-overlay .short-info-close::after', '#nodes thead .sort-button::after'];
|
||||
|
||||
/**
|
||||
* A length in CSS px.
|
||||
*
|
||||
* @param {string} value Declared length, such as `6px`.
|
||||
* @returns {number} Its number of px.
|
||||
*/
|
||||
function px(value) {
|
||||
assert.match(value, /^-?\d+(\.\d+)?px$/, `${value} is a px length`);
|
||||
return Number.parseFloat(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `[selector, property, value]` rows whose coarse-pointer declaration
|
||||
* differs from `value`.
|
||||
*
|
||||
* @param {Array<Array<string>>} wanted Expected `[selector, property, value]` rows.
|
||||
* @returns {Array<Array<string>>} The rows base.css does not declare so.
|
||||
*/
|
||||
function coarseMismatches(wanted) {
|
||||
return wanted.filter(([selector, property, value]) => declarationsFor(selector, COARSE)[property] !== value);
|
||||
}
|
||||
|
||||
test('coarse pointers grow the standalone controls to 44 px (CT1)', () => {
|
||||
const wanted = [
|
||||
...GROWN_SQUARE.flatMap(selector => [[selector, 'min-width', '44px'], [selector, 'min-height', '44px']]),
|
||||
...GROWN_TALL.flatMap(selector => [[selector, 'min-height', '44px'], [selector, 'box-sizing', 'border-box']]),
|
||||
];
|
||||
assert.deepEqual(coarseMismatches(wanted), []);
|
||||
});
|
||||
|
||||
test("the filter's × gets the chat badge's 44 px hit box, and the text stops short of it (CT1, FU11)", () => {
|
||||
const hitBox = {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'min-width': '44px',
|
||||
'min-height': '44px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
};
|
||||
assert.deepEqual(coarseMismatches(Object.entries(hitBox).map(([property, value]) => ['.filter-clear::after', property, value])), []);
|
||||
// Absolutely positioned already; relative would pull it into the flow.
|
||||
assert.equal(declarationsFor('.filter-clear').position, 'absolute');
|
||||
assert.equal(declarationsFor('.filter-clear', COARSE).position, undefined);
|
||||
// The hit box reaches its inset, half the ×, and half of 44 px in from the
|
||||
// box's right edge; the text's padding must cover that.
|
||||
const clear = declarationsFor('.filter-clear');
|
||||
const reach = px(clear.right) + px(clear.width) / 2 + 44 / 2;
|
||||
const padding = px(declarationsFor('.filter-input input[type="text"]', COARSE)['padding-right']);
|
||||
assert.ok(padding >= reach, `padding-right ${padding} px covers the hit box's ${reach} px`);
|
||||
});
|
||||
|
||||
test("the short-info close's 44 px hit box grows from inside the popover's rounded corner (CT1)", () => {
|
||||
assert.deepEqual(coarseMismatches([
|
||||
['.short-info-overlay .short-info-close::after', 'content', '""'],
|
||||
['.short-info-overlay .short-info-close::after', 'position', 'absolute'],
|
||||
['.short-info-overlay .short-info-close::after', 'width', '44px'],
|
||||
['.short-info-overlay .short-info-close::after', 'height', '44px'],
|
||||
]), []);
|
||||
const box = declarationsFor('.short-info-overlay .short-info-close::after', COARSE);
|
||||
const close = declarationsFor('.short-info-overlay .short-info-close');
|
||||
const overlay = declarationsFor('.short-info-overlay');
|
||||
const border = px(overlay.border.split(' ')[0]);
|
||||
const radius = px(overlay['border-radius']);
|
||||
for (const side of ['top', 'right']) {
|
||||
// How far inside the popover's outer edge the hit box starts.
|
||||
const inset = px(close[side]) + border + px(box[side]);
|
||||
assert.ok(inset >= 0, `${side}: the box starts ${inset} px inside the popover`);
|
||||
// The corner point at (inset, inset) lies inside the corner's rounding.
|
||||
assert.ok(Math.SQRT2 * (radius - inset) <= radius, `${side}: inside the ${radius} px rounding`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a sort button takes its whole header cell, which grows to a 44 px padding box (CT1)', () => {
|
||||
assert.deepEqual(coarseMismatches([
|
||||
['#nodes thead .sort-button::after', 'content', '""'],
|
||||
['#nodes thead .sort-button::after', 'position', 'absolute'],
|
||||
['#nodes thead .sort-button::after', 'inset', '0'],
|
||||
[COLUMN_HEADER_CELL, 'box-sizing', 'border-box'],
|
||||
[COLUMN_HEADER_CELL, 'height', '45px'],
|
||||
]), []);
|
||||
// The sticky cell is the hit box's containing block: the button itself
|
||||
// stays unpositioned at every pointer.
|
||||
assert.equal(declarationsFor('th').position, 'sticky');
|
||||
assert.equal(declarationsFor('.sort-button').position, undefined);
|
||||
assert.equal(declarationsFor('.sort-button', COARSE).position, undefined);
|
||||
// 45 px over the 1 px rule leaves a 44 px padding box.
|
||||
const rule = px(declarationsFor('thead th')['border-bottom'].split(' ')[0]);
|
||||
assert.equal(px(declarationsFor(COLUMN_HEADER_CELL, COARSE).height) - rule, 44);
|
||||
});
|
||||
|
||||
test('legend chips keep their box: they touch, so a hit box would move each tap area onto a neighbour (CT1)', () => {
|
||||
for (const selector of ['.legend-item', 'button.legend-item', '.legend-item::after', 'button.legend-item::after']) {
|
||||
assert.deepEqual(declarationsFor(selector, COARSE), {}, selector);
|
||||
}
|
||||
});
|
||||
|
||||
test('fine pointers keep the tight targets (CT1, FU11)', () => {
|
||||
const grown = [...GROWN_SQUARE, ...GROWN_TALL].filter(selector => declarationsFor(selector)['min-height'] === '44px');
|
||||
const hitBoxes = HIT_BOXES.filter(selector => declarationsFor(selector).content !== undefined);
|
||||
assert.deepEqual([...grown, ...hitBoxes], []);
|
||||
assert.equal(declarationsFor(COLUMN_HEADER_CELL).height, undefined);
|
||||
assert.equal(declarationsFor('.filter-input input[type="text"]')['padding-right'], '28px');
|
||||
});
|
||||
|
||||
test('every button without a ring of its own draws the accent focus ring (CT2)', () => {
|
||||
for (const selector of ['button:focus-visible', '[role="button"]:focus-visible']) {
|
||||
const ring = declarationsFor(selector);
|
||||
assert.equal(ring.outline, '2px solid var(--accent)', selector);
|
||||
assert.equal(ring['outline-offset'], '2px', selector);
|
||||
}
|
||||
// The identity caret's own rule, at (0,2,0), outranks the button rule.
|
||||
assert.equal(declarationsFor('.identity-disclosure:focus-visible').outline, '2px solid var(--fg)');
|
||||
});
|
||||
|
||||
test('chat tabs draw the ring inside the tab strip that clips it (CT2)', () => {
|
||||
assert.equal(declarationsFor('.chat-tab:focus-visible')['outline-offset'], '-2px');
|
||||
});
|
||||
|
||||
test('hidden hides the controls that set their own display (CT3)', () => {
|
||||
const painted = ['.protocol-toggle-btn[hidden]', '.filter-clear[hidden]', '.header-federation[hidden]', '.map-toolbar button[hidden]']
|
||||
.filter(selector => declarationsFor(selector).display !== 'none');
|
||||
assert.deepEqual(painted, []);
|
||||
});
|
||||
|
||||
test('the live toggle sits at the end of its row, so counts filling in never move it (CT3)', () => {
|
||||
assert.equal(declarationsFor('#autorefreshToggle')['margin-left'], 'auto');
|
||||
});
|
||||
|
||||
test('on phones the vital-sign line owns a reserved row of its own (CT3)', () => {
|
||||
const line = declarationsFor('.meta-active-nodes', PHONE);
|
||||
const row = { order: line.order, 'flex-basis': line['flex-basis'], 'line-height': line['line-height'], 'min-height': line['min-height'] };
|
||||
assert.deepEqual(row, { order: '1', 'flex-basis': '100%', 'line-height': '1.4', 'min-height': '1.4em' });
|
||||
});
|
||||
@@ -5967,8 +5967,9 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
// --- Auto-refresh play/pause toggle ---
|
||||
// Live vs. paused is visible text, not a glyph-only secret (SPEC UX6):
|
||||
// `\u25CF live` while streaming, `\u275A\u275A paused HH:MM` when frozen.
|
||||
// Live vs. paused is visible text that names the action (SPEC UX6, CT4):
|
||||
// `\u25CF live \u00B7 pause` while streaming,
|
||||
// `\u275A\u275A paused HH:MM \u00B7 resume` when frozen.
|
||||
if (autorefreshToggle) {
|
||||
applyAutorefreshControlState(autorefreshToggle, autorefreshControlState(false, null));
|
||||
autorefreshToggle.addEventListener('click', () => {
|
||||
|
||||
@@ -14,36 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Regression guard for audit finding D-010 (SPEC UX6 / ACCEPTANCE UX-A4):
|
||||
// live vs. paused must be visible text, not a glyph-only secret — `● live`
|
||||
// while streaming, `❚❚ paused HH:MM` when frozen.
|
||||
// Regression guard for audit finding D-010 (SPEC UX6, CT4 / ACCEPTANCE UX-A4,
|
||||
// CT-A4): live vs. paused must be visible text, not a glyph-only secret, and
|
||||
// the text names what a tap does: `● live · pause` while streaming,
|
||||
// `❚❚ paused HH:MM · resume` when frozen. The accessible name starts with the
|
||||
// visible state words, so it holds the visible label at every width (WCAG
|
||||
// 2.5.3), the action hidden from view on phones included.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
autorefreshControlState,
|
||||
pauseTimestampText,
|
||||
applyAutorefreshControlState,
|
||||
} from '../autorefresh-control.js';
|
||||
import { createLiveDocument } from '../../__tests__/live-dom-model.js';
|
||||
import { declarationsFor } from '../../__tests__/base-css-rules.js';
|
||||
|
||||
test('live state renders the accent dot with visible text', () => {
|
||||
/** Class of the span carrying the action; base.css hides it on phones. */
|
||||
const ACTION_CLASS = 'autorefresh-toggle__action';
|
||||
|
||||
/** The dashboard layout, which server-renders the toggle. */
|
||||
const LAYOUT_PATH = fileURLToPath(new URL('../../../../../../views/layouts/app.erb', import.meta.url));
|
||||
|
||||
/**
|
||||
* A `<button>` of the live DOM model, with the `ownerDocument` the browser
|
||||
* gives every element.
|
||||
*
|
||||
* @returns {Object} Live button element.
|
||||
*/
|
||||
function liveButton() {
|
||||
const { document } = createLiveDocument();
|
||||
const button = document.createElement('button');
|
||||
button.ownerDocument = document;
|
||||
return button;
|
||||
}
|
||||
|
||||
test('live state names the pause action after the accent dot', () => {
|
||||
const state = autorefreshControlState(false, null);
|
||||
assert.equal(state.text, '● live');
|
||||
assert.equal(state.ariaLabel, 'Pause auto-refresh');
|
||||
assert.equal(state.text, '● live · pause');
|
||||
assert.equal(state.status, '● live');
|
||||
assert.equal(state.ariaLabel, 'live, pause auto-refresh');
|
||||
assert.equal(state.ariaPressed, 'false');
|
||||
});
|
||||
|
||||
test('paused state renders the pause bars with the freeze timestamp', () => {
|
||||
test('paused state keeps the freeze timestamp and names the resume action', () => {
|
||||
const state = autorefreshControlState(true, '14:32');
|
||||
assert.equal(state.text, '❚❚ paused 14:32');
|
||||
assert.equal(state.ariaLabel, 'Resume auto-refresh');
|
||||
assert.equal(state.text, '❚❚ paused 14:32 · resume');
|
||||
assert.equal(state.status, '❚❚ paused 14:32');
|
||||
assert.equal(state.ariaLabel, 'paused 14:32, resume auto-refresh');
|
||||
assert.equal(state.ariaPressed, 'true');
|
||||
});
|
||||
|
||||
test('paused state without a timestamp still names the state', () => {
|
||||
test('paused state without a timestamp still names the state and the action', () => {
|
||||
const state = autorefreshControlState(true, '');
|
||||
assert.equal(state.text, '❚❚ paused');
|
||||
assert.equal(state.text, '❚❚ paused · resume');
|
||||
assert.equal(state.status, '❚❚ paused');
|
||||
assert.equal(state.ariaLabel, 'paused, resume auto-refresh');
|
||||
});
|
||||
|
||||
test('the accessible name starts with the visible state words and names the action (WCAG 2.5.3)', () => {
|
||||
for (const state of [autorefreshControlState(false, null), autorefreshControlState(true, '14:32'), autorefreshControlState(true, '')]) {
|
||||
// The state words follow the glyph and stay visible on phones; the action
|
||||
// follows the separator and shows from 660 px up.
|
||||
const words = state.status.slice(state.status.indexOf(' ') + 1);
|
||||
const action = state.text.slice(state.text.lastIndexOf(' · ') + 3);
|
||||
assert.ok(state.ariaLabel.startsWith(`${words}, `), `${state.ariaLabel} starts with ${words}`);
|
||||
assert.ok(state.ariaLabel.includes(`${action} auto-refresh`), `${state.ariaLabel} names ${action}`);
|
||||
assert.doesNotMatch(state.ariaLabel, /[●❚]/, 'the glyph stays visual');
|
||||
}
|
||||
});
|
||||
|
||||
test('pauseTimestampText renders a zero-padded 24h clock', () => {
|
||||
@@ -56,20 +98,53 @@ test('pauseTimestampText tolerates an invalid date', () => {
|
||||
assert.equal(pauseTimestampText(null), '');
|
||||
});
|
||||
|
||||
test('applyAutorefreshControlState writes text and aria state to the button', () => {
|
||||
const attrs = {};
|
||||
const button = {
|
||||
textContent: '',
|
||||
setAttribute: (name, value) => {
|
||||
attrs[name] = value;
|
||||
},
|
||||
};
|
||||
test('applyAutorefreshControlState renders the action in its own span and writes the aria state', () => {
|
||||
const button = liveButton();
|
||||
applyAutorefreshControlState(button, autorefreshControlState(true, '08:01'));
|
||||
assert.equal(button.textContent, '❚❚ paused 08:01');
|
||||
assert.equal(attrs['aria-label'], 'Resume auto-refresh');
|
||||
assert.equal(attrs['aria-pressed'], 'true');
|
||||
assert.equal(button.textContent, '❚❚ paused 08:01 · resume');
|
||||
assert.equal(button.innerHTML, `❚❚ paused 08:01<span class="${ACTION_CLASS}"> · resume</span>`);
|
||||
assert.equal(button.getAttribute('aria-label'), 'paused 08:01, resume auto-refresh');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
test('applyAutorefreshControlState replaces the previous label', () => {
|
||||
const button = liveButton();
|
||||
applyAutorefreshControlState(button, autorefreshControlState(true, '08:01'));
|
||||
applyAutorefreshControlState(button, autorefreshControlState(false, null));
|
||||
assert.equal(button.innerHTML, `● live<span class="${ACTION_CLASS}"> · pause</span>`);
|
||||
assert.equal(button.getAttribute('aria-label'), 'live, pause auto-refresh');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'false');
|
||||
});
|
||||
|
||||
test('applyAutorefreshControlState tolerates a missing button', () => {
|
||||
assert.doesNotThrow(() => applyAutorefreshControlState(null, autorefreshControlState(false, null)));
|
||||
});
|
||||
|
||||
test('the server renders the live state, so the first apply changes nothing (CT3, CT4)', () => {
|
||||
const served = readFileSync(LAYOUT_PATH, 'utf8').match(/<button id="autorefreshToggle"([^>]*)>([^]*?)<\/button>/);
|
||||
assert.ok(served, 'app.erb renders #autorefreshToggle');
|
||||
const live = autorefreshControlState(false, null);
|
||||
assert.ok(served[1].includes(`aria-label="${live.ariaLabel}"`), served[1]);
|
||||
assert.ok(served[1].includes(`aria-pressed="${live.ariaPressed}"`), served[1]);
|
||||
const parsed = liveButton();
|
||||
parsed.innerHTML = served[2];
|
||||
const applied = liveButton();
|
||||
applyAutorefreshControlState(applied, live);
|
||||
assert.equal(parsed.innerHTML, applied.innerHTML);
|
||||
});
|
||||
|
||||
test('base.css draws the toggle as a pill, framed in the accent while paused (CT4)', () => {
|
||||
const live = declarationsFor('#autorefreshToggle[aria-pressed="false"]');
|
||||
const paused = declarationsFor('#autorefreshToggle[aria-pressed="true"]');
|
||||
assert.equal(declarationsFor('#autorefreshToggle')['border-radius'], '999px');
|
||||
assert.equal(live.color, 'var(--accent)');
|
||||
assert.equal(paused.color, 'var(--muted)');
|
||||
assert.equal(paused['border-color'], 'var(--accent)');
|
||||
});
|
||||
|
||||
test('base.css hides the action on phones only, leaving it in the DOM (CT4)', () => {
|
||||
const phone = declarationsFor(`.${ACTION_CLASS}`, { media: '(max-width: 659px)' });
|
||||
const hidden = { position: phone.position, width: phone.width, overflow: phone.overflow, clip: phone.clip, display: phone.display };
|
||||
assert.deepEqual(hidden, { position: 'absolute', width: '1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', display: undefined });
|
||||
assert.equal(declarationsFor(`.${ACTION_CLASS}`).position, undefined, 'the action shows from 660 px up');
|
||||
});
|
||||
@@ -15,36 +15,56 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Visible live/paused state for the auto-refresh toggle (SPEC UX6, audit
|
||||
* Visible live/paused state for the auto-refresh toggle (SPEC UX6, CT4, audit
|
||||
* D-010).
|
||||
*
|
||||
* The single most important status on a live dashboard — whether the data is
|
||||
* streaming or frozen — must be readable text, not a glyph-only secret:
|
||||
* `● live` while streaming, `❚❚ paused HH:MM` (the freeze moment) when
|
||||
* paused. The helpers are pure so the control's contract is unit-testable;
|
||||
* the app applies them to the existing `#autorefreshToggle` button.
|
||||
* streaming or frozen — must be readable text, not a glyph-only secret, and
|
||||
* the text names what a tap does: `● live · pause` while streaming,
|
||||
* `❚❚ paused HH:MM · resume` (the freeze moment) when paused. The action
|
||||
* renders in its own span, which base.css hides on phones so pausing does not
|
||||
* wrap the meta row. The accessible name starts with the visible state words,
|
||||
* so it holds the visible label at every width (WCAG 2.5.3), and goes on with
|
||||
* the action and what it acts on: `live, pause auto-refresh`. The helpers are
|
||||
* pure so the control's contract is unit-testable; the app applies them to the
|
||||
* existing `#autorefreshToggle` button, whose server-rendered markup is the
|
||||
* live state.
|
||||
*
|
||||
* @module main/autorefresh-control
|
||||
*/
|
||||
|
||||
/** Class of the span that carries the action (` · pause`, ` · resume`). */
|
||||
const ACTION_CLASS = 'autorefresh-toggle__action';
|
||||
|
||||
/**
|
||||
* Build one control state from its parts, so the visible label and the
|
||||
* accessible name cannot drift apart.
|
||||
*
|
||||
* @param {string} glyph State glyph (`●`, `❚❚`); visual only, left out of the name.
|
||||
* @param {string} words State words, visible at every width (`live`, `paused 14:32`).
|
||||
* @param {string} action What a tap does (`pause`, `resume`).
|
||||
* @param {string} ariaPressed `aria-pressed` value: `true` while paused.
|
||||
* @returns {{text: string, status: string, ariaLabel: string, ariaPressed: string}} Control state.
|
||||
*/
|
||||
function controlState(glyph, words, action, ariaPressed) {
|
||||
const status = `${glyph} ${words}`;
|
||||
return { text: `${status} · ${action}`, status, ariaLabel: `${words}, ${action} auto-refresh`, ariaPressed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the toggle's visible text and ARIA state.
|
||||
*
|
||||
* @param {boolean} paused Whether auto-refresh (stream + safety poll) is paused.
|
||||
* @param {?string} pausedAtText Clock text of the pause moment (`HH:MM`), when
|
||||
* known; blank/omitted renders the bare paused state.
|
||||
* @returns {{text: string, ariaLabel: string, ariaPressed: string}} Control state.
|
||||
* @returns {{text: string, status: string, ariaLabel: string, ariaPressed: string}}
|
||||
* Control state: `text` is the whole visible label and starts with `status`,
|
||||
* the glyph and state words that stay visible on phones; the rest names the
|
||||
* action. `ariaLabel` starts with the state words.
|
||||
*/
|
||||
export function autorefreshControlState(paused, pausedAtText) {
|
||||
if (!paused) {
|
||||
return { text: '● live', ariaLabel: 'Pause auto-refresh', ariaPressed: 'false' };
|
||||
}
|
||||
const stamp = pausedAtText ? ` ${pausedAtText}` : '';
|
||||
return {
|
||||
text: `❚❚ paused${stamp}`,
|
||||
ariaLabel: 'Resume auto-refresh',
|
||||
ariaPressed: 'true',
|
||||
};
|
||||
if (!paused) return controlState('●', 'live', 'pause', 'false');
|
||||
return controlState('❚❚', pausedAtText ? `paused ${pausedAtText}` : 'paused', 'resume', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,16 +82,20 @@ export function pauseTimestampText(date) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a control state onto the toggle button.
|
||||
* Write a control state onto the toggle button: the state words as text, then
|
||||
* the action in its own span (`● live<span …> · pause</span>`).
|
||||
*
|
||||
* @param {?Element} button The `#autorefreshToggle` element.
|
||||
* @param {{text: string, ariaLabel: string, ariaPressed: string}} state State
|
||||
* from {@link autorefreshControlState}.
|
||||
* @param {{text: string, status: string, ariaLabel: string, ariaPressed: string}} state
|
||||
* State from {@link autorefreshControlState}.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applyAutorefreshControlState(button, state) {
|
||||
if (!button || typeof button.setAttribute !== 'function') return;
|
||||
button.textContent = state.text;
|
||||
const action = button.ownerDocument.createElement('span');
|
||||
action.setAttribute('class', ACTION_CLASS);
|
||||
action.textContent = state.text.slice(state.status.length);
|
||||
button.replaceChildren(state.status, action);
|
||||
button.setAttribute('aria-label', state.ariaLabel);
|
||||
button.setAttribute('aria-pressed', state.ariaPressed);
|
||||
}
|
||||
@@ -3272,6 +3272,8 @@ button.legend-item[aria-pressed="true"] {
|
||||
#autorefreshToggle {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
border-radius: 999px; /* a pill, so it reads as a control (CT4) */
|
||||
margin-left: auto; /* row end: counts filling in never move it (CT3) */
|
||||
}
|
||||
|
||||
#autorefreshToggle[aria-pressed="false"] {
|
||||
@@ -3280,6 +3282,7 @@ button.legend-item[aria-pressed="true"] {
|
||||
|
||||
#autorefreshToggle[aria-pressed="true"] {
|
||||
color: var(--muted);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Promoted day count in the vital-sign line (UX11, D-026). */
|
||||
@@ -3288,6 +3291,129 @@ button.legend-item[aria-pressed="true"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* `hidden` hides these (CT3). An author `display` beats the UA sheet's
|
||||
`[hidden]`, and each sets one (`.icon-button`, `.filter-clear` and
|
||||
`.map-toolbar button` inline-flex, `.header-federation` flex), so a hidden
|
||||
toggle painted as an empty box from first paint. */
|
||||
.protocol-toggle-btn[hidden],
|
||||
.filter-clear[hidden],
|
||||
.header-federation[hidden],
|
||||
.map-toolbar button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 659px) {
|
||||
/* The vital-sign line owns a reserved row, so the meta row's height is
|
||||
final at first paint (CT3). */
|
||||
.meta-active-nodes {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
line-height: 1.4;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
/* The toggle's action hides from view, so pausing does not wrap the toggle
|
||||
row; the aria-label still names it (CT4). */
|
||||
.autorefresh-toggle__action {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* One accent focus ring for every button and `role="button"` control (CT2).
|
||||
At (0,1,1) the component rules (0,2,0) keep their own ring. */
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The tab strip scrolls, and a scroll container clips an outset ring. */
|
||||
.chat-tab:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 44 px touch targets on coarse pointers (CT1, extends FU11 and RA11).
|
||||
Standalone controls grow. Three dense controls keep their box and get a
|
||||
transparent hit box: the filter's × the chat badge's centred one, the
|
||||
short-info close one that grows from inside its popover's corner, and a sort
|
||||
button its whole header cell. None of these reaches a node row or another
|
||||
button. Legend chips keep their box: they touch, so a hit box would only
|
||||
move each chip's tap area onto its neighbour. Fine pointers keep the tight
|
||||
targets. The Leaflet zoom and the mobile-menu links and close are sized by
|
||||
their own rules. */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:not(.mobile-menu__close),
|
||||
.map-toolbar button,
|
||||
.node-detail-overlay__close {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.protocol-toggle-btn,
|
||||
#autorefreshToggle,
|
||||
.legend-toggle-button,
|
||||
.chat-tab,
|
||||
.chat-tab-select,
|
||||
.filter-input input[type="text"] {
|
||||
box-sizing: border-box; /* the text input is content-box */
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* The text stops short of the ×'s hit box, which reaches 38 px in from
|
||||
the box's right edge (inset 6 px, 20 px wide, 44 px box). */
|
||||
.filter-input input[type="text"] {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* The × is already absolutely positioned. */
|
||||
.filter-clear::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* The close sits 5 px inside the popover's top-right corner. Its hit box
|
||||
grows down and left from 3 px inside that corner, so it stays within the
|
||||
popover's 8 px rounding instead of reaching the rows beneath. */
|
||||
.short-info-overlay .short-info-close::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* The column row grows to 45 px, a 44 px padding box over its 1 px rule
|
||||
(it is 28 px where no label wraps). The sticky `th` is the containing
|
||||
block of the sort button's hit box, so the box fills the cell and never
|
||||
reaches the group row above or the first node row below. */
|
||||
#nodes thead tr:not(.nodes-group-header) th {
|
||||
box-sizing: border-box;
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
#nodes thead .sort-button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* MeshCore marker chips (UX7, D-013; audit follow-up 03): shape encodes
|
||||
protocol. The fill is a round(radius × 1.78) square rotated 45° into an
|
||||
equal-area diamond with rounded corners; the rotated diagonal exceeds the
|
||||
|
||||
@@ -303,7 +303,9 @@
|
||||
<span id="footerActiveNodes" class="meta-active-nodes"></span>
|
||||
<%# The join strip moved to the footer (audit follow-up 04), freeing the
|
||||
meta row for the per-minute vital signs. %>
|
||||
<button id="autorefreshToggle" type="button" aria-label="Pause auto-refresh" aria-pressed="false" title="Pause or resume automatic data refresh">● live</button>
|
||||
<%# The live state as autorefresh-control.js renders it, so the first
|
||||
apply changes nothing; phones hide the action span (SPEC CT4). %>
|
||||
<button id="autorefreshToggle" type="button" aria-label="live, pause auto-refresh" aria-pressed="false" title="Pause or resume automatic data refresh">● live<span class="autorefresh-toggle__action"> · pause</span></button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
Reference in new issue
Block a user