web: honour hidden, 44px touch targets, focus ring, named live toggle (#1009)

This commit is contained in:
Afri authored and GitHub committed 2026-10-08 17:14:52 +02:00
1 parent a8fe2962c0
commit ff278b6147
7 files changed
+468 -44

No files matched your search

+4
View File
@@ -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' });
});
+3 -2
View File
@@ -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);
}
+126
View File
@@ -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
+3 -1
View File
@@ -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 %>