🦄 Sync 2026-09-06 03:35:37

This commit is contained in:
github-actions[bot] committed 2026-09-06 03:35:37 +08:00
1 parent dbdd70b00d
commit 8ca86b3a5e
14 files changed
+516 -44

No files matched your search

+4 -4
View File
@@ -16,7 +16,7 @@ start() {
[ "$enabled" -eq "1" ] || return 1
local listen_port root_path root_name
config_get listen_port "config" "listen_port" "8989"
config_get listen_port "config" "listen_port" "8787"
config_get root_path "config" "root_path" "/"
# 路径为 / 时 name 设为 root,否则留空
@@ -43,9 +43,9 @@ userDefaults:
EOF
else
grep -q " name:" "$CONF_PATH" || sed -i "s,.*- path:.*,&\n name: \"\"," "$CONF_PATH"
sed -e "s/.*port:.*/ port: $listen_port/" \
-e "s,.*- path:.*, - path: \"$root_path\"," \
-e "s/.*name:.*/ name: \"$root_name\"/" \
sed -e "s/ port:.*/ port: $listen_port/" \
-e "s, - path:.*, - path: \"$root_path\"," \
-e "s/ name: \"root\"/ name: \"$root_name\"/" \
-i "$CONF_PATH"
fi
@@ -76,7 +76,7 @@ return view.extend({
o = s.option(form.Value, 'listen_port', _('Listen port'));
o.datatype = 'port';
o.default = '8989';
o.default = '8787';
o.rmempty = false;
o = s.option(form.Value, 'root_path', _('Root directory'));
+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=60
PKG_RELEASE:=61
endif
LUCI_TITLE:=Footstrap Theme
@@ -480,6 +480,35 @@ function wireRail() {
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
* not export it). */
function syncIndAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
* span: no role, name or tabindex, so Tab skips it and a screen reader
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
el.click();
});
}
function wireIndicatorCounts() {
const box = document.getElementById('indicators');
if (!box) return;
@@ -491,6 +520,18 @@ function wireIndicatorCounts() {
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
el.setAttribute('title', txt);
/* Only a CLICKABLE indicator becomes a control. `ui.showIndicator` sets
* `data-clickable` only when it was handed a handler, and a status-only pill is
* given none (luci-base ui.js; `battstatus.js` calls it with a null handler) — a
* `role="button"` tabstop with nothing behind it is worse than the bare span it
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
* `[data-clickable]` for the same reason; the two must not disagree. */
if (el.hasAttribute('data-clickable')) {
syncIndAttr(el, 'role', 'button');
syncIndAttr(el, 'tabindex', '0');
syncIndAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
}
@@ -3,6 +3,7 @@
'require dom';
'require network';
'require fs-fit as fit';
'require menu-footstrap-common as common';
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
* Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a
@@ -23,11 +24,15 @@ const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto
/* the data-page value four call sites compare against; a string literal is not mangled, so a
* repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */
function sectionTitle(sec) {
function headerEl(sec) {
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
* a bare `<h3>` as the section's first child. Matching only one silently disables the grid on
* the other. */
const h = sec.querySelector('.cbi-title h3, :scope > h3');
return sec.querySelector('.cbi-title h3, :scope > h3');
}
function sectionTitle(sec) {
const h = headerEl(sec);
if (!h) return '';
/* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show <span> inside the
* same <h3>, so `firstChild` depends on upstream keeping the words first */
@@ -39,6 +44,101 @@ function sectionTitle(sec) {
return '';
}
/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ----
*
* Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the
* topbar poll pill plus one Hide/Show toggle per card — and every one is a bare <span>: no
* tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run
* of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A).
* docs/findings.md, "A card cannot be collapsed from the keyboard".
*
* index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone
* clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler,
* not a competing one. */
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
* no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than
* imported, since that file does not export it. */
function syncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed
* (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the
* same way. */
function pillExpanded(label) {
return label.getAttribute('data-style') !== 'active';
}
/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was
* measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without
* aria-controls rather than guessed at — role, tabindex, aria-expanded and the keyboard below still
* apply there; aria-controls is the one piece this function cannot state with confidence. */
function panelFor(h) {
const holder = h.closest('.cbi-title');
return holder ? holder.nextElementSibling : null;
}
let _panelSeq = 0;
/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header
* already wired touches only aria-expanded, and only when it actually changed; the click/keydown
* listeners are added once (`dataset.fsWired`), never re-added. */
function wireDisclosure(sec) {
const h = headerEl(sec);
const label = h && h.querySelector('.label[data-indicator="poll-status"]');
if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */
const panel = panelFor(h);
if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++);
syncAttr(h, 'role', 'button');
syncAttr(h, 'tabindex', '0');
syncAttr(h, 'aria-controls', panel ? panel.id : null);
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
/* the header now carries the pill's name, role and state; a screen reader user tabbing past it
* to a second, unlabelled clickable span would hear an unexplained duplicate control — same
* reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */
syncAttr(label, 'aria-hidden', 'true');
if (h.dataset.fsWired) return;
h.dataset.fsWired = '1';
h.addEventListener('click', (ev) => {
/* a click landing on the pill itself already ran index.js's own handler; forwarding here
* too would toggle the card twice */
if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click();
syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false');
});
/* the pill is an <a>-less <span>, so neither key is native here — contrast
* fs-widgets.js's wireSpaceKey, written for an <a role="button">, which gets Enter for free
* and needs only Space added */
h.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
label.click();
});
}
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
* "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is
* not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is
* suppressed rather than left as a rung in the tab order with nothing behind its own name. */
function hideEmptyCard(sec) {
if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty');
}
function tidyCards(view) {
view.querySelectorAll('.cbi-section').forEach((sec) => {
hideEmptyCard(sec);
wireDisclosure(sec);
});
}
/* the wrapper we built, so the poll-tick fast path costs one property read */
let _wrapEl = null;
@@ -75,6 +175,18 @@ function arrange() {
if (!view) return;
nameTooltips(view);
/* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not
* the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those
* bars need are stamped on here instead — on the same poll-tick callback nameTooltips() already
* runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every
* tick even when the wrapper survives, so a bar's reading is stale on every tick this line is
* skipped. */
common.annotateMeters(view);
/* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the
* wrapper survives, so a card that is only now getting its title text needs wiring on this
* pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(),
* hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */
tidyCards(view);
/* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds
* the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a
@@ -276,7 +388,10 @@ function patchOverview() {
/* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here:
* a stock include calls them bare from its own render(), so they must exist before the view class
* does, and this page module is required during the navigation that races it. */
* does, and this page module is required during the navigation that races it. `annotateMeters` is
* required from that same module (see arrange()) rather than duplicated: it is not a template
* global, so nothing forces it to live there, but the threshold split and label lookup it shares
* with `window.progressbar` would drift into two copies otherwise. */
return baseclass.extend({
/* called once by menu-footstrap-common's init; everything route-dependent hangs off the
@@ -316,6 +316,66 @@ function labelCells(t, head) {
}
}
/* ---- WCAG 1.3.1: a `display` override must not cost a table its own roles ----
*
* theme/*.css restyles `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over
* twenty places — `.fs-stacked`, `.fs-rowstack`, the config table's own `@container`, the phone
* `@media`, the Overview meter row, the software page's carded description column, the 560px
* key/value card added alongside this file — scattered across selectors this module cannot see the
* live state of (a media query, a container query, a class some other pass toggled). A `display`
* other than `table`/`table-row`/`table-cell`/… drops the IMPLICIT `table`/`row`/`cell` role
* HTML-AAM derives from it: measured on Overview's key/value cards, `display: block`/`grid`/`block`
* (table/row/cell) at both 1440 and 390px, and on the 390px card stack, `block`/`none`/`block` —
* neither carries a role, so the column a value belongs to is never announced.
*
* Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a
* race with every one of them — the role is written UNCONDITIONALLY, matching what a plain
* `<table>` already carries implicitly. That costs nothing while the table IS a table and survives
* the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future
* `display` rule on one of these classes with no role to match is what it is written to catch.
*
* The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with
* nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per
* class keeps it whole without walking each table by hand: `.tr`/`.th`/`.td` are LuCI's own names,
* always written together with `.table` (adoptMarkup() above never adds one without the others), so
* a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a
* div-based table; a REAL `<thead>`/`<tbody>`/`<tfoot>` this file never classes (20_lan.js's and
* 30_wifi.js's `<tfoot>`, theme/30-tables.css:570/634) is reached by tag name instead. */
const TABLE_ROLE_CLASSES = [
[ '.table', 'table' ],
[ '.thead', 'rowgroup' ],
[ '.tbody', 'rowgroup' ],
[ '.tfoot', 'rowgroup' ],
[ '.tr', 'row' ],
[ '.th', 'columnheader' ],
[ '.td', 'cell' ],
];
const TABLE_ROLE_TAGS = [
[ 'thead', 'rowgroup' ],
[ 'tbody', 'rowgroup' ],
[ 'tfoot', 'rowgroup' ],
];
/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries
* the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches
* no DOM and wakes no attribute observer. Not imported for one line — this file has none of that
* module's other exports to justify the dependency. */
function setRole(el, role) {
if (el.getAttribute('role') !== role) el.setAttribute('role', role);
}
/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/
* `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written
* here changes nothing about that, since a role is inert on a box the engine never generates. */
function roleTables() {
TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
TABLE_ROLE_TAGS.forEach(([ sel, role ]) => {
document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role));
});
}
/* ---- card-stack a data table that no longer fits ----
*
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the decision.
@@ -890,12 +950,14 @@ return baseclass.extend({
fit.armGate();
/* A table must be tagged .fs-dt before it can be fitted, and re-tagged whenever the poll
* brings a fresh one back, so tagging leads.
* brings a fresh one back, so tagging leads; roleTables() follows it directly so a class
* adoptMarkup() just added carries its role in the same pass rather than waiting a tick.
*
* Five registrations, not one callback: fs-fit catches per registered fitter, and the first
* Six registrations, not one callback: fs-fit catches per registered fitter, and the first
* of these walks third-party markup. Bundled, one throw in `tagDataTables()` leaves no table
* tagged and — with the gate above raised — a page with no tables on it at all. */
fit.add(tagDataTables);
fit.add(roleTables);
fit.add(fitTables);
fit.add(fitScrollables);
fit.add(unpinActionColumn);
@@ -153,6 +153,88 @@ function loadPlugins() {
});
}
/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement —
* 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a
* plain percentage is kept anyway so every meter on the page reads the same way. */
const FS_METER_WARN = 80;
const FS_METER_DANGER = 92;
/* Write an attribute only when the value actually changes, so a poll tick that reads the same
* numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes
* the attribute instead of writing the string "null". */
function fsSyncAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own
* label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU
* load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space
* bar, the package manager) gets no `aria-label` written at all, and Chromium's accessible-name
* computation then falls back to `title` — which is the reading, not a name ("95 / 100 (95%)"),
* measured with `Accessibility.getPartialAXTree`. That fallback cannot be closed from here: `title`
* is the ONLY source `::after { content: attr(title) }` has for the visible percentage
* (styles/theme/25-progressbar.css), so removing it would blank the bar for a sighted reader too. */
function findProgressbarLabel(pg) {
const row = pg.closest('.cbi-value');
let label = row ? row.querySelector('.cbi-value-title') : null;
if (!label) {
const tr = pg.closest('.tr');
if (tr) {
const own = pg.closest('.td, td');
const cell = tr.querySelector('.td, td');
if (cell && cell !== own) label = cell;
}
}
return label;
}
/* The percentage inside a meter's `title`, in either shape a caller writes it in: `window.progressbar`
* below composes `'%s / %s (%d%%)'`, parenthesised and preceded by the byte/localised reading, but a
* bare meter markup may carry just `'97%'` with nothing around it (docs/gallery.html). Both are
* anchored at the end of the string so neither can match a stray "%" earlier in a localised reading;
* an empty title or one with no percentage at all yields null, on purpose — nothing to annotate.
* `%d` is the unclamped percentage, so the result can still read past 100 or under 0 and is clamped
* by the caller, the same way `window.progressbar` clamps its own `level`, below. Exported for
* tests/meter.test.mjs, which is the only caller that needs the parse on its own. */
function parseMeterPercent(title) {
if (title == null) return null;
const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title);
return m ? parseInt(m[1], 10) : null;
}
/* The attribute work `window.progressbar` and a bare `.cbi-progressbar` node both need — Status ->
* Overview's own 20_memory.js, 25_storage.js and 30_network.js (luci-mod-status) declare and call
* their OWN local `progressbar()`, a straight copy of the same upstream body, so this theme's
* global never runs on the meters those stock includes actually draw. `annotateMeters()` below
* walks the DOM after the fact instead of owning every writer; the threshold split and the name
* logic stay in this one function either way, so the two callers cannot drift apart. */
function annotateMeter(pg) {
const title = pg.getAttribute('title');
const pc = parseMeterPercent(title);
if (pc == null) return;
const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc);
fsSyncAttr(pg, 'role', 'progressbar');
fsSyncAttr(pg, 'aria-valuemin', '0');
fsSyncAttr(pg, 'aria-valuemax', '100');
fsSyncAttr(pg, 'aria-valuenow', String(level));
fsSyncAttr(pg, 'aria-valuetext', title);
const label = findProgressbarLabel(pg);
fsSyncAttr(pg, 'aria-label', label ? label.textContent.trim() : null);
fsSyncAttr(pg, 'data-fs-level',
level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null));
}
/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called
* from fs-overview.js's own poll-tick observer (see there for why this file does not run a second
* one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */
function annotateMeters(root) {
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
}
/* The three template globals Status -> Overview needs, defined where ordering is guaranteed.
*
* `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script
@@ -165,29 +247,45 @@ function loadPlugins() {
* ordering the two. This file is required by the footer on every page and evaluates before the
* router exists.
*
* Bodies are verbatim from upstream except for two deltas: L.itemlist -> window.L.itemlist (the
* two-L trap, docs/spa-router.md), and renderBox's `[title]` — dom.append parses a scalar child as
* Bodies are verbatim from upstream except for three deltas: L.itemlist -> window.L.itemlist (the
* two-L trap, docs/spa-router.md); renderBox's `[title]` — dom.append parses a scalar child as
* innerHTML (luci.js:1395) and an array member as text (:1383), and this file defines the global on
* every admin page where upstream defines it on Status -> Overview alone. Same output: nothing in
* 24.10, 25.12 or master calls renderBox. The typeof guards make each a no-op on a full page load,
* where the template's own copies win the race. */
* every admin page where upstream defines it on Status -> Overview alone; and progressbar's
* accessibility attributes (role, aria-value*, data-fs-level) — upstream's copy writes the reading
* into `title` alone, which nothing reads back for a screen reader off a generated `::after`. Same
* output on the other two: nothing in 24.10, 25.12 or master calls renderBox, and progressbar's
* `title` and fill width are untouched.
*
* `renderBox`/`renderBadge` keep the `typeof` guard: a full page load runs the template's inline
* `<script>` first, in document order, before this module evaluates, so upstream has already
* declared both — the guard just avoids overwriting an identical body. `progressbar` CANNOT reuse
* that guard: upstream's copy of the same name wins that exact race, and it is the one WITHOUT the
* accessibility attributes below. Guarded, the theme's version only ever won when a page other than
* Status -> Overview loaded first and the SPA router then navigated here without a reload — which
* is a real gap, not the common case. The assignment is unconditional so the accessible copy always
* wins, on a full load and on an SPA arrival alike. */
function ensureOverviewHelpers() {
/* eslint-disable no-var -- these three bodies are copies of LuCI's admin_status/index.ut so
they can be diffed against upstream when it changes. Modernising the `var`s would break
that property, which is what makes carrying the copies safe. */
if (typeof window.progressbar !== 'function')
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', '%s / %s (%d%%)'.format(fv, fm, pc));
}
};
window.progressbar = function(query, value, max, byte) {
var pg = document.querySelector(query),
vn = parseInt(value) || 0,
mn = parseInt(max) || 100,
fv = byte ? String.format('%1024.2mB', value) : value,
fm = byte ? String.format('%1024.2mB', max) : max,
pc = Math.floor((100 / mn) * vn),
reading = '%s / %s (%d%%)'.format(fv, fm, pc);
if (pg) {
pg.firstElementChild.style.width = pc + '%';
pg.setAttribute('title', reading);
/* Accessible value, colour and name: annotateMeter() re-parses `reading` back out
* of `title` rather than reusing `pc` here directly, so a bare `.cbi-progressbar`
* this function never touched is annotated the identical way — the clamp, the
* label lookup and the warn/danger split stay in that one function. */
annotateMeter(pg);
}
};
if (typeof window.renderBox !== 'function')
window.renderBox = function(title, active, childs) {
childs = childs || [];
@@ -235,6 +333,15 @@ return baseclass.extend({
* remember() for what a key is */
remember,
/* the seam fs-overview.js calls, on its own already-coalesced poll-tick observer, to annotate
* the meters a stock Status -> Overview include draws with its own local progressbar() */
annotateMeters,
/* module-private otherwise: tests/meter.test.mjs drives the percent parse and the clamp/threshold
* split directly rather than through a live `.cbi-progressbar[title]` walk */
parseMeterPercent, /* fs:probe */
annotateMeter, /* fs:probe */
init(renderMainMenu) {
/* First, and outside the promise: a third-party sheet that outranks the chrome is already
* painting (fs-sheets: openclash's `* { margin: 0; padding: 0 }`). Deferring this to
@@ -12,16 +12,24 @@
* drifted). Only the overview-specific chevron toggle stays.
* index.js emits a span with a raw "Hide"/"Show" text node + data-style (expanded =
* inactive, collapsed = active); data-indicator='poll-status' is unique to these
* toggles. font-size:0 hides the word, ::after borders draw the chevron. */
* toggles. font-size:0 hides the word, ::after borders draw the chevron.
*
* The card HEADER is the disclosure control now (fs-overview.js wireDisclosure()); this pill is
* marked aria-hidden there and stays only as its glyph, so the border and fill that made it read
* as its own 26px button are dropped — 16px, matching the rail toggle's own chevron glyph
* (theme/20-shell.css `.fs-railtoggle .fs-ico`), and --fs-faint rather than the pill's --fs-dim,
* a step quieter since it is decoration rather than a target now. */
.cbi-title .label[data-indicator="poll-status"] {
font-size: 0;
width: 26px;
height: 26px;
width: 16px;
height: 16px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--fs-radius-sm);
background: none;
border: 0;
color: var(--fs-faint);
}
/* The glyph itself is the Appearance fold's, to the byte — one chevron for "this opens", drawn
* in two page files because the two hosts are a stock element keyed by attribute and one of
@@ -47,6 +55,37 @@
/* @endmirror */
}
/* The header itself (fs-overview.js wireDisclosure() stamps role="button"): a hand cursor for a
* pointer user, a focus ring for a keyboard one — the same ring every other interactive control
* in this theme draws (theme/35-alerts.css `.cbi-title .label:focus-visible`, the pill's own
* former ring, now the header's).
*
* Two selectors, not one: fs-overview.js's headerEl() reads `.cbi-title h3, :scope > h3` because
* the wrapper itself differs by release — 25.12 renders `.cbi-title > h3`, 24.10 a bare `<h3>`
* straight inside `.cbi-section`, no `.cbi-title` at all. `[role="button"]` is the anchor either
* way, so a heading wireDisclosure() left untouched — one with nothing to collapse — is never
* matched. Measured on a 24.10 FORM: without the second arm the attributes and the keyboard
* worked but `cursor` stayed `auto`, `box-shadow` `none`, and focus drew only the UA outline.
* On a live 24.10 the arm does not fire at all today, and that is not a fault in it: OpenWrt
* 24.10.8 renders no hide/show control on an Overview card, so wireDisclosure() stamps nothing
* there and 0 of the page 14 `<h3>`s carry the role (measured on owlab owrt2410). The arm
* guards a SHAPE of markup, not a release — it is what keeps the styling with the behaviour on
* whichever release grows the control next. */
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
cursor: pointer;
border-radius: var(--fs-radius-sm);
}
:is(.cbi-title h3, .cbi-section > h3)[role="button"]:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
/* An empty `.cbi-section` LuCI still renders when a stock include has nothing to show: title
* "-", the poll pill its only content, 72px tall on the live Overview (seventh there — a
* fixture is not proof of position, docs/findings.md "An empty card with `-` as its title").
* Real and cosmetic, so fs-overview.js's hideEmptyCard() marks it and this drops it. */
body[data-page="admin-status-overview"] .cbi-section.fs-ovl-empty { display: none; }
/* Stock overview layout: fs-overview.js wraps the stock System / Memory / Storage
* sections in .fs-ovl; this is the grid only — System left across both rows, Memory +
* Storage right. Stock styling and data untouched. */
@@ -192,8 +192,13 @@
}
.fs-ap-verrow { display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-2-5); }
/* links to the project repo — reads as muted text until hovered */
.fs-ap-version { font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version { position: relative; font-size: var(--fs-type-xs); color: var(--fs-dim); font-variant-numeric: tabular-nums; text-decoration: none; }
.fs-ap-version:hover { color: var(--fs-accent); text-decoration: underline; }
/* the link's own box was 84x17 (owrt2512/390x844, Appearance tab), under the 24px floor of
* WCAG 2.2 SC 2.5.8 on height. Same fix as `.cbi-checkbox > label[for]::before` in
* theme/60-inputs.css: an invisible, absolutely-positioned overlay grows the hit box to
* 88x25 without moving the text or growing `.fs-ap-verrow`, since the overlay is out of flow. */
.fs-ap-version::before { content: ""; position: absolute; inset: -4px -2px; }
/* The line that names the missing catalogue package. Same weight as the version beside it —
* it is a fact about this router, not a warning: nothing is broken, one package is absent.
@@ -782,4 +782,14 @@
* so "Refreshing" showed a text caret. */
.fs-indicators [data-indicator][data-clickable],
#indicators [data-indicator][data-clickable] { cursor: pointer; }
/* fs-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so
* it is a control now and takes the control ring the buttons beside it take (55-buttons.css)
* rather than the UA outline it was left with. `:focus-visible`, not `:focus`: a pointer press
* on the pill should not draw a ring, the same rule every other control here follows. */
.fs-indicators [data-indicator][data-clickable]:focus-visible,
#indicators [data-indicator][data-clickable]:focus-visible {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
}
}
@@ -19,6 +19,16 @@
border-radius: var(--fs-radius-pill);
transition: width var(--fs-dur-fill);
}
/* Colour carries the reading too, not length alone: `data-fs-level` is set from JS
* (menu-footstrap-common.js, FS_METER_WARN/FS_METER_DANGER — 80/92%, a decision, not a
* measurement) on every poll tick. Solid tokens, matching the accent fill above — no
* color-mix, so no static twin is owed to 04-nocolormix.css. */
.cbi-progressbar[data-fs-level="warn"] > div {
background: var(--fs-warn);
}
.cbi-progressbar[data-fs-level="danger"] > div {
background: var(--fs-danger);
}
.cbi-progressbar::after {
content: attr(title);
/* the gap is a scale step, not a literal 4: every other gap in the theme scales with the
@@ -303,6 +303,43 @@
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
}
/* Below ~560px of container width the 40%/nowrap column above starves the value: measured on
* System -> System at 390px content width, Firmware Version wrapped onto 5 lines and Kernel
* Version/Architecture onto 2 each, most of the 130px label column sitting empty. Stack it like
* the other card idioms in this file — label as an eyebrow above its value — instead of holding
* a fixed-fraction column at any width. A width win, not a height one: measured on the gallery's
* key/value specimen (a 340px .cbi-map, docs/gallery.html), stacking cost the card +44.9% height
* (202.6 -> 293.5px) for the value column widening x1.67 (183.6 -> 306px) and Firmware Version's
* line count dropping from two to one. (The five lines above are the live 390px page, where the
* value is longer than the specimen carries — the shape is the same, the count is not.)
*
* 560 is the second threshold named on fs-content; the config table's card (theme/65-dropdown.css)
* is the first, at 960, for a row full of widgets that bake in a layout width. This one holds two
* plain text cells, so it can stay two-column down to a narrower width before the label needs the
* row to itself.
*
* `:first-child:first-child` / `:last-child:last-child` double the tail pseudo-class to outrank
* the two rules above at identical specificity otherwise — the `.hidden.hidden` idiom
* (theme/60-inputs.css) — rather than win on source order, which the next edit to either rule
* could invert.
*
* The value's right-flush alignment above is a two-column device: with the label gone from the
* row, right-flushing floats the value away from its own caption. Left-aligned instead, so the
* card reads top-to-bottom as caption then value, matching every other stacked cell in this file. */
@container fs-content (max-width: 560px) {
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr {
display: block;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child {
display: block; width: auto; white-space: normal;
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
text-transform: uppercase;
}
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
display: block; width: auto; text-align: start;
}
}
/* --- the realtime graph legend (status/realtime/*) ---
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells
@@ -110,8 +110,8 @@
* a container-type, so giving container-type to any new component would silently re-target every
* unnamed query underneath it:
* fs-view the overview's grid (pages/20-overview.css, @container 800)
* fs-content "whatever content column I am in", carried by both .cbi-map and #view, so a query
* resolves to the nearer of the two. */
* fs-content "whatever content column I am in", carried by .cbi-map, #view and .modal (below),
* so a query resolves to the nearest of the three. */
.cbi-map { container-type: inline-size; container-name: fs-content; }
#view { container-type: inline-size; container-name: fs-view fs-content; }
@@ -782,32 +782,78 @@
* `!important` would do it too, and would invert the layer order for every future override. */
.hidden.hidden { display: none; }
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
* Token-driven track/thumb. */
* Token-driven track/thumb.
* The INPUT box is the WCAG 2.2 SC 2.5.8 target (24px min): it used to be the 6px track
* itself, measured 258x6 on owrt2512/390x844 (System > System). Box and paint split here —
* a transparent 24px box, the 6px bar drawn by the track pseudo-element inside it.
*
* A flat `height: 24px` DOES move the row, and not through `.cbi-range-slider`'s own cross
* size — that stayed 29.5px throughout, already pinned by the value chip beside it. It moves
* through the enclosing LINE BOX: `.cbi-value-field` lays this element out as ordinary inline
* content, and the line's height is anchored on where `.cbi-range-slider` (inline-flex, no
* baseline-participating child) reports ITS baseline to the ancestor line — which tracks the
* first flex item's own edge and moved with the input's box. Measured before either fix below:
* `.cbi-value-field` +2.75px (Chromium) / +2.5px (Firefox), `.cbi-value` +1.5px / +1.0px,
* track/thumb ~1.25px lower.
*
* First half of the fix is the checkbox's own trick (`--hit`/`--box` below): grow the box for
* the hit target, then pull the extra area back out of flow with a symmetric negative margin
* so the box overflows itself around its old centre instead of pushing something taller. 6px
* was the box before this rule; -9px is (6px - 24px) / 2. This is what keeps a BARE range
* input (no `.cbi-range-slider` wrapper, no taller sibling — a third-party app's own markup)
* from growing its own row, the same way the checkbox's margin protects a row with no toggle
* label beside it. Measured on its own, though, it left THIS row's growth above unchanged: the
* line-box placement of `.cbi-range-slider` tracks the input's own edge, not the flow space the
* margin trick frees up, so the second half below is still needed on top of it. */
input[type="range"] {
appearance: none;
width: 100%; height: 6px; min-height: 0; padding: 0;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
width: 100%; height: 24px; min-height: 0; padding: 0;
margin-block: -9px;
background: transparent; border: 0; border-radius: 0;
outline: none; box-shadow: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-moz-range-track {
width: 100%; height: 6px;
background: var(--fs-track); border: 0; border-radius: var(--fs-radius-pill);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel);
cursor: pointer;
/* WebKit centres the thumb on the INPUT's box, not the track: (6 - 16) / 2 = -5px pulls
* it back onto the 6px track. Firefox centres ::-moz-range-thumb on the track itself. */
margin-top: -5px;
}
input[type="range"]::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--fs-accent); border: 2px solid var(--fs-panel); cursor: pointer;
}
input[type="range"]::-moz-range-track { background: var(--fs-track); height: 6px; border-radius: var(--fs-radius-pill); }
/* -solo: the track sets `border: 0` and `outline: none`, so there is no second channel here
* either — the tinted ring was the whole indicator on a 6px bar. */
input[type="range"]:focus { box-shadow: var(--fs-focus-ring-solo); }
* either — the tinted ring was the whole indicator on a 6px bar. Set on the track pseudo,
* not the input, so the ring still hugs the 6px bar rather than the 24px hit box. */
input[type="range"]:focus::-webkit-slider-runnable-track { box-shadow: var(--fs-focus-ring-solo); }
input[type="range"]:focus::-moz-range-track { box-shadow: var(--fs-focus-ring-solo); }
/* THE SAME CEILING THE ELASTIC TEXT INPUTS TAKE, and the reason is the whole point of the
* token: a `.cbi-value-field` is `flex: 1`, so `width: 100%` here meant the slider ate the
* entire card while the select two rows above stopped at 210px and the readout chip was flung
* to the card's right edge. Every stretching control in a form row now ends at one x. */
.cbi-range-slider { display: inline-flex; align-items: center; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
* to the card's right edge. Every stretching control in a form row now ends at one x.
*
* `vertical-align: middle` answers the second half of the 24px-target growth explained above
* `input[type="range"]`. Default (`baseline`) places this inline-flex box in its line by the
* edge of its first flex item — the range input — so that edge moving 6px -> 24px still pushed
* `.cbi-value-field`'s line taller even with the margin trick already in place. `middle` places
* the box by its own total height instead, which the margin trick already held at its old
* 29.5px (pinned by the value chip, unaffected by the input's growth), decoupling the two.
* Measured on the gallery slider fixture, both engines, this rule plus the margin trick against
* HEAD: `.cbi-value-field` +0.000px in both, `.cbi-value` -0.187px (Chromium) / -0.183px
* (Firefox) — under a fifth of a CSS px, not a pixel a screen can render differently. Box
* unchanged at 258x24. */
.cbi-range-slider { display: inline-flex; align-items: center; vertical-align: middle; gap: var(--fs-space-3); width: 100%; max-width: var(--fs-field-max); }
.cbi-range-slider-value {
background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
padding: var(--fs-space-1) var(--fs-space-2); color: var(--fs-accent); font-family: var(--fs-font-mono);