🏅 Sync 2026-08-13 09:29:33

This commit is contained in:
github-actions[bot]
2026-08-13 09:29:33 +08:00
parent 2a6de1e65f
commit c57dcedc98
40 changed files with 902 additions and 494 deletions
@@ -22,6 +22,10 @@
const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null;
/* one canvas for the whole document: wordFloor() measures text with it, and creating one per call
* is the expensive half of that measurement */
let _cx = null;
const SPACE_RE = /\s+/;
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
* relevant mutation. */
@@ -119,6 +123,74 @@ return baseclass.extend({
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
},
/* THE NARROWEST THIS TABLE CAN BE WITHOUT BREAKING A WORD THROUGH — its own breakpoint, in px.
*
* Every other "does it fit" question here is asked of the browser. This one cannot be: it is
* exactly the table's min-content width, and `overflow-wrap: anywhere` (base/40-tables.css) makes
* the browser answer ONE CHARACTER per column. That is deliberate — an unbreakable ICCID must not
* push a table nobody measures out of its card — but it means a measured table can be starved to
* a ribbon of fragments and still report, truthfully, that it fits. Asking the engine the honest
* question by flipping `overflow-wrap` for one layout does not work either: Blink returns the same
* min-content for `normal`, `break-word` and `anywhere` on a table (measured — 645px in all three,
* with the widest word alone needing 367), so the number simply is not available from layout.
*
* So compute it: per COLUMN, the width of the widest WORD any of its cells has to show, plus that
* cell's own side padding; summed across columns. A `nowrap`/`pre` column takes its whole text
* instead, because that is what it has committed to showing. Nothing here is a threshold — the
* result is the table's content measured in the table's own fonts, so every table gets its own
* number and none was picked by anyone.
*
* Two approximations, both stated rather than hidden:
* - the FONT is sampled once per column, from the first data row. A column that changes face
* row by row would be measured in the first row's; no LuCI table does that, and the
* alternative is a `getComputedStyle` per cell, which is the expensive call here.
* - the widest word is picked by CHARACTER COUNT and only that one is measured, so a column of
* one font is one `measureText` per new maximum instead of one per cell. Within a single font
* that ranks `iiii` above `WWW`; it costs a few px on the floor and takes the walk over
* Processes (114 rows) from 6ms to about 1ms.
*
* A `colSpan` cell contributes its whole floor to one column, which over-states that column and
* under-states its neighbours by the same amount — the sum, which is what the caller compares, is
* unaffected. */
wordFloor(t) {
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
if (!rows.length) return 0;
if (!_cx) _cx = document.createElement('canvas').getContext('2d');
const floors = [], longest = [];
let cols = null;
for (const row of rows) {
const cells = row.children;
if (!cols) {
cols = [];
for (const c of cells) {
const s = getComputedStyle(c);
cols.push({
font: `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`,
tracking: parseFloat(s.letterSpacing) || 0,
pad: parseFloat(s.paddingLeft) + parseFloat(s.paddingRight),
whole: (s.whiteSpace === 'nowrap' || s.whiteSpace === 'pre')
});
}
}
for (let i = 0; i < cells.length; i++) {
const text = (cells[i].textContent || '').trim();
if (!text) continue;
const col = cols[i] || cols[cols.length - 1];
let word = '';
if (col.whole) word = text;
else for (const w of text.split(SPACE_RE)) if (w.length > word.length) word = w;
if (!(word.length > (longest[i] || 0))) continue;
longest[i] = word.length;
_cx.font = col.font;
const need = _cx.measureText(word).width + (col.tracking * word.length) + col.pad;
if (!(floors[i] >= need)) floors[i] = need;
}
}
let sum = 0;
for (const f of floors) sum += (f || 0);
return sum;
},
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
* squeezed into a tower" — a cell that has to break its own words is a column that has run
* out of width, which is a thing no viewport query can ask.
@@ -76,6 +76,63 @@ function clearViewIntervals() {
}
let _pollTimerWarned = false;
/* --- uci cache teardown for SPA nav ---
* `uci.load()` does not answer "is this config present?" — it answers "which of these packages did
* THIS call fetch", skipping every package already in its document-scoped cache:
*
* for (…) if (!self.state.values[packages[i]]) { pkgs.push(…); tasks.push(…) }
* return Promise.all(tasks).then(() => pkgs); // uci.js, luci-base
*
* Four shipped views read that return value as an existence check and abort on an empty array —
* luci-app-banip and luci-app-adblock's overview, luci-app-travelmate's overview and stations:
*
* if (!result[3] || result[3].length === 0) { ui.addNotification(…, _('No banIP config found!')); return; }
*
* On a full load the cache is empty, so the first visit always gets its package name back and the
* check passes. Under SPA the document survives, so the SECOND visit gets `[]` and the page renders
* as "no config found" — reported against banip, where switching between its own tabs and coming
* back to Overview is the ordinary gesture, and unstickable by anything short of a reload.
*
* The apps' reading of `load()` is wrong, but the DIVERGENCE is ours: a cache that outlives the page
* that filled it is state a fresh load does not have, exactly like the poll queue and the view's
* intervals above. So drop it on navigation and let the incoming view fetch what it needs.
*
* `unload()` is upstream's own idiom for this, not a lever we found: `uci.save()` ends with
* `self.unload(pkgs); return self.load(pkgs)`. Pending local edits (creates/changes/deletes) go with
* it — as they do on a full load, which throws away the whole document. Saved changes are already
* on the server and the Unsaved-changes banner reads them from there, so it is unaffected.
*
* Read through `window.L.uci` rather than a `'require uci'` pragma, deliberately: the class attaches
* itself to L's prototype when the FIRST requirer compiles it, so this sees the instance the pages
* actually use, and a router that required it would both bind it to its own prototypal L (the two-L
* trap, docs/spa-router.md) and pull uci.js onto pages that never touch uci. No instance means no
* cache to flush. */
function flushUciCache() {
const uci = window.L ? window.L.uci : null;
if (!uci || typeof uci.unload !== 'function') return;
/* `state.values` and `loaded` are private, so the same rule as L.Poll.timer applies: a shape we
* do not recognise is a reason to do nothing, once, loudly — never to guess. The two hold
* different halves of the cache (a package whose load is still in flight is in `loaded` alone),
* and unload() clears both, so the names are taken from both. */
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
if (!_uciCacheWarned) {
_uciCacheWarned = true;
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (names.length) uci.unload(names);
/* `state.reorder` is the one half unload() does not clear, and it is deliberately left alone
* rather than reset by hand: with `values` gone, reorderSections() finds no sections to order,
* emits no call and clears the map itself on the next save. Writing to that field would be us
* editing another module's private state, for a difference nothing can observe. */
}
let _uciCacheWarned = false;
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
* navigation from a mere fragment change (see there). Seeded from the served page. */
@@ -599,6 +656,9 @@ function navigate(pathname, push, kbd) {
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not have carried into the
* incoming page either (see flushUciCache) */
flushUciCache();
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* run every registered navigation callback — today the search palette's recent-pages record and
@@ -172,10 +172,40 @@ function enhance(sel) {
*
* `.table`, not `table.table` the SAME selector relevant() and STACKABLE use. Stock LuCI
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card.
*
* `.table` is LuCI's own class, and everything the theme knows how to do with a table hangs off it.
* A third-party app that emits a BARE `<table>` no LuCI classes at all therefore matched none of
* this: nothing tagged it, nothing measured it, nothing carded it, and the only thing that reached it
* was a phone-tier scrollbar (theme/90-responsive.css). Reported from a phone against a wifi-clients
* dashboard whose last column was simply cut off.
*
* So the second half of this selector claims those too. It is deliberately UNMEASURABLE on the dev
* stand a census of `#view table:not(.table):not(.cbi-section-table)` over all 196 menu pages,
* openclash / justclash / ssclash / dashboard / statistics included, found ZERO. That is the point:
* every table anyone here emits already carries the class, so this changes nothing that can be
* measured and covers the one shape that cannot be (docs/conventions.md: coverage is a contract). */
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table):not(.fs-dt), ' +
'#view table:not(.table):not(.cbi-section-table):not(.fs-dt)';
/* The FOURTH header markup, and the one only a foreign table produces: `<table><tr><th>`, with no
* `<thead>` for the parser to imply and none of LuCI's class names. It is the exact shape the phone
* tier's scroll fallback was written against, so it has to be recognisable here or that table can
* still only ever scroll.
*
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
* whose first cell is a row header (`<th scope=row>`) would otherwise be read as the header row and
* every value below it captioned with a value. A table with no `<th>` at all a layout table, a
* matrix returns null and keeps today's behaviour, which is what the scroll fallback is for. */
function headerRow(t) {
const row = t.rows && t.rows[0];
if (!row || !row.cells.length) return null;
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
}
function tagDataTables() {
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
document.querySelectorAll(FOREIGN_TABLE).forEach((t) => {
/* FOUR header markups, and each missing one cost a page. L.ui.Table emits
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
* it is why the package list once needed a stacking block of its own); and a third-party
* table may simply use a real `<thead>` luci-mod-dashboard's device lists are
@@ -188,16 +218,66 @@ function tagDataTables() {
* to the `<thead>` the parser's implied row never happens, so a `tr` in the selector finds
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
*
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
* ANY of the four = a data table; NONE = a key/value include (System, Memory), which must
* never card. `thead` is the structural form of the same statement the two classes make, so
* it belongs in the same list rather than in a rule of its own. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
* it belongs in the same list rather than in a rule of its own; headerRow() is the fourth and
* cannot be, because "the first row is all `<th>`" is not a selector. */
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
if (!head) return;
t.classList.add('fs-dt');
/* `.table` as well as `.fs-dt`, and only ever ADDED: the theme's whole table vocabulary
* the frame, the cell padding, the card stack is written against `.table`, so a foreign
* table that has just been recognised as a data table has to join it or the tag buys nothing.
* A no-op on everything LuCI renders, which carries the class already. */
t.classList.add('table', 'fs-dt');
adoptMarkup(t, head);
labelCells(t, head);
});
}
/* ...AND THE ROWS AND CELLS INSIDE IT, or the claim is a trap.
*
* `.table` alone gets the frame and the padding, because those rules end at the table. Everything
* that makes the CARD is written one level down `.table.fs-stacked .tr { display: flex }`, the
* `.td[data-title]::before` label, the hidden header row and a bare foreign `<table>` carries none
* of those class names. So the fitter would measure it, decide it no longer fits, set `.fs-stacked`
* and change NOTHING: measured at 390px on a bare four-column table, the rows stayed `table-row`,
* the cells `table-cell` at 80px each, no label was generated and `#view .table.fs-dt.fs-stacked`
* sets `overflow: hidden`, so the columns were CLIPPED with no scrollbar to reach them. That is
* worse than the phone-tier scroll it replaced, which is the whole reason this exists.
*
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md: the shared
* zone), and the theme is already writing `.table` onto the same element this is that one act
* carried down to the rows, not a new liberty. Additive only, and cheap enough to re-run every fit
* pass: `classList.add` on an element that already has the class is the same `contains` check we
* would write to skip it, and these tables are POLLED, so fresh rows arrive bare.
*
* The HEADER also has to be recognisable as one, or the card shows it as a first row of column
* names: a `<thead>` becomes `.thead` and a plain first row of `<th>` becomes `.tr.table-titles`
* the two names theme/30-tables.css hides when stacked. */
function adoptMarkup(t, head) {
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary. Asking
* the question every pass instead would answer "already adopted" the moment we adopted it and
* these tables are polled, so the fresh rows that arrive bare afterwards would never be taken.
* Asking it at all is what keeps the theme's hands off LuCI's own markup: the apk Software list
* heads its table with `.tr.cbi-section-table-titles`, and blindly adding `table-titles` to that
* would be the theme rewriting a class LuCI chose. */
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
if (!t._fsAdopt) return;
if (head.tagName === 'THEAD') head.classList.add('thead');
else head.classList.add('tr', 'table-titles');
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
for (const c of titleRow.children) c.classList.add('th');
/* `t.rows` covers a real <table> whether or not it has a <tbody> a table built with
* createElement has its <tr> directly under the <table> and `tbody tr` finds nothing. A
* `<div class="table">` has no `.rows` and is LuCI's own markup, which carries the classes. */
if (!t.rows) return;
for (const row of t.rows) {
if (head.contains(row) || row === head) continue;
row.classList.add('tr');
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
}
}
/* Give every cell the column heading it will show once the table cards.
*
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
@@ -212,7 +292,14 @@ function tagDataTables() {
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
* the fresh ones arrive without it. */
function labelCells(t, head) {
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
/* A `<thead>` that was WRITTEN as markup nests a real `<tr>` the parser inserts one even where
* the author left it out while one built by E() holds the `<th>`s directly (see above). Reading
* `head.children` blind therefore captioned every cell of a parsed table with the header row's
* ENTIRE text: "HostAddressSignal" over the hostname, over the address and over the signal.
* Measured against a bare `<table><thead><tr><th>` on the stand, which is the shape a
* server-rendered or innerHTML-built foreign table has. */
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
if (!titles.some(Boolean)) return;
for (const row of t.querySelectorAll('.tr, tbody tr')) {
if (row === head) continue;
@@ -299,6 +386,30 @@ function idTower(t) {
return false;
}
/* ---- AND THE SAME RIBBON IN A COLUMN THAT IS NOT THE FIRST ----
*
* There is no second test for it, and that is the fix rather than an omission.
*
* `idTower` above is deliberately first-column-only, on the grounds that "a value column wrapping to
* a few lines is a value being shown, not a table falling apart". That held right up until the value
* was one unbreakable token: `overflow-wrap: anywhere` gave such a cell a min-content of ONE
* CHARACTER, so auto table layout was free to starve its column to one character, and `overflows()`
* then reported truthfully, uselessly that the table fit. Reported from a hardware router at
* 700-790px of window: the v4 lease table cards there (its `nowrap` columns give it a floor, so it
* really does overflow) while the v6 table beside it shredded the DUID, measured at 5 lines with
* 674px of room and 7 at 654px, against 1 line at 1160px.
*
* A first pass answered it with a line count, which is a number somebody picks. This asks the table
* instead: `fit.wordFloor()` returns the narrowest the table can be without breaking a word through
* per column, the widest WORD it must show, in that column's own font, summed. Past that width the
* browser has to cut through a value, and the card view is what shows values whole. So every table
* carries its own breakpoint, derived from its own content, and no threshold was chosen anywhere.
*
* On the reporting router, at 1190px of room: leases6 asks for 935, the associated-stations table
* for 966, the v4 leases for 645, Processes for 794, Connections for 550 and Startup for 381 so
* the two that were unreadable card at roughly a 1000px window, and the four that were fine keep
* being tables until the room they actually need runs out. */
function fitTables() {
document.querySelectorAll(STACKABLE).forEach((t) => {
const was = t.classList.contains('fs-stacked');
@@ -309,8 +420,8 @@ function fitTables() {
const room = fit.roomFor(t);
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
/* idTower last: it is the only one that walks the rows */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
/* the two row walks last, cheapest first: idTower reads one column, wordFloor every cell */
const stack = room < CRAMPED || fit.overflows(t) || idTower(t) || fit.wordFloor(t) > room;
/* write only on a real change: the poll re-renders these tables once a second, and
* toggling the class off and on each tick would invalidate style for every row of
* Processes/Leases for nothing */