💐 Sync 2026-09-05 10:39:08

This commit is contained in:
github-actions[bot] committed 2026-09-05 10:39:08 +08:00
1 parent 3d42220d7f
commit eee3184ac2
17 files changed
+474 -236

No files matched your search

@@ -296,7 +296,14 @@ function build() {
const node = w.render();
node.setAttribute('aria-label', label);
const push = () => apply(parseInt(w.getValue(), 10));
node.addEventListener('widget-update', push);
/* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column
* the Appearance page is drawn in, so applying mid-drag slides the handle out from under
* the pointer, the pointer catches up, and one small drag runs the value to the maximum —
* reported from a router, and not a thing any other slider here can do: rounding and the
* tint sliders repaint around a control that stays where it is. On release the page still
* follows, and a keyboard user is unaffected either way: an arrow key on a range input
* fires `change` as well as `input`, so the value applies at once. */
if (o.live !== false) node.addEventListener('widget-update', push);
node.addEventListener('widget-change', push);
return node;
};
@@ -343,7 +350,10 @@ function build() {
* so it is a proper noun and stays untranslated, like the two above it */
bootstrap: 'Bootstrap',
/* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */
'2020': 'OpenWrt 2020'
'2020': 'OpenWrt 2020',
/* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is —
* a proper noun like the three above it, not the English common noun "forum" */
forum: 'Forum'
}, bump(repaint(axes.applyPalette)), label)),
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
@@ -352,6 +362,16 @@ function build() {
large: _('Large', 'footstrap')
}, bump(prefs.applyDensity), label)),
/* issue #44: the content column's own cap, separate from Density (which moves type and
* air, not the column's ceiling). The slider STARTS at the 1280px the theme has always
* shipped and only ever widens — there is no reason to offer a column narrower than the
* one every page was designed against, and keeping 1280 as the left end also keeps
* --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach
* by construction rather than by a rule someone has to remember. */
group(_('Content width', 'footstrap'),
(label) => sliderCtl(axes.currentContentWidth(), 1280, 3840,
bump(axes.applyContentWidth), label, { step: 40, live: false })),
group(_('Rounding', 'footstrap'),
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
@@ -2,6 +2,7 @@
'require baseclass';
'require rpc';
'require fs-prefs as prefs';
'require fs-fit as fit';
/* fs-axes — the nineteen Appearance axes and the Save-as-default machinery.
*
@@ -79,8 +80,14 @@ function colorAxis(key, attr, hueProp, colorProp) {
* in how the number formats onto the property, which is the one varying argument. The prefs.sd() field
* name is passed explicitly because one instance needs a rename rather than a spelling
* ('fs-radius' -> rounding), and a factory right for four keys out of five is the trap enumAxis and
* colorAxis name above. */
function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
* colorAxis name above.
*
* `after`, like listAxis's, is optional and runs once the property is set: every propAxis instance
* but Content width only repaints a rule that reads the token, so none of the other five pass it.
* Content width moves --fs-content-max, which shellGeometry() (fs-chrome.js) reads to decide
* data-narrow, so its applier must re-run the fit or the chrome goes on deciding against the width
* the column had a moment ago. */
function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
const def = () => { const d = prefs.sd(sdKey); return inRange(d) ? d : dfl; };
return {
@@ -96,6 +103,7 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
prefs.lsSet(key, String(v));
if (v === dfl) root.style.removeProperty(prop);
else root.style.setProperty(prop, fmt(v));
if (after) after();
}
};
}
@@ -111,7 +119,7 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
* Legacy names ('rvht'/'roman'/'github') are migrated by head.ut before paint, so they never reach
* currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTES = [ 'hicontrast', 'bootstrap', '2020', 'forum' ]; /* the non-default values; 'footstrap' = bare :root */
const PALETTE = prefs.listAxis('fs-palette', 'data-palette', PALETTES, 'footstrap');
const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
@@ -209,6 +217,19 @@ const currentDanger = DANGER.current, applyDanger = DANGER.apply;
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
/* Content width (issue #44): how far the reader lets the column grow past --fs-content-max's own
* 1280px. A propAxis like Rounding, pointed at that one token — the range comment lives in
* 02-tokens.css, next to the token it bounds, and is not restated here.
*
* The one propAxis instance that moves chrome geometry: shellGeometry() (fs-chrome.js) reads
* --fs-content-max on every fit, so dragging the slider must re-run it through the SAME
* fit.schedule() every other geometry-affecting axis calls, or the sidebar goes on folding at the
* width the column had before the drag. */
const FS_CWIDTH_DEFAULT = 1280;
const CWIDTH = propAxis('fs-content-width', 'content_width', '--fs-content-max', 1280, 3840,
FS_CWIDTH_DEFAULT, (v) => (v + 'px'), () => fit.schedule());
const currentContentWidth = CWIDTH.current, applyContentWidth = CWIDTH.apply, contentWidthDefault = CWIDTH.def;
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. One template, one renderer —
* CSS morphs the chrome off :root[data-layout] and toggling re-renders nothing; menu-footstrap.js
* observes the attribute and folds the accordion into dropdowns or restores it.
@@ -222,7 +243,7 @@ const AXIS_KEYS = [
'fs-accent', 'fs-good', 'fs-warn', 'fs-danger', 'fs-card', 'fs-control',
'fs-bar', 'fs-line', 'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength',
'fs-density', 'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength',
'fs-pattern-ink'
'fs-pattern-ink', 'fs-content-width'
];
/* Tint strength: a multiplier on the tint chroma (03-palettes.css), 100% being the designed
* strength and 200% the cap. 0 is not quite "no tint" — the relative colour that applies the tint
@@ -291,7 +312,8 @@ function snapshotAxes() {
photo_dim: String(currentPhotoDim()),
pattern_size: String(currentPatternSize()),
pattern_strength: String(currentPatternStrength()),
pattern_ink: currentPatternInk()
pattern_ink: currentPatternInk(),
content_width: String(currentContentWidth())
};
}
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
@@ -326,7 +348,8 @@ function _resolvedDefault() {
photo_dim: String(photoDimDefault()),
pattern_size: String(patternSizeDefault()),
pattern_strength: String(patternStrengthDefault()),
pattern_ink: PINK.def()
pattern_ink: PINK.def(),
content_width: String(contentWidthDefault())
};
}
let _savedDefault = _resolvedDefault();
@@ -401,6 +424,7 @@ function resetToBuiltin() {
applyPatternSize(FS_PSIZE_DEFAULT);
applyPatternStrength(FS_PSTR_DEFAULT);
applyPatternInk('theme');
applyContentWidth(FS_CWIDTH_DEFAULT);
}
/* ---- the two uploaded wallpapers, browser side ----
@@ -473,6 +497,7 @@ return baseclass.extend({
currentPatternSize, applyPatternSize,
currentPatternStrength, applyPatternStrength,
currentPatternInk, applyPatternInk,
currentContentWidth, applyContentWidth, contentWidthDefault,
currentPattern, patternUrl, applyPattern,
currentLoginBg, loginBgUrl, applyLoginBg,
@@ -138,9 +138,15 @@ const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, c
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
/* contentMax moves with data-content-width (02-tokens.css), the other three widths do not, but
* they are one memo and the axis's own applier calls fit.schedule() same as Density's — folded
* into the same key rather than a second one, or a content-width change alone would hit the
* density-only key and go on answering the previous contentMax until density also changed */
const cwidth = document.documentElement.getAttribute('data-content-width') || '';
const key = density + '|' + cwidth;
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === density) return _geom;
_geomDensity = density;
if (_geom && _geomDensity === key) return _geom;
_geomDensity = key;
const px = (name, dflt) => resolveLen(name, dflt);
const g = {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
@@ -192,10 +192,10 @@ _mqDark.addEventListener('change', () => {
/* ---- the four axis shapes, each written once ----
*
* Fifteen axes are four shapes, so the shape lives in a factory and each instance is one line:
* Sixteen axes are four shapes, so the shape lives in a factory and each instance is one line:
* enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
* strength). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
* strength, content width). Same contract throughout: `current()` is localStorage ?? def(), `def()` is the router
* default alone, `apply()` stores the choice explicitly. None use `this` — every export is a
* detached method reference, so a `this` here would throw on the first call.
*
@@ -272,6 +272,11 @@ const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
densityDefault = DENSITY.def;
/* Content width lives entirely in fs-axes.js now (a propAxis like Rounding, issue #44): unlike
* Density it sets no attribute, so nothing here or in fs-chrome.js needs to read one, and
* currentContentWidth()/applyContentWidth() have no caller outside the Appearance tab — the one
* thing that kept Density's shape in this cold-path file. */
/* Background-tint axis: the canvas the cards float on (--fs-bg), so a whole install reads as one
* colour and a tab or a screenshot says which router it belongs to. Cards, chrome and the status
* colours keep the palette's values — the cue colours the paper, not the UI. On a hue it is mixed