From ff278b6147dcb6f394960afb52a4c75ee80a5e4b Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:14:52 +0200 Subject: [PATCH] web: honour hidden, 44px touch targets, focus ring, named live toggle (#1009) --- CHANGELOG.md | 4 + .../js/app/__tests__/controls-css.test.js | 192 ++++++++++++++++++ web/public/assets/js/app/main.js | 5 +- .../__tests__/autorefresh-control.test.js | 119 +++++++++-- .../assets/js/app/main/autorefresh-control.js | 62 ++++-- web/public/assets/styles/base.css | 126 ++++++++++++ web/views/layouts/app.erb | 4 +- 7 files changed, 468 insertions(+), 44 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/controls-css.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1964c9fa..bbc36390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/web/public/assets/js/app/__tests__/controls-css.test.js b/web/public/assets/js/app/__tests__/controls-css.test.js new file mode 100644 index 00000000..62dcafc1 --- /dev/null +++ b/web/public/assets/js/app/__tests__/controls-css.test.js @@ -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>} wanted Expected `[selector, property, value]` rows. + * @returns {Array>} 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' }); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index a7e9394f..491c53b3 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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', () => { diff --git a/web/public/assets/js/app/main/__tests__/autorefresh-control.test.js b/web/public/assets/js/app/main/__tests__/autorefresh-control.test.js index 29471e7b..02a15925 100644 --- a/web/public/assets/js/app/main/__tests__/autorefresh-control.test.js +++ b/web/public/assets/js/app/main/__tests__/autorefresh-control.test.js @@ -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 ` + <%# The live state as autorefresh-control.js renders it, so the first + apply changes nothing; phones hide the action span (SPEC CT4). %> + <% end %>