💋 Sync 2026-08-21 20:37:56

This commit is contained in:
github-actions[bot] committed 2026-08-21 20:37:56 +08:00
1 parent b6a99f1461
commit f32af3cfd3
19 files changed
+826 -34

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap
FOOTSTRAP_VERSION?=
ifneq ($(FOOTSTRAP_VERSION),)
PKG_VERSION:=$(FOOTSTRAP_VERSION)
PKG_RELEASE:=31
PKG_RELEASE:=32
endif
LUCI_TITLE:=Footstrap Theme
+1 -1
View File
@@ -1,6 +1,6 @@
# luci-theme-footstrap (the package)
A LuCI theme for OpenWrt **24.10 and newer** (ucode templates). Installing and using it:
A LuCI theme for OpenWrt **23.05 and newer** (ucode templates). Installing and using it:
[the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md).
Internal name: `footstrap`. Media path: `/luci-static/footstrap`.
@@ -124,9 +124,51 @@ function build() {
node.addEventListener('widget-change', () => apply(w.getValue()));
return node;
};
/* THE ONE WIDGET THIS THEME CANNOT ASSUME, and the reason 23.05 is a supported release again.
*
* `ui.RangeSlider` arrived in 24.10. On 23.05 it is simply not there, and because this whole
* panel is built inside one try/catch the miss took the ENTIRE Appearance tab with it — a
* console line and an empty tab, reported from the field rather than by a gate, because no gate
* had ever opened a 23.05 router.
*
* So the widget is used where it exists and reproduced where it does not: same DOM, same class
* names (`.cbi-range-slider` and its value/units children are what theme/60-inputs.css dresses),
* same two events, and the same base class — `ui.AbstractElement` is the name LuCI exports on
* BOTH 23.05 and master, which is what makes `setUpdateEvents`/`setChangeEvents` available to
* the copy. Nothing below this line knows which of the two it got.
*
* Kept deliberately smaller than upstream's: `calculate` is not reproduced, because no axis on
* this page uses it. If one ever does, use the real widget's shape rather than growing this. */
const RangeSlider = ui.RangeSlider || ui.AbstractElement.extend({
__init__(value, options) {
this.value = value;
this.options = Object.assign({ min: 0, max: 100, step: 1, calcunits: null }, options);
},
render() {
this.sliderEl = E('input', {
type: 'range', min: this.options.min, max: this.options.max,
step: this.options.step || 'any', value: this.value
});
this.valueEl = E('output', { class: 'cbi-range-slider-value' }, String(this.value));
const node = E('div', { class: 'cbi-range-slider' }, [
this.sliderEl,
this.valueEl,
this.options.calcunits ? E('span', { class: 'cbi-range-slider-calc-units' }, this.options.calcunits) : null
].filter(Boolean));
this.node = node;
this.setUpdateEvents(this.sliderEl, 'input', 'blur');
this.setChangeEvents(this.sliderEl, 'change');
this.sliderEl.addEventListener('input', () => { this.valueEl.textContent = this.sliderEl.value; });
dom.bindClassInstance(node, this);
return node;
},
getValue() { return this.sliderEl.value; },
setValue(value) { this.sliderEl.value = value; this.valueEl.textContent = value; }
});
const sliderCtl = (current, min, max, apply, label, opts) => {
const o = opts || {};
const w = new ui.RangeSlider(String(current), {
const w = new RangeSlider(String(current), {
min: min, max: max, step: o.step || 1, calcunits: o.unit || null
});
const node = w.render();
@@ -724,6 +724,22 @@
stroke: var(--fs-text) !important;
}
/* …and the axis labels beside them, for the same reason and with a worse symptom. Every <text>
* in the .svg carries an inline fill of the file's own light grey plus a one-pixel black halo,
* drawn for the black background the file assumes rather than for the panel this theme paints
* behind it. On a light palette that grey lands on white at 1.16:1 — the numbers are legible
* only as the shadow around them; --fs-text measures 17.9:1 there, and 14.7:1 on the dark
* panel, where it is the halo that has to go instead. Both come from an inline declaration, so
* both need the flag. Reported from the forum.
*
* The weight is not decoration: the SVG hardcodes a 9pt label, the smallest type this theme
* renders anywhere, and at the regular weight it is also the thinnest. */
#view div[style] > svg text[style] {
fill: var(--fs-text) !important;
text-shadow: none !important;
font-weight: var(--fs-weight-bold);
}
/* Overview Hide/Show toggle pills: theme/35-alerts.css (.cbi-title .label) +
* pages/20-overview.css (chevron variant) — absorbed. */
}
+39 -3
View File
@@ -28,18 +28,54 @@
* a heavy grey cloud under a white card in light mode. */
padding: var(--fs-space-6) var(--fs-space-6) var(--fs-space-6); box-shadow: var(--fs-shadow-pop);
}
/* The router's own name, first line of the card (sysauth.ut) — the shape an appliance console
* uses for this (FortiGate prints the hostname on its login page for the same reason): a
* CENTRED identity block, set apart from the form, above a left-aligned heading and left-aligned
* fields. Centred and separated is what makes the mixed alignment read as deliberate rather than
* as a stray line: the 24px below is the card's own padding, so the block sits in a gutter of the
* same size as the one around it, and the eye takes it as a header rather than as the heading's
* first line.
*
* Still SMALLER than the heading it stands over (16px against the h2's 20px). It answers
* "which box is this", which matters only until you have read it; `Authorization Required`
* answers "what now", which is the page's actual subject. Set larger, the two swap roles and the
* heading reads as a subtitle.
*
* Not a heading element, and that is on purpose — WCAG's outline is about the page's SUBJECT,
* and a second heading above the h2 would announce the login form as two sections. It is a <p>
* in the template.
*
* The eyebrow tokens are NOT reused, though this is an eyebrow's position: they carry
* `text-transform: uppercase`, and a hostname is a name — `Rb5009-Usb` is not what the admin
* typed, and case is the only thing telling two similar names apart on a router farm.
*
* `overflow-wrap: anywhere` because the card is a DEFINITE 400px (above) and a hostname is one
* unbreakable token of up to 63 characters — nothing in it is a break opportunity, so without
* this a long one runs out of the card instead of wrapping inside it. */
.fs-login-host {
margin: 0 0 var(--fs-space-6); text-align: center;
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-dim);
overflow-wrap: anywhere;
}
.fs-login .cbi-map,
.fs-login .cbi-section,
.fs-login .cbi-section-node {
background: none; border: 0; padding: 0; margin: 0; box-shadow: none;
}
/* the login card IS the panel — the generic "heading gets its own card" rule would
* draw a second frame inside it */
.fs-login .cbi-map > h2,
* draw a second frame inside it. `h1` because this page has no title bar to carry the
* document's top-level heading (sysauth.ut says why); the card rule in theme/45-misc.css is
* written for `.cbi-map > h2` and so does not reach it, but the reset stays listed — the
* heading's level is not what should decide whether a second frame appears inside this card. */
.fs-login .cbi-map > h1,
.fs-login .cbi-map-descr {
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
}
.fs-login .cbi-map > h2 {
/* the h2's size, on an h1: the ramp puts an h1 at 26px (theme/45-misc.css) and in a 400px card
* that wraps `Authorization Required` onto two lines. The heading was the right SIZE all along
* — only its level was wrong. `margin`, because the ramp gives an h1 a bottom margin and this
* card sets its own rhythm. */
.fs-login .cbi-map > h1 {
font-size: var(--fs-type-xl); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; margin: 0 0 var(--fs-space-1-5);
}
.fs-login .cbi-map-descr { color: var(--fs-dim); font-size: var(--fs-type); margin: 0 0 var(--fs-space-5); }
@@ -32,6 +32,16 @@
.cbi-section > h3:first-child,
.cbi-section > legend { margin-bottom: var(--fs-space-3); }
/* A SECOND heading inside the same card — the one that follows a table rather than opening the
* section — had no air at all: measured 0px above it on Status → Overview, where the stock
* includes stack `table + h3 + table` inside one .cbi-section and the heading read as another
* row of the table it was captioning. The rule above cannot cover it: `:first-child` is what
* excludes the one that opens the card, and that one takes its gap from the wrapper instead.
* Reported from the forum. The two values are the ladder's own: a heading is separated from
* what came before by more than it is from what it introduces. */
.cbi-section .table + h3 { margin-top: var(--fs-space-5); }
.cbi-section h3 + .table { margin-top: var(--fs-space-3); }
/* ---- tables in cards ----
* Discriminator: a key/value include (System, Memory) has NO id; a data table has one
* (status_leases) or the JS tag .fs-dt. Key/value is not itself a card — no frame, no clip;
@@ -24,12 +24,45 @@
catalogue for.
-#}
{%
/* WHICH ROUTER THIS IS — requested as openwrt/luci#8961, by an admin coming from
luci-theme-material, which prints the hostname in the top bar and therefore also on its login
page. This one had no chrome at all (blank_page below), so the only place the name appeared
was the browser TAB.
A SECOND `ubus.call('system', 'board')`: header.ut makes its own inside its own scope and an
include cannot hand a local back, so the choice is one extra ubus round trip on the login
render or threading `boardinfo` out through a global. One call, on the one page that renders
once per session, is the cheaper of the two.
It discloses nothing new. The same `boardinfo.hostname` already reaches an unauthenticated
browser through <title> (partials/head.ut) — as it does in every stock theme — so this moves
a string that was always on the wire into the place a reader looks. */
const boardinfo = ubus.call('system', 'board') ?? {};
-%}
{% include('header', { blank_page: true }) %}
{# The class is what 10-login.css hooks: the form used to be found by
`form:has(> .cbi-map input[name="luci_username"])` — 49 characters written 17 times, ~0.8 KB —
because the markup was assumed to be stock LuCI's. It is OURS: name the form instead. #}
<form method="post" class="fs-login">
{#
The router's name, above everything else in the card — an admin with three of these open has no
other way to tell the tabs apart once the form is focused. `?? 'OpenWrt'` is the fallback
partials/head.ut and partials/brand.ut already use, so the line never renders empty; the
striptags/entityencode pair is theirs too, and load-bearing: the hostname is admin-controlled
text printed to an UNAUTHENTICATED page, and one containing `&` broke entity parsing here
before it broke anything else (see the <title> note in partials/head.ut).
A <p> and NOT a heading: the card's h2 is `Authorization Required` and stays the page's one
heading. An outline is about the page's SUBJECT, and a heading above that one would announce
the login form as two sections — a screen reader walking the headings would hear the router's
name as a section title with the form nested under it. It is printed above the h2 and centred
(10-login.css) because that is where a reader looks for "which box is this", not because it
outranks anything.
-#}
<p class="fs-login-host">{{ entityencode(striptags(boardinfo.hostname ?? 'OpenWrt'), true) }}</p>
{#
TWO INDEPENDENT ALERTS, because neither variable on its own says which of the two dispatcher
branches rendered this page — and each of the two spellings in the tree picks one and gets the
@@ -67,7 +100,19 @@
{% endif %}
<div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2>
{#
An h1, where every other LuCI theme leaves this an h2. The login page has no chrome
(blank_page above), so header.ut's `.fs-title-main` h1 never renders here and the document
went out with its top-level heading MISSING — the one sentence saying where you are, and
the heading a screen reader's `1` shortcut jumps to. Stock's h2 is a copy of a page that
does have a title bar above it; this one does not, so the card's heading IS the page title.
`name="content"` is upstream's legacy anchor and travels with the element, not with its
level. The card's own type scale is in 10-login.css: an h1 is 26px in the ramp
(theme/45-misc.css) and this one is set back to the h2's 20px, because the SIZE was never
what was wrong — only the level.
-#}
<h1 name="content">{{ _('Authorization Required') }}</h1>
<div class="cbi-map-descr">
{{ _('Please enter your username and password.') }}
</div>