mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 20:25:13 +08:00
🎉 Sync 2026-09-25 19:20:10
This commit is contained in:
1 parent
e624ec1984
commit
0d442e0830
70 files changed
+2016
-5905
No files matched your search
@@ -12,14 +12,9 @@ PKG_NAME:=luci-theme-footstrap
|
||||
# the CSS build. Pin it.
|
||||
LUCI_NAME:=luci-theme-footstrap
|
||||
|
||||
# Version is git-derived; never hardcode PKG_VERSION. CI injects FOOTSTRAP_VERSION from the
|
||||
# tag (an SDK build has no .git, so it cannot derive one).
|
||||
FOOTSTRAP_VERSION?=
|
||||
ifneq ($(FOOTSTRAP_VERSION),)
|
||||
PKG_VERSION:=$(FOOTSTRAP_VERSION)
|
||||
PKG_RELEASE:=75
|
||||
endif
|
||||
|
||||
# Version is git-derived; never hardcode PKG_VERSION. luci.mk falls back to PKG_SRC_VERSION,
|
||||
# which it always defines, when nothing here sets it — see docs/package.md for why no override
|
||||
# lives here.
|
||||
LUCI_TITLE:=Footstrap Theme
|
||||
# +luci-base is the WHOLE list, and keeping it that way is the constraint. Nothing this package ships
|
||||
# fetches anything at run time, so there is no second name to justify — and the rule that got it here
|
||||
@@ -37,27 +32,12 @@ LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap
|
||||
# color-mix() and nested calc() and break the layout. build-css.sh minifies instead.
|
||||
LUCI_MINIFY_CSS:=0
|
||||
#
|
||||
# JS: two minify paths, chosen by who is building.
|
||||
#
|
||||
# CI (the released packages) pre-minifies with terser BEFORE the SDK build — tools/minify-js.mjs
|
||||
# mangles top-level identifiers too, which jsmin cannot: ~41 KB against jsmin's ~57 KB, −27% — and
|
||||
# signals it with FOOTSTRAP_PREMIN=1.
|
||||
# jsmin must then stay away from the already-minified output: terser legitimately emits
|
||||
# `return/^v/.test(s)`, the exact one-character-lookback trap below, so LUCI_MINIFY_JS goes to 0.
|
||||
#
|
||||
# Everyone else (an SDK user, the buildbot — no node) builds the untouched source and keeps
|
||||
# luci.mk's default jsmin: ~105 KB of this tree's JS is comments, and uhttpd serves /www with NO
|
||||
# compression, so those are wire and flash bytes either way.
|
||||
#
|
||||
# The catch on the jsmin path, and why the gates exist: jsmin decides regex-or-division from a
|
||||
# one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its allow-list, so
|
||||
# `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299). eslint's
|
||||
# `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output is
|
||||
# token-identical to it.
|
||||
FOOTSTRAP_PREMIN?=
|
||||
ifneq ($(FOOTSTRAP_PREMIN),)
|
||||
LUCI_MINIFY_JS:=0
|
||||
endif
|
||||
# JS: the released package is built by owfeed (tools/stage.sh), never by this Makefile through
|
||||
# the SDK — so LUCI_MINIFY_JS keeps luci.mk's default jsmin here. jsmin decides regex-or-division
|
||||
# from a one-character lookback, and `n` (of `return`) and `>` (of `=>`) are not on its
|
||||
# allow-list, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299).
|
||||
# eslint's `wrap-regex` bans the shape in the source; tools/jsmin-verify.mjs proves jsmin's output
|
||||
# is token-identical to it.
|
||||
|
||||
# Apache-2.0, and not a free choice: styles/base began as a fork of luci-theme-bootstrap's
|
||||
# cascade.css, the ucode templates derive from LuCI's own, and a few JS helpers are verbatim copies.
|
||||
@@ -131,9 +111,7 @@ define Package/luci-theme-footstrap/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
# uci-defaults registers the theme; drop the LuCI caches so it is seen without a reboot.
|
||||
# Calling it ourselves is belt-and-braces: OpenWrt's default_postinst also runs (then
|
||||
# deletes) every /etc/uci-defaults/* we ship, so it executes twice per install. It is
|
||||
# idempotent: the second pass finds the theme already registered and changes nothing.
|
||||
# default_postinst also runs it; the second pass is idempotent.
|
||||
[ -f /etc/uci-defaults/30_luci-theme-footstrap ] && \
|
||||
sh /etc/uci-defaults/30_luci-theme-footstrap >/dev/null 2>&1 || true
|
||||
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true
|
||||
@@ -221,12 +199,8 @@ endef
|
||||
# falls back to PKG_SRC_VERSION, which luci.mk always defines, so only an un-built source
|
||||
# checkout keeps the literal '0.0.0-dev'.
|
||||
#
|
||||
# The catalogue is luci.mk's business and nothing here compiles it. `po/` is the directory
|
||||
# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-<lang>` package per language, the
|
||||
# way it does for every luci-app — which is what a package in the luci tree has to look like, and
|
||||
# what Weblate can see. It was `i18n/` while a fielded self-update script mis-picked a multi-asset
|
||||
# release with `head -1` (issue #6); that script is retired and owfeed builds one theme artifact
|
||||
# per format regardless.
|
||||
# The catalogue is luci.mk's business and nothing here compiles it: `po/` is the directory
|
||||
# LUCI_LANGUAGES globs, so luci.mk emits one `luci-i18n-footstrap-<lang>` package per language.
|
||||
define Build/Prepare/luci-theme-footstrap
|
||||
# luci.mk's Build/Prepare copies only src/ luasrc/ htdocs/ root/ ucode/ into
|
||||
# $(PKG_BUILD_DIR), and PKG_LICENSE_FILES resolves against THAT — so the Apache text has to
|
||||
|
||||
@@ -52,7 +52,7 @@ Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.c
|
||||
|
||||
## Working on a router
|
||||
|
||||
The normal dev stand is four containers driven by `owlab` from `../owlab.yaml` — see
|
||||
The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab` — see
|
||||
[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over
|
||||
ssh:
|
||||
|
||||
|
||||
+125
-148
@@ -37,8 +37,8 @@ trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
# One `@layer X{` per LAYER, not one per FILE. Every source file carries its own wrapper so it can
|
||||
# be read and edited alone; concatenated, that is 38 copies of the same six-to-eleven bytes, 554 of
|
||||
# them, plus two files that are all comment and emit nothing but `@layer page{}`.
|
||||
# be read and edited alone; concatenated, that is 37 copies of the same six-to-eleven bytes, 542 of
|
||||
# them.
|
||||
#
|
||||
# The wrapper now comes from the DIRECTORY, so a file filed under the wrong layer would be silently
|
||||
# re-layered instead of just being wrong — hence the check that each file opens with the layer its
|
||||
@@ -53,13 +53,13 @@ emit_layer() {
|
||||
if head -1 "$f" | grep -q '^@layer '; then
|
||||
head -1 "$f" | grep -q "^@layer $layer {\$" || {
|
||||
echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2
|
||||
rm -f "$body"; exit 1; }
|
||||
exit 1; }
|
||||
# the file's own wrapper: its first line, and its last line, which is that wrapper's `}`
|
||||
sed '1d;$d' "$f" >> "$body"
|
||||
elif [ -s "$body" ]; then
|
||||
echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2
|
||||
echo "build-css: swallowed into the $layer block instead of staying above it." >&2
|
||||
rm -f "$body"; exit 1
|
||||
exit 1
|
||||
else
|
||||
cat "$f"
|
||||
fi
|
||||
@@ -69,6 +69,7 @@ emit_layer() {
|
||||
cat "$body"
|
||||
printf '}\n'
|
||||
fi
|
||||
# not covered by the EXIT trap: the success path runs `trap - EXIT` first
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
@@ -80,20 +81,35 @@ emit_layer() {
|
||||
emit_layer page "$D"/styles/pages/*.css
|
||||
} > "$TMP"
|
||||
|
||||
# Strip /* … */, keep /*! … */ (the licence banner), drop indentation and blank lines.
|
||||
#
|
||||
# String-aware: a scanner that just hunts for the next "/*" lets `content: "/*"` open a comment and
|
||||
# eat every rule up to the next "*/", with only the brace count below as a guard — and two such
|
||||
# literals balance each other, so rules can vanish in silence. Quoted data-URIs run through here on
|
||||
# every build.
|
||||
strip_comments() {
|
||||
awk '
|
||||
BEGIN { inc = 0; q = "" }
|
||||
# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware
|
||||
# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below, and every
|
||||
# `--dev` build) strips comments and trims each line; squeeze=1 also does the ~9.5 KB whitespace
|
||||
# pass. Why a scan has to be string-aware, what the whitespace pass leaves alone and why, and the
|
||||
# sed bug this replaced: docs/css.md, "The build".
|
||||
squeeze() {
|
||||
awk -v SQUEEZE="$2" '
|
||||
BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = "" }
|
||||
{
|
||||
line = $0; out = ""; i = 1; n = length(line)
|
||||
line = $0
|
||||
# a banner already open on an earlier line: nothing on a pure continuation line can
|
||||
# change q or inc, so the whole line is banner content until its own closing "*/".
|
||||
if (banner) {
|
||||
trimmed = line
|
||||
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
|
||||
print trimmed
|
||||
lastc = ""; lastreal = ""
|
||||
if (index(line, "*/")) banner = 0
|
||||
next
|
||||
}
|
||||
# The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration
|
||||
# may be wrapped across it. Feed it to the whitespace-run logic below as a leading
|
||||
# space: it survives only where a space would and is dropped next to { } ; , : —
|
||||
# lastc == "" means output is already at the start of a line, with nothing to glue to.
|
||||
if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line
|
||||
out = ""; i = 1; n = length(line); opened = 0
|
||||
while (i <= n) {
|
||||
c = substr(line, i, 1)
|
||||
if (inc) { # inside /* ... */
|
||||
if (inc) { # inside a stripped /* ... */
|
||||
if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue }
|
||||
i++; continue
|
||||
}
|
||||
@@ -101,120 +117,90 @@ strip_comments() {
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
|
||||
if (c == q) q = ""
|
||||
i++; continue
|
||||
}
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; i++; continue }
|
||||
if (c == "/" && substr(line, i + 1, 1) == "*") {
|
||||
# the banner: keep it, and everything after it on this line
|
||||
if (substr(line, i + 2, 1) == "!") { out = out substr(line, i); break }
|
||||
inc = 1; i += 2; continue
|
||||
}
|
||||
out = out c; i++
|
||||
}
|
||||
sub(/^[ \t]+/, "", out)
|
||||
sub(/[ \t]+$/, "", out)
|
||||
if (length(out)) print out
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Squeeze the whitespace CSS ignores — wire AND flash bytes, since uhttpd does not compress.
|
||||
#
|
||||
# Removed (~9.5 KB): the space after `:`, the spaces around `{ } ; ,`, the last `;` of a block, the
|
||||
# newline after every declaration. lightningcss would save ~13 KB, but its extra 3.5 KB comes from
|
||||
# rewriting colours and merging rules — transforms that can change behaviour. These cannot.
|
||||
#
|
||||
# Left alone, each for a reason:
|
||||
# - the single space between selectors: `.a .b` is a DESCENDANT combinator, `.a.b` is not;
|
||||
# - spaces inside calc(): required around `*`, `/` and the `-` of `calc(100% - 8px)`;
|
||||
# - the LINE BREAK inside a declaration, which is whitespace too — joining lines with nothing
|
||||
# between them turns a wrapped calc() into `…))- .004 …`, and a `-` with no space before it is
|
||||
# a parse error, so the declaration drops and the token it defined goes undefined, silently —
|
||||
# --fs-bg then became invalid at computed-value time and the canvas fell back to white
|
||||
# (export-tier.mjs caught it: contrast 1.5:1);
|
||||
# - `>` `+` `~` spaces: stripping them is safe but buys only ~200 bytes;
|
||||
# - anything inside a string: every data-URI here is quoted and full of `:`, `;` and spaces;
|
||||
# - one newline after `}`, so the shipped file stays greppable.
|
||||
squeeze() {
|
||||
awk '
|
||||
BEGIN { q = ""; ban = 0; lastc = ""; buf = ""; lastreal = "" }
|
||||
{
|
||||
line = $0
|
||||
# The /*! banner is an Apache-2.0 attribution, not formatting: it must survive
|
||||
# BYTE FOR BYTE. Squeezing it made "Twitter, Inc" into "Twitter,Inc" and glued its
|
||||
# lines together. Copy it out untouched.
|
||||
if (ban) { print line; lastc = ""; lastreal = ""; if (index(line, "*/")) ban = 0; next }
|
||||
if (substr(line, 1, 3) == "/*!") {
|
||||
print line; lastc = ""; lastreal = ""
|
||||
if (!index(substr(line, 4), "*/")) ban = 1
|
||||
next
|
||||
}
|
||||
# The line BREAK we are about to swallow is whitespace, and a declaration may be
|
||||
# wrapped across it. Feed it to the whitespace-run logic below as a leading space:
|
||||
# it survives only where a space would (between two tokens) and is dropped next to
|
||||
# { } ; , : — lastc == "" means output is already at the start of a line, with
|
||||
# nothing to glue to.
|
||||
if (lastc != "" && q == "") line = " " line
|
||||
out = ""; i = 1; n = length(line)
|
||||
while (i <= n) {
|
||||
c = substr(line, i, 1)
|
||||
if (q != "") { # inside a string: copy verbatim
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue }
|
||||
if (c == q) q = ""
|
||||
lastreal = "" # a char inside a string is not structure
|
||||
lastreal = ""
|
||||
i++; continue
|
||||
}
|
||||
if (c == "\"" || c == "'"'"'") { q = c; out = out c; lastreal = ""; i++; continue }
|
||||
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
|
||||
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
|
||||
# the last char EMITTED, which on a continuation line lives on the
|
||||
# previous output line — hence lastc, not just `out`.
|
||||
prev = (length(out) ? substr(out, length(out), 1) : lastc)
|
||||
nxt = (i <= n ? substr(line, i, 1) : "")
|
||||
# drop it entirely next to a delimiter; otherwise it may be a combinator
|
||||
if (c == "/" && substr(line, i + 1, 1) == "*") {
|
||||
if (substr(line, i + 2, 1) == "!") {
|
||||
# a genuine banner open: reached only with q=="" and inc==0 AT THIS
|
||||
# CHARACTER, the same guard strip_comments used before this pass absorbed
|
||||
# it — so a "/*!" that is really bytes inside a string or an already-open
|
||||
# ordinary comment never lands here. Copied raw from the marker on, byte
|
||||
# for byte: it is an Apache-2.0 attribution, not formatting.
|
||||
banner = 1
|
||||
rest = substr(line, i)
|
||||
out = out rest
|
||||
if (index(rest, "*/")) banner = 0
|
||||
opened = 1
|
||||
break
|
||||
}
|
||||
inc = 1; i += 2; continue
|
||||
}
|
||||
if (SQUEEZE) {
|
||||
if (c == " " || c == "\t") { # collapse a run of whitespace to one space
|
||||
while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++
|
||||
# the last char EMITTED, which on a continuation line lives on the
|
||||
# previous output line — hence lastc, not just `out`.
|
||||
prev = (length(out) ? substr(out, length(out), 1) : lastc)
|
||||
nxt = (i <= n ? substr(line, i, 1) : "")
|
||||
# drop it entirely next to a delimiter; otherwise it may be a combinator
|
||||
#
|
||||
# `>` is a delimiter too, and the only one that is itself a combinator: a
|
||||
# space either side of it is decoration. Safe because a `>` outside a
|
||||
# string can only be the child combinator — the sheet has no media range
|
||||
# syntax (`@media (width > 600px)`) — and a `>` inside a string never
|
||||
# reaches here, the scanner having copied it verbatim above. `~` and `+`
|
||||
# are deliberately NOT joined: `[attr~=v]` and `calc(100% - 10px)` make
|
||||
# them ambiguous without tracking bracket depth.
|
||||
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
|
||||
continue
|
||||
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
|
||||
continue
|
||||
out = out " "; lastreal = " "
|
||||
continue
|
||||
}
|
||||
# THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e.
|
||||
# INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted
|
||||
# onto the awk output, and sed cannot see strings: `content: ";}"` came out as
|
||||
# `content: "}"`, and a data-URI containing `;}` was corrupted the same way
|
||||
# (both reproduced). Nothing in the tree holds that byte pair today — which is
|
||||
# how such a bug waits for whoever adds the first one.
|
||||
#
|
||||
# `>` is a delimiter too, and the only one that is itself a combinator: a
|
||||
# space either side of it is decoration. 516 of them in the sheet, so it is
|
||||
# 1,032 B — the file header used to guess "~200 bytes" for this whole pass.
|
||||
# Safe because a `>` outside a string can only be the child combinator: the
|
||||
# sheet has no media range syntax (`@media (width > 600px)`), and the 107
|
||||
# `>` inside string literals never reach here, the scanner having copied them
|
||||
# verbatim above. `~` and `+` are deliberately NOT joined: `[attr~=v]` and
|
||||
# `calc(100% - 10px)` make them ambiguous without tracking bracket depth,
|
||||
# and they are worth 14 B and 34 B.
|
||||
if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">")
|
||||
continue
|
||||
if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">")
|
||||
continue
|
||||
out = out " "; lastreal = " "
|
||||
continue
|
||||
# The `;` may already sit in the previous line output, so text is held in
|
||||
# `buf` until the rule closes: a `;` already printed cannot be taken back.
|
||||
if (c == "}") {
|
||||
if (length(out) && substr(out, length(out), 1) == ";")
|
||||
out = substr(out, 1, length(out) - 1)
|
||||
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
|
||||
buf = substr(buf, 1, length(buf) - 1)
|
||||
}
|
||||
out = out c; lastreal = c; i++
|
||||
} else {
|
||||
out = out c; i++
|
||||
}
|
||||
# THE LAST `;` OF A BLOCK IS REDUNDANT — dropped as the `}` is emitted, i.e.
|
||||
# INSIDE the string-aware scanner. It used to be a `| sed "s/;}/}/g"` bolted onto
|
||||
# the awk output, and sed cannot see strings: `content: ";}"` came out as
|
||||
# `content: "}"`, and a data-URI containing `;}` was corrupted the same way (both
|
||||
# reproduced). Nothing in the tree holds that byte pair today — which is how such
|
||||
# a bug waits for whoever adds the first one.
|
||||
#
|
||||
# The `;` may already sit in the previous line output, so text is held in `buf`
|
||||
# until the rule closes: a `;` already printed cannot be taken back.
|
||||
if (c == "}") {
|
||||
if (length(out) && substr(out, length(out), 1) == ";")
|
||||
out = substr(out, 1, length(out) - 1)
|
||||
else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";")
|
||||
buf = substr(buf, 1, length(buf) - 1)
|
||||
}
|
||||
out = out c; lastreal = c; i++
|
||||
}
|
||||
buf = buf out
|
||||
if (length(out)) lastc = substr(out, length(out), 1)
|
||||
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
|
||||
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and flushing
|
||||
# there would split the rule and lose the space before its next token.
|
||||
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
|
||||
if (opened) {
|
||||
trimmed = out
|
||||
sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed)
|
||||
print trimmed
|
||||
lastc = ""; lastreal = ""
|
||||
next
|
||||
}
|
||||
if (SQUEEZE) {
|
||||
buf = buf out
|
||||
if (length(out)) lastc = substr(out, length(out), 1)
|
||||
# newline only after a closing brace — one rule per line. lastreal, not lastc: a
|
||||
# line ending in a QUOTED `}` (content: "}") is not the end of a rule, and
|
||||
# flushing there would split the rule and lose the space before its next token.
|
||||
if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" }
|
||||
} else {
|
||||
sub(/^[ \t]+/, "", out)
|
||||
sub(/[ \t]+$/, "", out)
|
||||
if (length(out)) print out
|
||||
}
|
||||
}
|
||||
END { if (length(buf)) print buf; else printf "\n" }
|
||||
END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
@@ -250,47 +236,38 @@ brace_count() {
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
# Always brace-check a COMMENT-STRIPPED copy, --dev included: counting braces in the raw
|
||||
# file made a stray "{" in prose fail the build on perfectly valid CSS.
|
||||
strip_comments "$TMP" > "$TMP.min"
|
||||
# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the
|
||||
# raw file made a stray "{" in prose fail the build on perfectly valid CSS.
|
||||
squeeze "$TMP" 0 > "$TMP.min"
|
||||
RULES_BEFORE=$(brace_count "$TMP.min") || exit 1
|
||||
rm -f "$TMP.min"
|
||||
|
||||
if [ "$DEV" -eq 0 ]; then
|
||||
# comments gone; now squeeze the whitespace CSS ignores
|
||||
squeeze "$TMP.min" > "$TMP"
|
||||
rm -f "$TMP.min"
|
||||
squeeze "$TMP" 1 > "$TMP.min"
|
||||
mv "$TMP.min" "$TMP"
|
||||
|
||||
# AND AGAIN, on what actually ships: the check above only saw the squeeze's INPUT, yet
|
||||
# the squeeze is the pass most able to corrupt the sheet — it tracks strings, joins lines
|
||||
# and deletes the `;` before a `}`. An unchanged rule count is what says it did not.
|
||||
#
|
||||
# That equality — not brace balance on its own — is also why the 80 KB FS_CSS_FLOOR size
|
||||
# guard once here is gone. Balance alone misses a cut right after any of the four
|
||||
# `@layer NAME { … }` wrappers closes: everything up to that point is still a matched set, so
|
||||
# a truncated write — a full disk, a compression that ate the tail — that stops exactly there
|
||||
# reads as a valid, complete-looking file. Fed the real sheet, a cut right after the base
|
||||
# layer's own close counts a balanced 283 rules against the file's true 1057. What catches
|
||||
# that is the COUNT: RULES_BEFORE is measured from the untruncated concatenation before this
|
||||
# pass runs, so a write that stops early can only ever come up short against it.
|
||||
RULES_AFTER=$(brace_count "$TMP") || exit 1
|
||||
if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then
|
||||
echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# --dev keeps comments AND formatting: this output is for reading, not shipping
|
||||
rm -f "$TMP.min"
|
||||
fi
|
||||
# --dev: $TMP is still the raw concatenation — comments and formatting intact, for reading on a
|
||||
# router rather than shipping.
|
||||
|
||||
# BROKEN-BUILD FLOOR (no upper size budget — removed). With only an upper bound, every way of
|
||||
# producing a SHORT file — a truncated write, a full disk, a squeeze that ate the tail — passed
|
||||
# and shipped a stylesheet with its second half missing. The floor catches that; it is a
|
||||
# correctness guard, not a size limit.
|
||||
#
|
||||
# Measured on $TMP and BEFORE the mv, with the EXIT trap still armed — like the brace checks above,
|
||||
# which had this order right. It used to run after, so the guard's own failure path LEFT the mangled
|
||||
# sheet at $OUT with nothing to clean it up: make aborts in Build/Prepare, fine, but dev-sync.sh
|
||||
# writes straight into htdocs/luci-static/footstrap/cascade.css, so a truncated sheet stayed in the
|
||||
# working tree and the CSS-only iterate loop (scp that same file) would ship it.
|
||||
SIZE=$(wc -c < "$TMP" | tr -d ' ')
|
||||
FLOOR=${FS_CSS_FLOOR:-81920} # 80 KB — well under the real sheet; only a mangled build lands here
|
||||
if [ "$DEV" -eq 0 ] && [ "$SIZE" -lt "$FLOOR" ]; then
|
||||
echo "build-css: cascade.css would be only $SIZE bytes, under the $FLOOR-byte floor." >&2
|
||||
echo "build-css: that is not a smaller stylesheet, that is a broken one. $OUT left untouched." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mv "$TMP" "$OUT"
|
||||
trap - EXIT
|
||||
|
||||
|
||||
@@ -98,10 +98,7 @@ for _f in "$D"/root/etc/config/*; do
|
||||
scp -q "$_f" "$R":"/tmp/.fs-conf-$_b"
|
||||
ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b"
|
||||
done
|
||||
# There is no self-update backend to deploy any more: the theme upgrades through the package feed
|
||||
# the installer adds, which is what a package manager is for.
|
||||
#
|
||||
# No rpcd reload here either — same race as the Makefile's postinst note (Package/luci-theme-
|
||||
# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme-
|
||||
# footstrap/postinst): this copies files onto a router where the theme registers no rpcd object,
|
||||
# rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already
|
||||
# carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless.
|
||||
|
||||
@@ -73,37 +73,28 @@ function probeColor(expr) {
|
||||
* the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the
|
||||
* wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read",
|
||||
* in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine
|
||||
* convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as
|
||||
* the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)`
|
||||
* forms can appear. */
|
||||
* convert instead (tools/export-tier.mjs uses the same method). Canvas 2D is universal on every
|
||||
* engine 24.10 ships against, so there is no second path. */
|
||||
let _cx = null;
|
||||
function rasterCtx() {
|
||||
if (_cx !== null) return _cx;
|
||||
try {
|
||||
if (!_cx) {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = 1;
|
||||
_cx = cv.getContext('2d', { willReadFrequently: true }) || false;
|
||||
} catch (e) { _cx = false; }
|
||||
_cx = cv.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
return _cx;
|
||||
}
|
||||
function parseColor(s) {
|
||||
const str = String(s || '');
|
||||
const cx = rasterCtx();
|
||||
if (cx) {
|
||||
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
|
||||
* report the previous one as a fresh reading — the trap probeColor() clears for */
|
||||
cx.fillStyle = '#000';
|
||||
cx.fillStyle = str;
|
||||
cx.clearRect(0, 0, 1, 1);
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||||
if (d[3] === 255) return [ d[0], d[1], d[2] ];
|
||||
/* translucent: composite over nothing is meaningless for a readout, so fall through */
|
||||
}
|
||||
const nums = str.match(/[\d.]+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const unit = (/^color\(/i).test(str) ? 255 : 1;
|
||||
return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit)));
|
||||
/* fillStyle keeps the last value it could parse, so a colour this engine rejects would
|
||||
* report the previous one as a fresh reading — the trap probeColor() clears for */
|
||||
cx.fillStyle = '#000';
|
||||
cx.fillStyle = String(s || '');
|
||||
cx.clearRect(0, 0, 1, 1);
|
||||
cx.fillRect(0, 0, 1, 1);
|
||||
const d = cx.getImageData(0, 0, 1, 1).data;
|
||||
/* translucent: composite over nothing is meaningless for a readout */
|
||||
return d[3] === 255 ? [ d[0], d[1], d[2] ] : null;
|
||||
}
|
||||
|
||||
/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states
|
||||
@@ -141,8 +132,8 @@ function toHex(s) {
|
||||
* `opts.probe` is the live token the effective colour is read back from, so the field shows the
|
||||
* palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is
|
||||
* the pair whose ratio is reported under the row. */
|
||||
function colorControl(current, onPick, label, opts) {
|
||||
const o = opts || {};
|
||||
function colorControl(onPick, label, opts) {
|
||||
const o = opts;
|
||||
|
||||
/* type=color leaves the picker to the browser: accessible without reimplementing a colour
|
||||
* wheel, and native on a phone. The text field beside it takes a pasted hex and is the
|
||||
@@ -155,10 +146,6 @@ function colorControl(current, onPick, label, opts) {
|
||||
const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]);
|
||||
const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null;
|
||||
|
||||
/* what the axis holds right now: the page can change it behind this control (a preset, Reset
|
||||
* to default), so a private copy would go stale. `current` is only the build-time value. */
|
||||
const currentOf = o.read || (() => current);
|
||||
|
||||
/* Repaint everything that mirrors the axis. Called after every edit, and through the returned
|
||||
* refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's
|
||||
* own colour is while this axis stays off. */
|
||||
@@ -207,7 +194,9 @@ function colorControl(current, onPick, label, opts) {
|
||||
const commit = () => {
|
||||
const v = field.value.trim().toLowerCase();
|
||||
if ((/^#[0-9a-f]{6}$/).test(v)) pick(v);
|
||||
else reflect(currentOf());
|
||||
/* re-read rather than a cached copy: the page can change what the axis holds behind this
|
||||
* control (a preset, Reset to default), which a snapshot taken at build time would miss */
|
||||
else reflect(o.read());
|
||||
};
|
||||
field.addEventListener('blur', commit);
|
||||
field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } });
|
||||
@@ -218,7 +207,7 @@ function colorControl(current, onPick, label, opts) {
|
||||
].concat(ratio ? [ ratio ] : []));
|
||||
/* the caller decides when this runs: probeColor() needs the document, and this control is not
|
||||
* in it yet */
|
||||
wrap.fsRefresh = () => reflect(currentOf());
|
||||
wrap.fsRefresh = () => reflect(o.read());
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -309,14 +298,15 @@ function build() {
|
||||
return node;
|
||||
};
|
||||
|
||||
/* one colour axis: `probe` is the live token the control reads the effective colour back from,
|
||||
* `contrast` the pair it reports */
|
||||
const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => {
|
||||
const ctl = colorControl(axis.current(), bump(axis.apply), lbl, {
|
||||
/* one colour axis: `axis` is the `{current, apply}` object fs-axes.js exports it as; `probe` is
|
||||
* the live token the control reads the effective colour back from, `contrast` the pair it
|
||||
* reports */
|
||||
const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => {
|
||||
const ctl = colorControl(bump(axis.apply), lbl, {
|
||||
probe: probe,
|
||||
read: axis.current,
|
||||
contrast: contrast,
|
||||
cls: (opts && opts.cls) || ''
|
||||
cls: opts.cls
|
||||
});
|
||||
colourCtls.push(ctl);
|
||||
return ctl;
|
||||
@@ -344,7 +334,7 @@ function build() {
|
||||
dark: _('Dark', 'footstrap')
|
||||
}, bump(repaint(prefs.applyMode)), label)),
|
||||
|
||||
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), {
|
||||
group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), {
|
||||
footstrap: 'Footstrap',
|
||||
hicontrast: 'Hi-Contrast',
|
||||
/* names the OTHER package, luci-theme-bootstrap, whose colours this palette is —
|
||||
@@ -355,7 +345,7 @@ function build() {
|
||||
/* 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)),
|
||||
}, bump(repaint(axes.palette.apply)), label)),
|
||||
|
||||
group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), {
|
||||
compact: _('Compact', 'footstrap'),
|
||||
@@ -370,11 +360,11 @@ function build() {
|
||||
* --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 })),
|
||||
(label) => sliderCtl(axes.contentWidth.current(), 1280, 3840,
|
||||
bump(axes.contentWidth.apply), label, { step: 40, live: false })),
|
||||
|
||||
group(_('Rounding', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)),
|
||||
(label) => sliderCtl(axes.radius.current(), 0, 20, bump(axes.radius.apply), label)),
|
||||
|
||||
/* The top layout has no accordion, so this switch is meaningless there: always built,
|
||||
* hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an
|
||||
@@ -392,9 +382,7 @@ function build() {
|
||||
const colours = [
|
||||
/* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody
|
||||
* would look for the router-identity cue under it */
|
||||
colourGroup(_('Tint (router identification)', 'footstrap'), {
|
||||
current: axes.currentTint, apply: axes.applyTint
|
||||
}, 'var(--fs-bg)', {
|
||||
colourGroup(_('Tint (router identification)', 'footstrap'), axes.tint, 'var(--fs-bg)', {
|
||||
/* the canvas is the one axis with no derived ink: its text is --fs-text, a palette
|
||||
* token this axis must not move, so the ratio is reported instead of corrected */
|
||||
fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap')
|
||||
@@ -406,7 +394,7 @@ function build() {
|
||||
* Not called "Density": that is the select above, and this string is both the caption and
|
||||
* the aria-label, so a screen reader would announce two rows under one name. */
|
||||
group(_('Tint strength', 'footstrap'),
|
||||
(label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, {
|
||||
(label) => sliderCtl(axes.tintStrength.current(), 0, 200, bump(repaint(axes.tintStrength.apply)), label, {
|
||||
step: 5
|
||||
}), { cls: 'fs-ap-tint fs-ap-tintstr' }),
|
||||
|
||||
@@ -419,12 +407,12 @@ function build() {
|
||||
* eight times between here and the surfaces below, they cost their repeated literals in
|
||||
* full — a string is not mangled — so the rows are data and the row is stated once. */
|
||||
...[
|
||||
[ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ],
|
||||
[ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ],
|
||||
[ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ],
|
||||
[ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ]
|
||||
].map(([ label, current, apply, ink ]) =>
|
||||
colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
|
||||
[ _('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)' ],
|
||||
[ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ],
|
||||
[ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ],
|
||||
[ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ]
|
||||
].map(([ label, axis, ink ]) =>
|
||||
colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD }))
|
||||
];
|
||||
|
||||
/* ---- the surfaces: the sheet the UI is drawn on ----
|
||||
@@ -439,12 +427,12 @@ function build() {
|
||||
/* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text
|
||||
* for the three that carry body text and the hairline itself for the border. */
|
||||
...[
|
||||
[ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||||
[ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||||
[ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||||
[ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||||
].map(([ label, current, apply, probe, fg, bg, where, kind ]) =>
|
||||
colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind }))
|
||||
[ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ],
|
||||
[ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ],
|
||||
[ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ],
|
||||
[ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ]
|
||||
].map(([ label, axis, probe, fg, bg, where, kind ]) =>
|
||||
colourGroup(label, axis, probe, { fg, bg, label: where, kind }))
|
||||
];
|
||||
|
||||
/* ---- section 3: the wallpaper and the rows each value brings ----
|
||||
@@ -493,30 +481,30 @@ function build() {
|
||||
group(_('Pattern', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]),
|
||||
{ extra: [ patInput, patPreview, patErr ] }),
|
||||
group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600,
|
||||
bump(axes.applyPatternSize), lbl, { step: 20 })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100,
|
||||
bump(axes.applyPatternStrength), lbl, { step: 5 })),
|
||||
group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), {
|
||||
group(scaleLabel, (lbl) => sliderCtl(axes.patternSize.current(), 40, 1600,
|
||||
bump(axes.patternSize.apply), lbl, { step: 20 })),
|
||||
group(strengthLabel, (lbl) => sliderCtl(axes.patternStrength.current(), 0, 100,
|
||||
bump(axes.patternStrength.apply), lbl, { step: 5 })),
|
||||
group(inkLabel, (lbl) => selectCtl(axes.patternInk.current(), {
|
||||
theme: _('Theme', 'footstrap'),
|
||||
original: _('As in file', 'footstrap')
|
||||
}, bump(axes.applyPatternInk), lbl))
|
||||
}, bump(axes.patternInk.apply), lbl))
|
||||
];
|
||||
/* …and the rows the FILE photo brings. */
|
||||
const fileRows = [
|
||||
group(_('File', 'footstrap'),
|
||||
() => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]),
|
||||
{ extra: [ fileInput, preview, err ] }),
|
||||
group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100,
|
||||
bump(axes.applyPhotoDim), lbl, { step: 5 }))
|
||||
group(dimLabel, (lbl) => sliderCtl(axes.photoDim.current(), 0, 100,
|
||||
bump(axes.photoDim.apply), lbl, { step: 5 }))
|
||||
];
|
||||
|
||||
function reflect(tok) {
|
||||
if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||||
if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; }
|
||||
else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; }
|
||||
}
|
||||
function reflectPattern(tok) {
|
||||
if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||||
if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; }
|
||||
else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; }
|
||||
}
|
||||
/* `hidden` on the row, which 80-appearance.css restates at a specificity beating
|
||||
@@ -527,11 +515,11 @@ function build() {
|
||||
patRows.forEach((r) => { r.hidden = (v !== 'pattern'); });
|
||||
fileRows.forEach((r) => { r.hidden = (v !== 'file'); });
|
||||
}
|
||||
reflect(axes.currentLoginBg());
|
||||
reflectPattern(axes.currentPattern());
|
||||
togglePanel(axes.currentWallpaper());
|
||||
reflect(axes.loginBg.current());
|
||||
reflectPattern(axes.pattern.current());
|
||||
togglePanel(axes.wallpaper.current());
|
||||
|
||||
const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||||
const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); refreshColours(); };
|
||||
|
||||
/* Both uploads present the same three controls and the same four states — pick, upload,
|
||||
* report, remove — so the wiring is stated once. What differs is `after`: the pattern also
|
||||
@@ -579,7 +567,7 @@ function build() {
|
||||
|
||||
let seg;
|
||||
const wallRow = group(_('Wallpaper', 'footstrap'), (label) => {
|
||||
seg = selectCtl(axes.currentWallpaper(), {
|
||||
seg = selectCtl(axes.wallpaper.current(), {
|
||||
off: _('Off', 'footstrap'),
|
||||
pattern: _('Pattern', 'footstrap'),
|
||||
file: _('File', 'footstrap')
|
||||
|
||||
@@ -212,20 +212,8 @@ function _sanitizeSvg(text) {
|
||||
* (Chromium and WebKit decline to run XSLT on an image/svg+xml document). A tile has no use for
|
||||
* one, and without the PI the embedded stylesheet is never applied. */
|
||||
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): a prologue of
|
||||
* `<!DOCTYPE svg SYSTEM "http://evil.example/x.dtd">` is a document-level construct this walk
|
||||
* never visits — `doc.doctype` is left as parsed, the same as everything else here that is not an
|
||||
* element or an attribute — and it survives `XMLSerializer` byte for byte on the same round trip
|
||||
* that runs below when anything else on the document needs cleaning. It costs nothing today
|
||||
* because no shipping engine resolves an external DTD for any document at all, `image/svg+xml`
|
||||
* included (external entity resolution has been off since the XXE era); a browser that ever did
|
||||
* would only reach that fetch by opening the file directly, which the CGI answers with
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` — every other consumer sees this bundle
|
||||
* only as a `mask-image`/`background-image`/`<img>` source, none of which parses a DOCTYPE at all.
|
||||
* Not closed: dropping `doc.doctype` would carve out one more node type in a walk that otherwise
|
||||
* judges only elements and their attributes, for a fetch nothing in this project's support matrix
|
||||
* performs. Starts mattering the day some consumer of this bundle DOES resolve an external DTD, or
|
||||
* the file is ever served as a navigable document without that header. */
|
||||
/* An external-DTD DOCTYPE prologue is left unvisited on purpose: verified-safe residual finding,
|
||||
* docs/architecture.md "The SVG sanitizer's residual, verified-safe findings". */
|
||||
for (const n of [ ...doc.childNodes ])
|
||||
if (n.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { n.remove(); elements++; }
|
||||
|
||||
@@ -265,21 +253,9 @@ function _sanitizeSvg(text) {
|
||||
* one the spec gives them — a change to how SVG itself is interpreted, not something a
|
||||
* check here could see coming or catch. */
|
||||
if ((/^on[a-z]+$/).test(n)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): XML attribute-value
|
||||
* normalisation does not fold a numeric character reference the way HTML does, so
|
||||
* `href="javas	cript:alert(1)"` reaches `v` as a DOM string with a LITERAL TAB still in
|
||||
* it — `/^javascript:/i` does not match a value starting with a control character, and the
|
||||
* fixture round-trips through `XMLSerializer` byte for byte, so it survives both this
|
||||
* filter and the serializer. It still resolves to `javascript:` in a real browser, because
|
||||
* a URL parser strips TAB/LF/CR from the scheme before comparing it (WHATWG URL, "scheme
|
||||
* start state") — the same class of hole every naive `^scheme:` regex has. Not closed:
|
||||
* matching every control character a URL parser discards is the grammar-guessing this file
|
||||
* exists to avoid, for a value that never resolves as a URL here in the first place — this
|
||||
* bundle only ever leaves the router as `mask-image`/`background-image`/`<img>`, none of
|
||||
* which reads `href` as navigable, and the CGI serving it back for a direct open sends
|
||||
* `Content-Security-Policy: default-src 'none'; sandbox` regardless. Starts mattering the
|
||||
* day the tile is served without that header, or inlined into a page rather than
|
||||
* referenced as an image. */
|
||||
/* A TAB-obfuscated `javascript:` (`href="javas	cript:alert(1)"`) passes this regex:
|
||||
* verified-safe residual finding, docs/architecture.md "The SVG sanitizer's residual,
|
||||
* verified-safe findings". */
|
||||
if ((/^javascript:/i).test(v)) { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Security review finding (LOW): `xml:base` (XML Base, W3C) is not itself a reference —
|
||||
* it changes what every RELATIVE one in the subtree UNDER IT resolves against. A perfectly
|
||||
@@ -296,22 +272,9 @@ function _sanitizeSvg(text) {
|
||||
* prefix to the XML namespace just to carry it is a narrower attack this does not chase,
|
||||
* consistent with this file avoiding a second grammar it would have to maintain. */
|
||||
if (n === 'xml:base') { el.removeAttribute(a.name); refs++; continue; }
|
||||
/* Residual, verified NOT exploitable here (2026-09 security review): the branch below is
|
||||
* the ONLY place a `url(` is ever inspected. A PRESENTATION ATTRIBUTE that takes the same
|
||||
* function — `fill`, `stroke`, `filter`, `clip-path`, `mask`, `cursor` (SVG 1.1 §11, §15) —
|
||||
* is a plain attribute value to this walk, not a CSS declaration, so `<rect
|
||||
* fill="url(//evil.example/x.svg#g)"/>` matches nothing above and passes with its ORIGINAL
|
||||
* bytes untouched. What it can do is start a second fetch — no script, no navigation,
|
||||
* exactly the reach `_externalStyleRef` already closes for `style` — and that fetch runs
|
||||
* only where the SVG is rendered with its own resource-loading context, which this bundle
|
||||
* never is: it is only ever a `mask-image`/`background-image`/`<img>` source, and a direct
|
||||
* open is answered by the CGI's `Content-Security-Policy: default-src 'none'; sandbox`
|
||||
* regardless. Not closed: doing here what `_sanitizeStyleValue` does for `style` means the
|
||||
* same check against every presentation attribute the SVG spec lets carry a `url()`, a list
|
||||
* this file would then have to track as the spec grows it — the grammar this file exists to
|
||||
* avoid guessing at, moved from one attribute to a dozen. Starts mattering the day a
|
||||
* presentation `url()` gets a fetch path this parser-only pass does not already block by
|
||||
* other means, or the tile stops being image-only. */
|
||||
/* A presentation attribute's `url(` (`fill`, `stroke`, `filter`, …) is only checked below
|
||||
* for `style`, not here: verified-safe residual finding, docs/architecture.md "The SVG
|
||||
* sanitizer's residual, verified-safe findings". */
|
||||
if (n === 'style') {
|
||||
const cleaned = _sanitizeStyleValue(v);
|
||||
if (cleaned.removed) {
|
||||
@@ -337,17 +300,6 @@ function _sanitizeSvg(text) {
|
||||
return { text: new XMLSerializer().serializeToString(doc), elements, refs };
|
||||
}
|
||||
|
||||
/* read the picked file as text so it can be inspected before upload, and so what reaches the
|
||||
* router is exactly the bytes that were checked */
|
||||
function _readText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result || ''));
|
||||
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- login/page background upload: router-side, and deliberately not an axis ----
|
||||
* The other axes are per-browser with a router default; this one has no browser layer. An admin
|
||||
* uploads an image once, it becomes the router-wide background for every device and shows
|
||||
@@ -490,7 +442,7 @@ function assetAxis(o) {
|
||||
.catch((e) => _rollbackUpload(o.path, e))
|
||||
.then(() => {
|
||||
/* switch this browser onto it: the ordinary axis path, localStorage only */
|
||||
axes.applyWallpaper(o.wallpaper);
|
||||
axes.wallpaper.apply(o.wallpaper);
|
||||
o.apply(tok);
|
||||
return tok;
|
||||
});
|
||||
@@ -510,13 +462,16 @@ function assetAxis(o) {
|
||||
* back a raster, so the parsed-document check above stands in for it. */
|
||||
const PATTERN = assetAxis({
|
||||
path: PAT_PATH, filename: 'pattern.svg', field: 'pattern', wallpaper: 'pattern',
|
||||
apply: (tok) => axes.applyPattern(tok),
|
||||
apply: (tok) => axes.pattern.apply(tok),
|
||||
prepare: (file) => {
|
||||
if (!file) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
|
||||
if (!isSvg) return Promise.reject(new Error(MSG_PICK_SVG));
|
||||
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
|
||||
return _readText(file).then((text) => {
|
||||
/* the catch keeps the same message, so what reaches the router is still exactly the bytes
|
||||
* that were checked */
|
||||
const readErr = () => Promise.reject(new Error(_('That file could not be read.', 'footstrap')));
|
||||
return file.text().catch(readErr).then((text) => {
|
||||
const cleaned = _sanitizeSvg(text);
|
||||
if (cleaned.error) return Promise.reject(new Error(cleaned.error));
|
||||
/* Told once, here, regardless of whether the upload that follows succeeds — what left
|
||||
@@ -535,7 +490,7 @@ const PATTERN = assetAxis({
|
||||
* grant for BG_PATH. */
|
||||
const LOGIN_BG = assetAxis({
|
||||
path: BG_PATH, filename: 'login-bg', field: 'login_bg', wallpaper: 'file',
|
||||
apply: (tok) => axes.applyLoginBg(tok),
|
||||
apply: (tok) => axes.loginBg.apply(tok),
|
||||
prepare: (file) => {
|
||||
if (!file || !(/^image\//).test(file.type || ''))
|
||||
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
|
||||
|
||||
@@ -121,7 +121,6 @@ function propAxis(key, sdKey, prop, min, max, dfl, fmt, after) {
|
||||
|
||||
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;
|
||||
|
||||
/* Wallpaper is a multi-value axis: off (bare canvas), pattern (the admin-uploaded SVG, tiled and
|
||||
* recoloured — 15-wallpaper.css) or file (the admin-uploaded photo, 16-login-bg.css).
|
||||
@@ -134,7 +133,6 @@ const currentPalette = PALETTE.current, applyPalette = PALETTE.apply;
|
||||
* in the list falls back to 'off'. */
|
||||
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
|
||||
const WALLPAPER = prefs.listAxis('fs-wallpaper', 'data-wallpaper', WALLPAPERS, 'off');
|
||||
const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
|
||||
|
||||
/* Density: how much air the UI uses. A three-value axis like wallpaper, and a pure token axis —
|
||||
* 02-tokens.css multiplies the type and space ladders and every size follows, with no layout switch
|
||||
@@ -145,14 +143,12 @@ const currentWallpaper = WALLPAPER.current, applyWallpaper = WALLPAPER.apply;
|
||||
* otherwise the bar stays stacked — or stays unstacked and overflows — until the next resize. */
|
||||
|
||||
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
|
||||
const currentTint = TINT.current, applyTint = TINT.apply;
|
||||
|
||||
/* Accent axis: the UI accent (solid buttons, toggle knobs, sliders, focus rings, accented links)
|
||||
* while canvas, cards and status colours stay put. On a hue, CSS rotates --fs-accent and keeps the
|
||||
* palette's lightness and chroma, so --fs-on-accent stays legible unrecomputed; on a hex the ink is
|
||||
* recomputed from the entered colour's lightness (03-palettes.css). 0 = off. */
|
||||
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
|
||||
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
|
||||
|
||||
/* The three status colours are the same axis pointed at --fs-good / --fs-warn / --fs-danger, kept
|
||||
* separate because they carry separate meanings and every derived tint is a color-mix() of the
|
||||
@@ -197,25 +193,16 @@ function surfaceAxis(key, sdKey, prop) {
|
||||
};
|
||||
}
|
||||
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
|
||||
const currentCard = CARD.current, applyCard = CARD.apply;
|
||||
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
|
||||
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
|
||||
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
|
||||
const currentBar = BAR.current, applyBar = BAR.apply;
|
||||
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
|
||||
const currentLine = LINE.current, applyLine = LINE.apply;
|
||||
|
||||
|
||||
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
|
||||
const currentGood = GOOD.current, applyGood = GOOD.apply;
|
||||
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
|
||||
const currentWarn = WARN.current, applyWarn = WARN.apply;
|
||||
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
|
||||
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
|
||||
|
||||
/* Rounding: the propAxis instance (default const and rationale up top), --fs-radius-base in px. */
|
||||
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
|
||||
@@ -228,7 +215,6 @@ const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault
|
||||
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
|
||||
@@ -256,14 +242,12 @@ const AXIS_KEYS = [
|
||||
* module throws, taking the chrome and the menu with it. */
|
||||
const FS_TSTR_DEFAULT = 100;
|
||||
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
|
||||
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
|
||||
|
||||
/* Photo dim: the scrim opacity over the FILE photo (0–100%). The photo is shared; how strongly
|
||||
* this browser dims it is not, and it reaches the router through Save-as-default. Only bites while
|
||||
* the wallpaper is 'file'. Declared up here for the TDZ reason above. */
|
||||
const FS_PDIM_DEFAULT = 74;
|
||||
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
|
||||
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
|
||||
|
||||
/* The pattern's two live knobs, and the third that is an enum. All three bite only while the
|
||||
* wallpaper is 'pattern'; the FILE is shared, how this browser draws it is not.
|
||||
@@ -273,14 +257,11 @@ const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefaul
|
||||
* the file would put out of CSS's reach. Declared up here for the TDZ reason above. */
|
||||
const FS_PSIZE_DEFAULT = 440;
|
||||
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
|
||||
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
|
||||
const FS_PSTR_DEFAULT = 20;
|
||||
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
|
||||
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
|
||||
/* Ink: 'theme' (the file's alpha, the theme's colour) or 'original' (the file's own colours, no
|
||||
* mask). Two-valued with the default as a bare :root, i.e. the enumAxis shape. */
|
||||
const PINK = prefs.enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
|
||||
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
|
||||
/* `reject: true` is load-bearing: without it a refused write arrives as SUCCESS. rpc.js raises on
|
||||
* the ubus status code only when the declaration asks it to, and otherwise hands the code back as
|
||||
* the resolved value — measured on the router, a per-config ACL refusal resolves with 6
|
||||
@@ -294,26 +275,26 @@ function snapshotAxes() {
|
||||
return {
|
||||
layout: prefs.currentLayout(),
|
||||
darkmode: prefs.currentMode(),
|
||||
palette: currentPalette(),
|
||||
wallpaper: currentWallpaper(),
|
||||
tint: String(currentTint()),
|
||||
accent: String(currentAccent()),
|
||||
good: String(currentGood()),
|
||||
warn: String(currentWarn()),
|
||||
danger: String(currentDanger()),
|
||||
card: String(currentCard()),
|
||||
control: String(currentControl()),
|
||||
bar: String(currentBar()),
|
||||
line: String(currentLine()),
|
||||
rounding: String(currentRadius()),
|
||||
palette: PALETTE.current(),
|
||||
wallpaper: WALLPAPER.current(),
|
||||
tint: String(TINT.current()),
|
||||
accent: String(ACCENT.current()),
|
||||
good: String(GOOD.current()),
|
||||
warn: String(WARN.current()),
|
||||
danger: String(DANGER.current()),
|
||||
card: String(CARD.current()),
|
||||
control: String(CONTROL.current()),
|
||||
bar: String(BAR.current()),
|
||||
line: String(LINE.current()),
|
||||
rounding: String(RADIUS.current()),
|
||||
autocollapse: prefs.currentAutoCollapse() ? 'on' : 'off',
|
||||
tint_strength: String(currentTintStrength()),
|
||||
tint_strength: String(TSTR.current()),
|
||||
density: prefs.currentDensity(),
|
||||
photo_dim: String(currentPhotoDim()),
|
||||
pattern_size: String(currentPatternSize()),
|
||||
pattern_strength: String(currentPatternStrength()),
|
||||
pattern_ink: currentPatternInk(),
|
||||
content_width: String(currentContentWidth())
|
||||
photo_dim: String(PDIM.current()),
|
||||
pattern_size: String(PSIZE.current()),
|
||||
pattern_strength: String(PSTR.current()),
|
||||
pattern_ink: PINK.current(),
|
||||
content_width: String(CWIDTH.current())
|
||||
};
|
||||
}
|
||||
/* The resolved router default (the uci value if set, else the built-in) in snapshotAxes() string
|
||||
@@ -341,15 +322,15 @@ function _resolvedDefault() {
|
||||
control: String(CONTROL.def()),
|
||||
bar: String(BAR.def()),
|
||||
line: String(LINE.def()),
|
||||
rounding: String(radiusDefault()),
|
||||
rounding: String(RADIUS.def()),
|
||||
autocollapse: (prefs.autoCollapseDefault() ? 'on' : 'off'),
|
||||
tint_strength: String(tintStrengthDefault()),
|
||||
tint_strength: String(TSTR.def()),
|
||||
density: prefs.densityDefault(),
|
||||
photo_dim: String(photoDimDefault()),
|
||||
pattern_size: String(patternSizeDefault()),
|
||||
pattern_strength: String(patternStrengthDefault()),
|
||||
photo_dim: String(PDIM.def()),
|
||||
pattern_size: String(PSIZE.def()),
|
||||
pattern_strength: String(PSTR.def()),
|
||||
pattern_ink: PINK.def(),
|
||||
content_width: String(contentWidthDefault())
|
||||
content_width: String(CWIDTH.def())
|
||||
};
|
||||
}
|
||||
let _savedDefault = _resolvedDefault();
|
||||
@@ -405,26 +386,26 @@ function resetToBuiltin() {
|
||||
* first click — the one state no static gate can see and the live check does. */
|
||||
prefs.applyLayout('top');
|
||||
prefs.applyMode('auto');
|
||||
applyPalette('footstrap');
|
||||
applyWallpaper('off');
|
||||
applyTint(0);
|
||||
applyAccent(0);
|
||||
applyGood(0);
|
||||
applyWarn(0);
|
||||
applyDanger(0);
|
||||
applyCard(0);
|
||||
applyControl(0);
|
||||
applyBar(0);
|
||||
applyLine(0);
|
||||
applyRadius(FS_RADIUS_DEFAULT);
|
||||
PALETTE.apply('footstrap');
|
||||
WALLPAPER.apply('off');
|
||||
TINT.apply(0);
|
||||
ACCENT.apply(0);
|
||||
GOOD.apply(0);
|
||||
WARN.apply(0);
|
||||
DANGER.apply(0);
|
||||
CARD.apply(0);
|
||||
CONTROL.apply(0);
|
||||
BAR.apply(0);
|
||||
LINE.apply(0);
|
||||
RADIUS.apply(FS_RADIUS_DEFAULT);
|
||||
prefs.applyAutoCollapse('off');
|
||||
applyTintStrength(FS_TSTR_DEFAULT);
|
||||
TSTR.apply(FS_TSTR_DEFAULT);
|
||||
prefs.applyDensity('normal');
|
||||
applyPhotoDim(FS_PDIM_DEFAULT);
|
||||
applyPatternSize(FS_PSIZE_DEFAULT);
|
||||
applyPatternStrength(FS_PSTR_DEFAULT);
|
||||
applyPatternInk('theme');
|
||||
applyContentWidth(FS_CWIDTH_DEFAULT);
|
||||
PDIM.apply(FS_PDIM_DEFAULT);
|
||||
PSIZE.apply(FS_PSIZE_DEFAULT);
|
||||
PSTR.apply(FS_PSTR_DEFAULT);
|
||||
PINK.apply('theme');
|
||||
CWIDTH.apply(FS_CWIDTH_DEFAULT);
|
||||
}
|
||||
|
||||
/* ---- the two uploaded wallpapers, browser side ----
|
||||
@@ -438,31 +419,6 @@ function resetToBuiltin() {
|
||||
* layer — one admin uploads once and every device sees it, pre-login included. So they are absent
|
||||
|
||||
* from AXIS_KEYS, snapshotAxes() and matchesSavedDefault(), and must not move the Save button. */
|
||||
/* NOT a /www symlink, unlike BG_SERVE below: an SVG served as a plain static file is a same-origin
|
||||
* document that runs script opened directly (OpenWrt forum thread 251930). This CGI handler reads
|
||||
* the fixed /etc/footstrap/pattern.svg path itself and answers with CSP 'none' + sandbox and
|
||||
* nosniff, headers uhttpd cannot attach to a static file — see the uci-default and the handler for
|
||||
* the rest of the reasoning. Painting stays exactly as before: a URL is a URL to a mask-image. */
|
||||
const PAT_SERVE = '/cgi-bin/luci-theme-footstrap-pattern';
|
||||
function currentPattern() {
|
||||
const t = prefs.sd('pattern');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
|
||||
|
||||
/* set/clear the tile URL live. This only supplies the url(); whether it PAINTS is the Wallpaper
|
||||
* axis (data-wallpaper="pattern"). Exported because fs-assets.js applies the token it just wrote. */
|
||||
function applyPattern(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-pattern-url');
|
||||
prefs.setSD('pattern', tok || '');
|
||||
}
|
||||
|
||||
/* Still the plain uhttpd symlink the uci-default makes, unlike PAT_SERVE above: fs-assets.js
|
||||
* re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the stored bytes
|
||||
* are pixels only — there is no script grammar left in a raster for a direct hit to run. */
|
||||
const BG_SERVE = '/luci-static/footstrap/bg';
|
||||
/* the cache-bust token charset, an md5/sha hex string. One copy here; head.ut's ucode sanitiser
|
||||
* and the pre-paint inline script keep their own identical copies unavoidably, running before this
|
||||
* module — see the axes contract in head.ut. */
|
||||
@@ -471,45 +427,79 @@ const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
|
||||
* A predicate rather than the pattern itself, so the charset stays stated once. */
|
||||
function tokenOk(t) { return BG_TOKEN_RE.test(t); }
|
||||
|
||||
/* the token the server last saved, validated to the same hex charset head.ut's sanitiser and
|
||||
* pre-paint use, so the Appearance tab can build a cache-busted preview src. '' = none. */
|
||||
function currentLoginBg() {
|
||||
const t = prefs.sd('login_bg');
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
}
|
||||
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
|
||||
|
||||
/* applyPattern's twin for the photo; data-wallpaper="file" decides whether it paints. */
|
||||
function applyLoginBg(tok) {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty('--fs-login-bg-url', 'url("' + loginBgUrl(tok) + '")');
|
||||
else root.style.removeProperty('--fs-login-bg-url');
|
||||
prefs.setSD('login_bg', tok || '');
|
||||
/* The pattern and the login-bg photo are the same shape twice: a router-saved cache-bust TOKEN,
|
||||
* read back through prefs.sd(), that sets or clears one url() custom property. Neither is an axis
|
||||
* (see the comment above) — this factory is what a bare url() setter shares with a real axis's
|
||||
* apply(), not membership in AXIS_KEYS. `serve` differs for a reason stated at each call below. */
|
||||
function tokenAsset(sdKey, serve, prop) {
|
||||
const current = () => {
|
||||
const t = prefs.sd(sdKey);
|
||||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||||
};
|
||||
const url = (tok) => serve + '?v=' + tok;
|
||||
/* set/clear the url() live; whether it PAINTS is the Wallpaper axis (data-wallpaper="pattern"
|
||||
* or "file"). Exported because fs-assets.js applies the token it just wrote. */
|
||||
const apply = (tok) => {
|
||||
const root = document.documentElement;
|
||||
if (tok) root.style.setProperty(prop, 'url("' + url(tok) + '")');
|
||||
else root.style.removeProperty(prop);
|
||||
prefs.setSD(sdKey, tok || '');
|
||||
};
|
||||
return { current, url, apply };
|
||||
}
|
||||
|
||||
/* NOT a /www symlink, unlike LOGIN_BG's plain static path below: an SVG served as a plain static
|
||||
* file is a same-origin document that runs script opened directly (OpenWrt forum thread 251930).
|
||||
* This CGI handler reads the fixed /etc/footstrap/pattern.svg path itself and answers with CSP
|
||||
* 'none' + sandbox and nosniff, headers uhttpd cannot attach to a static file — see the uci-default
|
||||
* and the handler for the rest of the reasoning. Painting stays exactly as before: a URL is a URL
|
||||
* to a mask-image. */
|
||||
const PATTERN = tokenAsset('pattern', '/cgi-bin/luci-theme-footstrap-pattern', '--fs-pattern-url');
|
||||
|
||||
/* Still the plain uhttpd symlink the uci-default makes, unlike PATTERN's CGI handler above:
|
||||
* fs-assets.js re-encodes every login-bg upload to a JPEG on a canvas before it is sent, so the
|
||||
* stored bytes are pixels only — there is no script grammar left in a raster for a direct hit to
|
||||
* run. */
|
||||
const LOGIN_BG = tokenAsset('login_bg', '/luci-static/footstrap/bg', '--fs-login-bg-url');
|
||||
|
||||
/* Every axis exported as the OBJECT itself — `{ current, apply, def? }` — not a flat
|
||||
* currentX()/applyX() pair per axis: a caller wanting both already had to carry them as a pair
|
||||
* (fs-appearance.js's colourGroup() takes `{ current, apply }`, built ad hoc from two separate
|
||||
* lookups before this), and a flat name is one more thing every consumer of THIS FILE has to keep
|
||||
* spelled the same way fs-axes.js does — 18 axis names doubled to 36 lookups, now 18. `pattern`
|
||||
* and `loginBg` are not axes (no AXIS_KEYS entry, no router-default `def()`) but the same
|
||||
* `tokenAsset()` shape, so they export the same way. `tokenOk`, `matchesSavedDefault`,
|
||||
* `saveAsDefault`, `resetToSaved` and `resetToBuiltin` stay flat: none of them is a
|
||||
* `{current, apply}` pair. `snapshotAxes` stays module-private: fs-appearance.js, its only would-be
|
||||
* caller, builds the Appearance form from the per-axis exports above, not from the router-default
|
||||
* snapshot matchesSavedDefault()/saveAsDefault() read internally.
|
||||
*
|
||||
* No flat `currentX`/`applyX` fallback kept: grepped `../luci-app-footstrap-*` (the sibling
|
||||
* checkouts) and this repo's docs for either spelling — nothing outside fs-appearance.js and
|
||||
* fs-assets.js, both updated alongside this file, ever called one. */
|
||||
return baseclass.extend({
|
||||
currentPalette, applyPalette,
|
||||
currentWallpaper, applyWallpaper,
|
||||
currentTint, applyTint,
|
||||
currentAccent, applyAccent,
|
||||
currentGood, applyGood,
|
||||
currentWarn, applyWarn,
|
||||
currentDanger, applyDanger,
|
||||
currentCard, applyCard,
|
||||
currentControl, applyControl,
|
||||
currentBar, applyBar,
|
||||
currentLine, applyLine,
|
||||
currentRadius, applyRadius,
|
||||
currentTintStrength, applyTintStrength,
|
||||
currentPhotoDim, applyPhotoDim,
|
||||
currentPatternSize, applyPatternSize,
|
||||
currentPatternStrength, applyPatternStrength,
|
||||
currentPatternInk, applyPatternInk,
|
||||
currentContentWidth, applyContentWidth, contentWidthDefault,
|
||||
palette: PALETTE,
|
||||
wallpaper: WALLPAPER,
|
||||
tint: TINT,
|
||||
accent: ACCENT,
|
||||
good: GOOD,
|
||||
warn: WARN,
|
||||
danger: DANGER,
|
||||
card: CARD,
|
||||
control: CONTROL,
|
||||
bar: BAR,
|
||||
line: LINE,
|
||||
radius: RADIUS,
|
||||
tintStrength: TSTR,
|
||||
photoDim: PDIM,
|
||||
patternSize: PSIZE,
|
||||
patternStrength: PSTR,
|
||||
patternInk: PINK,
|
||||
contentWidth: CWIDTH,
|
||||
pattern: PATTERN,
|
||||
loginBg: LOGIN_BG,
|
||||
|
||||
currentPattern, patternUrl, applyPattern,
|
||||
currentLoginBg, loginBgUrl, applyLoginBg,
|
||||
tokenOk,
|
||||
|
||||
snapshotAxes, matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
|
||||
matchesSavedDefault, saveAsDefault, resetToSaved, resetToBuiltin
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
'require fs-fit as fit';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-menutree as tree';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
|
||||
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
|
||||
@@ -18,11 +19,8 @@ function setRenderMain(fn) {
|
||||
|
||||
/* section tabs -> #tabmenu (horizontal) */
|
||||
function renderTabMenu(node, url, level) {
|
||||
/* #tabmenu is emitted whenever this module loads (notices.ut, !blank_page) */
|
||||
const container = document.querySelector('#tabmenu');
|
||||
/* a template without the container must not reject: an unhandled rejection here kills the
|
||||
* whole ui.menu.load() chain, i.e. every menu */
|
||||
if (!container)
|
||||
return E([]);
|
||||
const ul = E('ul', { 'class': 'tabs' });
|
||||
const children = ui.menu.getChildren(node);
|
||||
let activeNode = null;
|
||||
@@ -39,15 +37,13 @@ function renderTabMenu(node, url, level) {
|
||||
});
|
||||
|
||||
if (ul.children.length === 0)
|
||||
return E([]);
|
||||
return;
|
||||
|
||||
container.appendChild(ul);
|
||||
container.style.display = '';
|
||||
|
||||
if (activeNode)
|
||||
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
/* ---- tab-strip auto-fit ----
|
||||
@@ -150,17 +146,16 @@ function shellGeometry() {
|
||||
/* the gutter is re-asked even on a memo hit: it moves with the width, not the 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),
|
||||
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: px('--fs-rail-w', GEOM_DFLT.railW),
|
||||
contentMin: resolveLen('--fs-content-min', GEOM_DFLT.contentMin),
|
||||
sidebarW: resolveLen('--fs-sidebar-w', GEOM_DFLT.sidebarW),
|
||||
railW: resolveLen('--fs-rail-w', GEOM_DFLT.railW),
|
||||
/* the token is one side's padding; the column loses it twice. It is only the fallback —
|
||||
* measureShell() overwrites this with the gutter the column actually got, which nothing
|
||||
* has measured before the first fitter (and the login page has no `.fs-content`). */
|
||||
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
contentPad: resolveLen('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
|
||||
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
|
||||
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
|
||||
contentMax: resolveLen('--fs-content-max', GEOM_DFLT.contentMax)
|
||||
};
|
||||
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
|
||||
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
|
||||
@@ -401,6 +396,7 @@ function clusterFitsBrandRow(bar, menu) {
|
||||
|
||||
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
|
||||
function renderModeMenu(node, renderMainMenu) {
|
||||
/* #modemenu is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
const ul = document.querySelector('#modemenu');
|
||||
const children = ui.menu.getChildren(node);
|
||||
|
||||
@@ -409,18 +405,14 @@ function renderModeMenu(node, renderMainMenu) {
|
||||
? child.name === L.env.requestpath[0]
|
||||
: index === 0;
|
||||
|
||||
/* the main menu must render even where a template has no #modemenu */
|
||||
if (ul)
|
||||
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
|
||||
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
|
||||
]));
|
||||
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
|
||||
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
|
||||
]));
|
||||
|
||||
if (isActive)
|
||||
renderMainMenu(child, child.name);
|
||||
});
|
||||
|
||||
if (!ul)
|
||||
return;
|
||||
if (children.length <= 1)
|
||||
ul.classList.add('single');
|
||||
if (ul.children.length > 1)
|
||||
@@ -431,13 +423,15 @@ function renderModeMenu(node, renderMainMenu) {
|
||||
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
|
||||
function renderChrome() {
|
||||
const root = tree.tree();
|
||||
/* #modemenu/#topmenu/#tabmenu are emitted whenever this module loads (header.ut/notices.ut,
|
||||
* !blank_page) */
|
||||
const modemenu = document.querySelector('#modemenu');
|
||||
const topmenu = document.querySelector('#topmenu');
|
||||
const tabmenu = document.querySelector('#tabmenu');
|
||||
|
||||
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
|
||||
if (topmenu) topmenu.innerHTML = '';
|
||||
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
|
||||
modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single');
|
||||
topmenu.innerHTML = '';
|
||||
tabmenu.innerHTML = ''; tabmenu.style.display = 'none';
|
||||
|
||||
renderModeMenu(root, _renderMain);
|
||||
|
||||
@@ -461,8 +455,8 @@ function renderChrome() {
|
||||
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
|
||||
* CSS keyed off that attribute. */
|
||||
function wireRail() {
|
||||
/* #fs-rail-toggle is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
const btn = document.getElementById('fs-rail-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function sync() {
|
||||
const on = prefs.currentRail();
|
||||
@@ -494,38 +488,23 @@ 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. */
|
||||
* never invented. widgets.wireActivate() calls el.click() because a <span>, unlike an <a>, gets
|
||||
* neither Enter nor Space for free; 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();
|
||||
});
|
||||
widgets.wireActivate(el, () => el.click());
|
||||
}
|
||||
|
||||
function wireIndicatorCounts() {
|
||||
/* #indicators is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
const box = document.getElementById('indicators');
|
||||
if (!box) return;
|
||||
|
||||
function stamp() {
|
||||
box.querySelectorAll('[data-indicator]').forEach((el) => {
|
||||
@@ -541,9 +520,9 @@ function wireIndicatorCounts() {
|
||||
* 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);
|
||||
widgets.syncAttr(el, 'role', 'button');
|
||||
widgets.syncAttr(el, 'tabindex', '0');
|
||||
widgets.syncAttr(el, 'aria-label', txt.trim() || null);
|
||||
wireIndicatorKeyboard(el);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,23 +65,34 @@ function holdFloor(records) {
|
||||
if (scrolling()) return;
|
||||
const host = document.getElementById('view');
|
||||
if (!host) return; /* the login page has no view */
|
||||
const boxes = [], hs = [];
|
||||
/* boxes stays an array — order feeds the parallel `hs` below and dirty's own .filter() — with a
|
||||
* Set alongside it purely for the O(1) "already have this one" check .indexOf() was doing in
|
||||
* O(n). */
|
||||
const boxes = [], boxSeen = new Set(), hs = [];
|
||||
host.querySelectorAll(SHRINKS).forEach((el) => {
|
||||
let box = el, cs = window.getComputedStyle(el);
|
||||
while (box && box !== host && cs.display.startsWith('table')) {
|
||||
box = box.parentElement;
|
||||
if (box) cs = window.getComputedStyle(box);
|
||||
}
|
||||
if (!box || box === host || boxes.indexOf(box) !== -1) return;
|
||||
if (!box || box === host || boxSeen.has(box)) return;
|
||||
boxSeen.add(box);
|
||||
boxes.push(box);
|
||||
});
|
||||
host.querySelectorAll(FLOORED).forEach((box) => {
|
||||
if (boxSeen.has(box)) return;
|
||||
boxSeen.add(box);
|
||||
boxes.push(box);
|
||||
});
|
||||
host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });
|
||||
|
||||
let dirty = boxes;
|
||||
if (records && records.length) {
|
||||
const targets = [];
|
||||
for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
|
||||
dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
|
||||
const targets = new Set();
|
||||
for (const r of records) if (r.target) targets.add(r.target);
|
||||
dirty = boxes.filter((box) => {
|
||||
for (const t of targets) if (box.contains(t) || t.contains(box)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!dirty.length) return;
|
||||
|
||||
@@ -363,14 +374,28 @@ function anchorRef() {
|
||||
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
let _anchorWhy = null;
|
||||
const _anchorTrail = [];
|
||||
function awhy(w) {
|
||||
_anchorWhy = w;
|
||||
_anchorTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_anchorTrail.length > 8) _anchorTrail.shift();
|
||||
/* Two independent diagnostic trails — one per correction path (the frame-coalesced anchor below,
|
||||
* the late drift correction further down) — same shape twice: the last reason recorded, and the
|
||||
* last 8 with a timestamp, for tools/scroll-anchor.mjs to read back after a probe run. One factory,
|
||||
* so the cap and the timestamp format cannot drift between the two. */
|
||||
const TRAIL_MAX = 8;
|
||||
function makeTrail() {
|
||||
let last = null;
|
||||
const entries = [];
|
||||
return {
|
||||
record(w) {
|
||||
last = w;
|
||||
entries.push(w + '@' + Math.round(performance.now()));
|
||||
if (entries.length > TRAIL_MAX) entries.shift();
|
||||
},
|
||||
last: () => last,
|
||||
entries: () => entries
|
||||
};
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
const _anchor = makeTrail();
|
||||
function awhy(w) { _anchor.record(w); }
|
||||
let _anchorFrame = 0;
|
||||
function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
@@ -380,14 +405,8 @@ function anchorEnabled() {
|
||||
* It keeps a reference still; it does not promise that a section can vanish and come back. An
|
||||
* older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
|
||||
let _lateFrame = 0;
|
||||
/* why the last late correction did or did not write — eight exits, one symptom from outside */
|
||||
let _lateWhy = null;
|
||||
const _lateTrail = [];
|
||||
function why(w) {
|
||||
_lateWhy = w;
|
||||
_lateTrail.push(w + '@' + Math.round(performance.now()));
|
||||
if (_lateTrail.length > 8) _lateTrail.shift();
|
||||
}
|
||||
const _late = makeTrail();
|
||||
function why(w) { _late.record(w); }
|
||||
|
||||
function lateDrift(ref, grow, floorShrink) {
|
||||
if (_lateFrame) return why('busy');
|
||||
@@ -607,11 +626,11 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
scrolling,
|
||||
/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
|
||||
lateWhy: () => _lateWhy,
|
||||
lateTrail: () => _lateTrail.slice(),
|
||||
anchorWhy: () => _anchorWhy,
|
||||
anchorTrail: () => _anchorTrail.slice(),
|
||||
/* unmarked, for tools/scroll-anchor.mjs — see `makeTrail()` above */
|
||||
lateWhy: () => _late.last(),
|
||||
lateTrail: () => _late.entries().slice(),
|
||||
anchorWhy: () => _anchor.last(),
|
||||
anchorTrail: () => _anchor.entries().slice(),
|
||||
deferMeasurement,
|
||||
|
||||
/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
|
||||
|
||||
@@ -19,7 +19,7 @@ function setTree(tree) {
|
||||
* reserved for a path outside LuCI's scriptname. */
|
||||
function segsFromPath(pathname) {
|
||||
const base = L.env.scriptname || '';
|
||||
if (base && pathname.indexOf(base) !== 0)
|
||||
if (base && !pathname.startsWith(base))
|
||||
return null;
|
||||
const rest = pathname.slice(base.length).replace(/^\/+|\/+$/g, '');
|
||||
return rest.length ? rest.split('/') : [];
|
||||
@@ -159,10 +159,6 @@ function currentNode() {
|
||||
return baseclass.extend({
|
||||
setTree,
|
||||
tree: () => _tree,
|
||||
/* raw presence, no alias or firstchild resolution: fs-commands gates each command on the menu
|
||||
* node whose `depends.acl` names the group that command needs, and a node that resolves
|
||||
* elsewhere would answer for a permission the session may not hold */
|
||||
nodeForSegs,
|
||||
segsFromPath,
|
||||
currentNode,
|
||||
resolveSegs,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
'require dom';
|
||||
'require network';
|
||||
'require fs-fit as fit';
|
||||
'require fs-widgets as widgets';
|
||||
'require menu-footstrap-common as common';
|
||||
|
||||
/* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory /
|
||||
@@ -21,8 +22,6 @@
|
||||
/* section title -> grid role. _() with no msgctxt on purpose: these must resolve to exactly what
|
||||
* luci-mod-status resolves to, or the titles stop matching. Built once, not per poll tick. */
|
||||
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 headerEl(sec) {
|
||||
/* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits
|
||||
@@ -50,23 +49,11 @@ function sectionTitle(sec) {
|
||||
* 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. */
|
||||
@@ -96,14 +83,14 @@ function wireDisclosure(sec) {
|
||||
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');
|
||||
widgets.syncAttr(h, 'role', 'button');
|
||||
widgets.syncAttr(h, 'tabindex', '0');
|
||||
widgets.syncAttr(h, 'aria-controls', panel ? panel.id : null);
|
||||
widgets.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');
|
||||
widgets.syncAttr(label, 'aria-hidden', 'true');
|
||||
|
||||
if (h.dataset.fsWired) return;
|
||||
h.dataset.fsWired = '1';
|
||||
@@ -112,16 +99,12 @@ function wireDisclosure(sec) {
|
||||
/* 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');
|
||||
widgets.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();
|
||||
});
|
||||
* menu-footstrap.js's wireSpaceKey, written for an <a role="button">, which gets Enter for
|
||||
* free and needs only Space added */
|
||||
widgets.wireActivate(h, () => label.click());
|
||||
}
|
||||
|
||||
/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title
|
||||
@@ -216,15 +199,13 @@ function arrange() {
|
||||
_wrapEl = wrap;
|
||||
}
|
||||
|
||||
/* Stock sections render async and repaint every poll, so watch #view and re-run arrange(),
|
||||
* coalesced and one observer per #view node. The SPA router may replace #view between visits, so
|
||||
* re-attach when the observed node is no longer the current one — a singleton bound to the first
|
||||
* #view would watch a detached tree and the grid would never apply again. */
|
||||
let _observer = null, _observedRoot = null, _routeObserver = null;
|
||||
/* Stock sections render async and repaint every poll, so watch #maincontent and re-run arrange(),
|
||||
* coalesced. #maincontent (unlike #view) outlives every SPA swap, so one observer, attached once,
|
||||
* is never stale and never needs to be re-attached. */
|
||||
let _observer = null, _routeObserver = null;
|
||||
function stopWatch() {
|
||||
if (_observer) _observer.disconnect();
|
||||
_observer = null;
|
||||
_observedRoot = null;
|
||||
_wrapEl = null; /* the grid belongs to the #view we are leaving */
|
||||
}
|
||||
function watch() {
|
||||
@@ -234,16 +215,12 @@ function watch() {
|
||||
* first — so the observer bound here read `isConnected: false` after one round trip and the
|
||||
* grid stopped being re-arranged on every poll tick, silently, until the next full load. The
|
||||
* shell's column outlives every swap. Same fault, same fix as fs-appearance.js. */
|
||||
const root = document.getElementById('maincontent') || view;
|
||||
if (_observer && _observedRoot !== root)
|
||||
stopWatch();
|
||||
const root = document.getElementById('maincontent');
|
||||
arrange();
|
||||
/* a chrome module is alive on every page, so without the route check an observer would attach
|
||||
* to #view on, say, the firewall page and re-run arrange() for every table mutation */
|
||||
if (_observer || !view ||
|
||||
(document.body.getAttribute('data-page') || '') !== 'admin-status-overview')
|
||||
/* the caller (onOverview(), via wire()'s data-page observer) already confirmed the overview
|
||||
* route before reaching here; !view alone covers the mid-navigation race above */
|
||||
if (_observer || !view)
|
||||
return;
|
||||
_observedRoot = root;
|
||||
/* one arrange() per frame, however many mutations a poll tick delivers (fit.frame — the
|
||||
* theme's shared coalescer, fs-fit.js) */
|
||||
_observer = new MutationObserver(fit.frame(arrange));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require fs-fit as fit';
|
||||
|
||||
/* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The
|
||||
@@ -44,13 +43,15 @@ function lsGetArr(k) {
|
||||
}
|
||||
|
||||
/* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the
|
||||
* effective default when this browser has no localStorage */
|
||||
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
|
||||
* effective default when this browser has no localStorage. No try/catch: unlike localStorage,
|
||||
* reading or writing a plain object property cannot throw, and window.__fsSD is always one (head.ut
|
||||
* stamps it as an object literal). */
|
||||
function sd(k) { return (window.__fsSD || {})[k]; }
|
||||
|
||||
/* …and the write back: an applier that persists to the router must update the blob the server
|
||||
* stamped, or current*() keeps reporting the old router default until the next full load and
|
||||
* matchesSavedDefault() lies about whether anything is left to save */
|
||||
function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
|
||||
function setSD(field, val) { (window.__fsSD = window.__fsSD || {})[field] = val; }
|
||||
|
||||
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
|
||||
* `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed
|
||||
@@ -65,7 +66,6 @@ function currentMode() {
|
||||
const s = lsGet('fs-darkmode');
|
||||
if (s === 'true') return 'dark';
|
||||
if (s === 'false') return 'light';
|
||||
if (s === 'auto') return 'auto';
|
||||
if (s === null) return modeDefault();
|
||||
return 'auto';
|
||||
}
|
||||
@@ -169,18 +169,11 @@ function paintThemeColor() {
|
||||
}
|
||||
|
||||
function watchThemeColor() {
|
||||
let queued = false;
|
||||
const paint = () => {
|
||||
queued = false;
|
||||
paintThemeColor();
|
||||
};
|
||||
const schedule = () => {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
window.requestAnimationFrame(paint);
|
||||
};
|
||||
paintThemeColor();
|
||||
new MutationObserver(schedule).observe(document.documentElement,
|
||||
/* fit.frame() is the same next-frame coalescer every geometry fitter schedules through: the
|
||||
* tint and strength sliders write on every input event, and getComputedStyle forces style
|
||||
* resolution, so a dozen mutations in one frame must still repaint the meta tag once. */
|
||||
new MutationObserver(fit.frame(paintThemeColor)).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: [ 'style', 'class', 'data-darkmode', 'data-palette', 'data-wallpaper' ] });
|
||||
}
|
||||
|
||||
@@ -192,28 +185,6 @@ _mqDark.addEventListener('change', () => {
|
||||
stampDark(root, intendedDark());
|
||||
}
|
||||
});
|
||||
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root, from which
|
||||
* 02-tokens derives every other radius. head.ut pre-paints it and tools/axes.mjs holds JS/CSS/head
|
||||
* to this one number, hence the named const. */
|
||||
|
||||
/* ---- the four axis shapes, each written once ----
|
||||
*
|
||||
* 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, 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.
|
||||
*
|
||||
* Each factory takes its localStorage key as the first argument, and tools/axes.mjs matches the
|
||||
* call by its literal args: an axis built by a factory has no lsGet('fs-…') call site for the gate
|
||||
* to find.
|
||||
*
|
||||
* The remaining axes stay separate, each with a quirk a shared table would need an option for:
|
||||
* `mode` stores a value it does not apply and owns an MQL listener, `layout` reads the attribute,
|
||||
* `wallpaper` and `density` are three-valued, `palette` outgrew the two-value shape when the third
|
||||
* one landed, `autoCollapse` has no :root attribute. */
|
||||
|
||||
/* An axis whose values are a list, with one of them stamped as nothing.
|
||||
*
|
||||
* `values` are the names that become `attr="<name>"`; `dflt` is the one that leaves :root bare, and
|
||||
@@ -235,7 +206,6 @@ function listAxis(key, attr, values, dflt, after) {
|
||||
current() {
|
||||
const s = lsGet(key);
|
||||
if (ok(s)) return s;
|
||||
if (s === dflt) return dflt;
|
||||
if (s === null) return def();
|
||||
return dflt; /* a stray value reads as the built-in default */
|
||||
},
|
||||
@@ -258,22 +228,6 @@ function enumAxis(key, attr, on, off) {
|
||||
return listAxis(key, attr, [ on ], off);
|
||||
}
|
||||
|
||||
/* A colour axis — Tint, Accent and the three status colours are one axis pointed at five tokens:
|
||||
* same validation, same "0 is off", same ordering rule (set the custom property BEFORE the
|
||||
* attribute, or a fresh load paints one frame in the previous colour).
|
||||
*
|
||||
* A value is one of three things, and the attribute says which (03-palettes.css matches on it):
|
||||
*
|
||||
* 0 off — no attribute, the palette as it shipped
|
||||
* 1–360 a HUE: CSS rotates the palette's own colour through oklch(from … l c H), so
|
||||
* lightness, chroma and every contrast margin stay the palette's
|
||||
* '#rrggbb' a COLOUR, stamped inline on :root as the live token; the ink over it is derived
|
||||
* from its lightness in CSS
|
||||
*
|
||||
* Both live in one localStorage key rather than a colour key beside a hue key: two keys would need
|
||||
* a third to say which is in effect, and that third is the one a pre-paint script forgets.
|
||||
* `hueProp` carries the degrees, `colorProp` the live token a hex value overwrites; each mode
|
||||
* clears the other's property, so the two can never both be half-applied. */
|
||||
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
|
||||
const DENSITY = listAxis('fs-density', 'data-density', DENSITIES, 'normal', () => fit.schedule());
|
||||
const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
|
||||
@@ -283,11 +237,6 @@ const currentDensity = DENSITY.current, applyDensity = DENSITY.apply,
|
||||
* 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
|
||||
* in CSS (03-palettes.css explains why that stays contrast-safe at every angle); on a hex it IS the
|
||||
* canvas. 0 is off rather than red, a hue wheel wrapping, so one end of the range is free. */
|
||||
function currentLayout() {
|
||||
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
|
||||
}
|
||||
@@ -342,23 +291,6 @@ function currentRail() {
|
||||
return document.documentElement.getAttribute('data-rail') === 'true';
|
||||
}
|
||||
|
||||
/* ---- Save to router: write the current effective axes to /etc/config/footstrap ----
|
||||
* The scoped rpcd ACL (config 'footstrap' only) lets the admin's session set and commit those
|
||||
* options; rpcd validates the config/section/option names, so no value reaches a shell. The server
|
||||
* reads them back on the next load and head.ut's sanitiser clamps each before it becomes
|
||||
* window.__fsSD.
|
||||
*
|
||||
* snapshotAxes() reads the effective values, which already fold in this browser's localStorage, so
|
||||
* Save captures what the user sees. It does not touch localStorage: this browser keeps overriding,
|
||||
* and the saved default is for other devices. resetToSaved() drops this browser back onto it. */
|
||||
/* Every saved axis's localStorage key: what Save-as-default clears and what a reset walks.
|
||||
*
|
||||
* Tried as one table of [key, field, def] with the resolved defaults derived from it: correct,
|
||||
* and 188 B larger after minification — three lists of short literals compress better than
|
||||
* twenty-one rows of data, because a function name is mangled and a row is not. The copies are
|
||||
* held together by tools/axes.mjs instead, which reads snapshotAxes()'s body and holds every
|
||||
* field against header.ut's FS_AXES. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* the storage wrappers and the router-default reader: fs-axes.js is built on these */
|
||||
lsGet, lsSet, lsDel, lsGetArr, storageBroken, sd, setSD,
|
||||
|
||||
@@ -43,9 +43,9 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
* keyed by that first id and the entry carries `live`, the id armed right now (null while
|
||||
* paused), so a view holding its handle can still stop its own poller after a trip through a
|
||||
* hidden tab. The arguments are kept because a `setInterval` id carries none of them back. */
|
||||
window.setInterval = function (fn, ms) {
|
||||
window.setInterval = function (fn, ms, ...rest) {
|
||||
const id = _si.apply(window, arguments);
|
||||
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
|
||||
_viewIntervals.set(id, { fn, ms, rest, live: id });
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
@@ -102,28 +102,19 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
|
||||
*
|
||||
* `L.Poll.timer` is that id, and it is private state: `add`/`remove`/`start`/`stop`/`active` are the
|
||||
* documented surface, and the whole `L.Poll` alias is already deprecated (`'require poll'` replaces
|
||||
* it, but no supported release ships poll.js yet). Read blind, a renamed field would make LuCI's
|
||||
* tick look like a view's — cleared on the next navigation, every poll on every later page silently
|
||||
* dead. So a missing field is a reason to do nothing, once, loudly.
|
||||
*
|
||||
* Asked through the documented half first: `active()` says whether the tick is running, and `timer`
|
||||
* is deleted by `stop()`, so an absent field is the ordinary "nothing to protect" case. The anomaly
|
||||
* worth reporting is the pair disagreeing — a tick running while the id it runs on has no name we
|
||||
* know.
|
||||
*
|
||||
* The alias itself is guarded for the same reason: the sweep runs inside the staged render, and a
|
||||
* TypeError there would leave every click showing the previous page's content under the new page's
|
||||
* title. */
|
||||
* it, but no supported release ships poll.js yet). `active()` below is called unguarded: this
|
||||
* caller runs from the visibilitychange listener at module eval (:69-81), before CONTRACT_FNS ever
|
||||
* checks anything, so what backs it is the floor, not the boot contract — `L.Poll.active` is
|
||||
* unconditional in every 24.10+ luci.js. Read blind, a renamed `timer` field would make LuCI's tick
|
||||
* look like a view's — cleared on the next navigation, every poll on every later page silently
|
||||
* dead. So a missing field is a reason to do nothing, once, loudly: `active()` says whether the
|
||||
* tick is running, and `timer` is deleted by `stop()`, so an absent field is the ordinary "nothing
|
||||
* to protect" case. The anomaly worth reporting is the pair disagreeing — a tick running while the
|
||||
* id it runs on has no name we know. */
|
||||
/* -> the tick's id; null when LuCI is not polling; false when the two cannot be told apart, which
|
||||
* every caller reads as "leave every interval alone" */
|
||||
function pollTickId() {
|
||||
if (!L.Poll) {
|
||||
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
|
||||
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
|
||||
+ 'updating for this luci-base.');
|
||||
return false;
|
||||
}
|
||||
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
|
||||
const running = L.Poll.active();
|
||||
if (running && L.Poll.timer == null) {
|
||||
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
|
||||
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
|
||||
@@ -338,11 +329,9 @@ function watchSession() {
|
||||
* which also takes it out of the live tree. Public API only; no reaching into `dom.registry`. */
|
||||
function discard(el) {
|
||||
try {
|
||||
const dom = window.L ? window.L.dom : null;
|
||||
if (!dom || typeof dom.content !== 'function') { el.remove(); return; }
|
||||
const bin = document.createElement('div');
|
||||
bin.appendChild(el);
|
||||
dom.content(bin, null);
|
||||
window.L.dom.content(bin, null);
|
||||
}
|
||||
catch (e) {
|
||||
el.remove();
|
||||
@@ -479,7 +468,7 @@ function restoreScroll(pos, gen) {
|
||||
stop();
|
||||
};
|
||||
/* the keys that scroll, and only those: typing in a field must not cancel anything */
|
||||
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
|
||||
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
|
||||
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
|
||||
const opts = { passive: true, capture: true };
|
||||
function off() {
|
||||
@@ -784,11 +773,7 @@ function commitStage(stage, contentHost) {
|
||||
if (contentHost) contentHost.setAttribute('data-page', page);
|
||||
}
|
||||
const nodes = Array.from(stage.view.childNodes);
|
||||
const dom = window.L ? window.L.dom : null;
|
||||
if (live && dom && typeof dom.content === 'function')
|
||||
dom.content(live, nodes);
|
||||
else if (live)
|
||||
live.replaceChildren(...nodes);
|
||||
window.L.dom.content(live, nodes);
|
||||
dropStage(stage);
|
||||
}
|
||||
|
||||
@@ -1434,7 +1419,7 @@ function wireRouter() {
|
||||
* "Paused" is always shown with `handler: null`), and which of the two ran first here used to decide
|
||||
* everything — luci.js registers its listener from `setupDOM()`, after an async chain
|
||||
* (DOMContentLoaded + ui/rpc/form + probeRPCBaseURL), this module at eval, from the inline
|
||||
* `L.require('menu-footstrap')` in `partials/footer.ut`, so network/cache timing picked the order.
|
||||
* `L.require('menu-footstrap')` in `footer.ut`, so network/cache timing picked the order.
|
||||
* Run with this listener first, its hide removed the span before luci.js re-created it for "Paused"
|
||||
* with no handler, and the next `poll-start` found that span already there and only changed its
|
||||
* text — clickless for the rest of the document. A microtask runs only once the whole synchronous
|
||||
|
||||
@@ -99,48 +99,6 @@ function index() {
|
||||
return _index;
|
||||
}
|
||||
|
||||
/* ---- extra sources -------------------------------------------------------
|
||||
*
|
||||
* An optional package can add rows to the same list — one indexes the SECTION titles inside each
|
||||
* page, so "footstrap" finds System -> Appearance. A source hands over entries in the shape
|
||||
* buildIndex() produces and nothing else: the matching, the ranking and the rendering stay here,
|
||||
* or two lists would disagree about what a hit is. Two fields are the source's alone: `onTake`,
|
||||
* called when the row is chosen, and `key`, what the recents list stores it under when its `path`
|
||||
* is not its own (see keyOf).
|
||||
*
|
||||
* Registration is a GLOBAL ARRAY, not an export a package requires. This module is fetched on the
|
||||
* first gesture and most sessions never make it; a package that had to `require` it to register
|
||||
* would pull it onto every page and pay its 4.5 KB for a palette nobody opened. Pushing a function
|
||||
* onto `window.__fsSearchSources` costs the package nothing and names no one in either direction.
|
||||
*
|
||||
* `window.__fsSearchGen` is how a source says its data grew — a harvester fills in over a session
|
||||
* — and the stamp below is what rebuilds the pool when it does. */
|
||||
const _sources = [];
|
||||
let _pool = null, _stamp = -1;
|
||||
|
||||
function globalSources() {
|
||||
return Array.isArray(window.__fsSearchSources) ? window.__fsSearchSources : [];
|
||||
}
|
||||
|
||||
function addSource(fn) {
|
||||
_sources.push(fn);
|
||||
_pool = null;
|
||||
}
|
||||
function refresh() {
|
||||
_pool = null;
|
||||
}
|
||||
function pool() {
|
||||
const all = _sources.concat(globalSources());
|
||||
const stamp = all.length + (window.__fsSearchGen || 0);
|
||||
if (_pool && stamp === _stamp) return _pool;
|
||||
_stamp = stamp;
|
||||
_pool = all.reduce((rows, fn) => {
|
||||
try { return rows.concat(fn() || []); }
|
||||
catch (e) { console.error('footstrap: a search source threw', e); return rows; }
|
||||
}, index().slice());
|
||||
return _pool;
|
||||
}
|
||||
|
||||
/* ---- matching ----------------------------------------------------------- */
|
||||
|
||||
/* Every whitespace-separated token must hit something: a second word means AND. Deliberately not
|
||||
@@ -160,7 +118,7 @@ function search(q, limit) {
|
||||
if (!toks.length) return [];
|
||||
|
||||
const hits = [];
|
||||
for (const e of pool()) {
|
||||
for (const e of index()) {
|
||||
let sum = 0;
|
||||
for (const tok of toks) {
|
||||
const s = tokenScore(e, tok);
|
||||
@@ -179,31 +137,21 @@ function search(q, limit) {
|
||||
/* What the palette shows before anything is typed: an admin lives in three or four pages, so the
|
||||
* empty state is its most-used view.
|
||||
*
|
||||
* Only the KEY is stored, never the title — the title is resolved through the pool on every
|
||||
* render, so it follows the UI language and a row whose package went away drops out instead of
|
||||
* lingering as a dead row. A page's key is its menu path; a row from a source carries its own
|
||||
* `key`, because a section has no dispatcher node and therefore no path that is only its own —
|
||||
* the sections source keys one `admin/system/system#Footstrap`, the page it is on plus its own
|
||||
* heading. */
|
||||
* Only the PATH is stored, never the title — the title is resolved through the index on every
|
||||
* render, so it follows the UI language and a page an ACL change hides drops out instead of
|
||||
* lingering as a dead row. RECENT_KEY is the one name menu-footstrap-common.js's remember() also
|
||||
* writes; the two modules stay decoupled (see there), so the string is the only thing shared. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
/* the string a row is remembered under, and the one menu-footstrap-common's remember() writes */
|
||||
function keyOf(e) {
|
||||
return e.key || e.path;
|
||||
}
|
||||
|
||||
/* The list is WRITTEN by menu-footstrap-common.js, which is on every page — this module is not any
|
||||
* more, and a palette that only loads when it is opened cannot be what records where the admin has
|
||||
* been. Read here, at open time, so it is always current. `prefs.lsGetArr` owns the parse, the
|
||||
* corruption guard and the Array check; only the "these are keys" filter belongs here. */
|
||||
function recentEntries() {
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
/* pool(), not index(): a section is recalled exactly as a page is. Against the index alone a
|
||||
* section key resolved to nothing and the row silently vanished, so taking "Footstrap" left
|
||||
* only "System" in the list — the page path is all either row carries. */
|
||||
const byKey = new Map(pool().map((e) => [ keyOf(e), e ]));
|
||||
return recent.map((k) => byKey.get(k)).filter(Boolean).slice(0, RECENT_MAX);
|
||||
const byPath = new Map(index().map((e) => [ e.path, e ]));
|
||||
return recent.map((k) => byPath.get(k)).filter(Boolean).slice(0, RECENT_MAX);
|
||||
}
|
||||
|
||||
/* ---- the palette -------------------------------------------------------- */
|
||||
@@ -311,16 +259,8 @@ function build() {
|
||||
e.trail.length ? E('span', { 'class': 'fs-search-opt-path' }, [ e.trail.join(' › ') ]) : ''
|
||||
]);
|
||||
/* close before the click reaches the router, which re-renders the chrome underneath;
|
||||
* no focus return, the user is going elsewhere.
|
||||
*
|
||||
* `onTake` is how a row from an extra source finishes the job the href cannot: a
|
||||
* section row's href can only reach the PAGE, so the source that produced it opens the
|
||||
* tab and scrolls to the section itself. It fires for a click and for Enter alike —
|
||||
* Enter synthesises this very click. */
|
||||
a.addEventListener('click', () => {
|
||||
close(false);
|
||||
if (typeof e.onTake === 'function') e.onTake();
|
||||
});
|
||||
* no focus return, the user is going elsewhere */
|
||||
a.addEventListener('click', () => close(false));
|
||||
a.addEventListener('pointermove', () => { if (at !== i) setActive(i); });
|
||||
list.appendChild(a);
|
||||
return a;
|
||||
@@ -421,7 +361,5 @@ function openPalette() {
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
open: openPalette,
|
||||
/* the seam an optional package registers through; see addSource() */
|
||||
addSource, refresh
|
||||
open: openPalette
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
'require fs-fit as fit';
|
||||
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
|
||||
'require fs-chrome as chrome';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
|
||||
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
|
||||
@@ -19,14 +20,14 @@
|
||||
|
||||
function readChoices(sel) {
|
||||
const choices = {};
|
||||
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
|
||||
for (const o of sel.options) choices[o.value] = o.textContent;
|
||||
return choices;
|
||||
}
|
||||
|
||||
/* cheap identity of the option list, to detect a script rebuilding it
|
||||
* (select.replaceChildren, dependency-driven re-population, …) */
|
||||
function choicesKey(sel) {
|
||||
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
||||
return Array.from(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
||||
}
|
||||
|
||||
/* Undo enhance(): drop the widget, unhide the select and cut every listener enhance() installed.
|
||||
@@ -36,8 +37,7 @@ function choicesKey(sel) {
|
||||
* anonymous listener. */
|
||||
function teardown(sel) {
|
||||
if (sel._fsAbort) sel._fsAbort.abort();
|
||||
if (sel._fsNode && sel._fsNode.parentNode)
|
||||
sel._fsNode.parentNode.removeChild(sel._fsNode);
|
||||
sel._fsNode?.remove();
|
||||
delete sel.dataset.fsSelect;
|
||||
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
|
||||
sel.removeAttribute('aria-hidden');
|
||||
@@ -132,7 +132,7 @@ function enhance(sel) {
|
||||
sel._fsAbort = ac;
|
||||
|
||||
/* after the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
|
||||
sel.parentNode.insertBefore(node, sel.nextSibling);
|
||||
sel.after(node);
|
||||
|
||||
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
|
||||
let syncing = false;
|
||||
@@ -184,11 +184,12 @@ function enhance(sel) {
|
||||
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
|
||||
* PASS rather than fixed at load, and fs-fit.js watches that class. */
|
||||
const ROOTS = [ '#view', '#modal_overlay' ];
|
||||
const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
|
||||
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
|
||||
/* every call passes liveRoots(); folded in here rather than threaded as a parameter */
|
||||
const inRoots = (sel) => liveRoots().map((r) => `${r} ${sel}`).join(', ');
|
||||
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)') + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)');
|
||||
|
||||
/* 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.
|
||||
@@ -341,6 +342,9 @@ function labelCells(t, head) {
|
||||
* 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. */
|
||||
/* tools/table-contract.mjs reads these two names and shapes by regex, so a `display` rule on any of
|
||||
* these classes/tags is checked against a matching role — keep both names, and each entry a
|
||||
* `'.class'` or `'tag'` string literal, even though roleTables() below now reads them as one list. */
|
||||
const TABLE_ROLE_CLASSES = [
|
||||
[ '.table', 'table' ],
|
||||
[ '.thead', 'rowgroup' ],
|
||||
@@ -356,23 +360,12 @@ const TABLE_ROLE_TAGS = [
|
||||
[ '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));
|
||||
[ ...TABLE_ROLE_CLASSES, ...TABLE_ROLE_TAGS ].forEach(([ sel, role ]) => {
|
||||
document.querySelectorAll(inRoots(sel)).forEach((el) => widgets.syncAttr(el, 'role', role));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,7 +381,7 @@ function roleTables() {
|
||||
* NOT be measured: its rows hold widgets, and a widget bakes in the width of the layout it was laid
|
||||
* out in, so un-collapsing it to read it changes what is read — the firewall zone table then
|
||||
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
|
||||
const stackables = () => inRoots('.table.fs-dt', liveRoots());
|
||||
const stackables = () => inRoots('.table.fs-dt');
|
||||
|
||||
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
|
||||
* decision takes on trust rather than measuring. A four-column table of short values still fits at
|
||||
@@ -527,7 +520,7 @@ function fitTables() {
|
||||
/* mid-scroll, only the tables with no answer yet: one that has an answer needs nothing, one
|
||||
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
|
||||
* reads layout while `scrolling()` is true. */
|
||||
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
|
||||
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)') : stackables();
|
||||
document.querySelectorAll(sel).forEach((t) => {
|
||||
/* While the reader scrolls this pass only WRITES: every layout read here forces a
|
||||
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
|
||||
@@ -694,7 +687,7 @@ function fitTables() {
|
||||
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
|
||||
* width that matters is the room rather than the viewport, so this measures instead of naming a
|
||||
* page. */
|
||||
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots());
|
||||
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)');
|
||||
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
|
||||
|
||||
/* ---- making a scroll box reachable, and handing the markup back as it was found ----
|
||||
@@ -795,7 +788,7 @@ function fitScrollables() {
|
||||
function unpinActionColumn() {
|
||||
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table'))) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* Claim upstream's resize hook, which under SPA navigation is a leak:
|
||||
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
|
||||
@@ -864,7 +857,10 @@ const TYPEAHEAD_RESET_MS = 1000;
|
||||
let _taBuf = '', _taTimer = null, _taLast = null;
|
||||
|
||||
function typeaheadItems(sb) {
|
||||
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
|
||||
/* sb is always `.cbi-dropdown[open]` here, and stock ui.js sets that attribute and adds
|
||||
* `ul.dropdown` in the same openDropdown() call (openwrt/luci ui.js), so the two never
|
||||
* disagree — verified on openwrt-24.10. */
|
||||
const ul = sb.querySelector('ul.dropdown');
|
||||
if (!ul) return [];
|
||||
return [...ul.children].filter((li) =>
|
||||
li.tagName === 'LI' &&
|
||||
@@ -923,10 +919,7 @@ function wireTypeahead() {
|
||||
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
|
||||
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
|
||||
const inst = dom.findClassInstance(sb);
|
||||
if (inst && typeof inst.setFocus === 'function')
|
||||
inst.setFocus(sb, hit, true);
|
||||
else
|
||||
hit.focus();
|
||||
if (inst) inst.setFocus(sb, hit, true);
|
||||
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -301,8 +301,7 @@ function documentCarries(path) {
|
||||
* original is deliberately not one. */
|
||||
function documentPoisoned() {
|
||||
const names = themeNames();
|
||||
return Array.prototype.some.call(
|
||||
document.querySelectorAll(VIEW_SHEETS),
|
||||
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
|
||||
(el) => outlivesPage(el)
|
||||
&& (!names || (invasiveSheet(el, names) && !_owner.has(el) && !_silenced.has(el))));
|
||||
}
|
||||
@@ -417,7 +416,7 @@ function fenceImported(styleEl, names, until) {
|
||||
|
||||
/* what a sheet IS, as text: the rules that are applying, not the markup that may have produced
|
||||
* them. Serialised only to compare, never re-parsed. */
|
||||
const serializeRules = (rules) => Array.prototype.map.call(rules, (r) => r.cssText).join('\n');
|
||||
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
|
||||
|
||||
/* ---- a <style>'s textContent is NOT its sheet ----
|
||||
*
|
||||
@@ -536,13 +535,12 @@ function ownerKey() {
|
||||
return (_ownerHint !== null) ? _ownerHint : currentKey();
|
||||
}
|
||||
|
||||
/* L.env.dispatchpath (ctx.path) is stamped by luci-base's own header.ut before any theme script
|
||||
* runs, on every page including blank_page — verified on openwrt-24.10, no location.pathname
|
||||
* fallback needed. */
|
||||
function currentKey() {
|
||||
if (_curKey !== null) return _curKey;
|
||||
const dp = L.env && L.env.dispatchpath;
|
||||
if (dp && dp.length) return appKey(dp);
|
||||
/* no env to read (a document that never got the bootstrap): the URL is all there is */
|
||||
const p = location.pathname.replace(/^.*\/cgi-bin\/luci\/?/, '').replace(/\/+$/, '');
|
||||
return appKey(p ? p.split('/') : []);
|
||||
return appKey(L.env.dispatchpath);
|
||||
}
|
||||
|
||||
/* Both halves, for the reason silence() gives: el.disabled is the element's flag, el.sheet.disabled
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
|
||||
/* The theme's UI primitives: the inline-SVG wrapper, the disclosure pair the menu is built on, and
|
||||
* the colour control behind the Appearance tab's colour axes. Nothing here knows what it is used
|
||||
* for, so the menu and the Appearance tab share it without requiring each other. */
|
||||
/* The theme's UI primitives: the inline-SVG wrapper and two small idioms every module that draws a
|
||||
* control on its own repeats — an idempotent attribute write and Enter/Space activation for an
|
||||
* element with no native key handling. Nothing here knows what it is used for, so the menu, the
|
||||
* chrome and the Overview grid share it without requiring each other. */
|
||||
|
||||
/* The one inline-SVG wrapper: every theme icon is a 24x24 stroked outline differing only in path
|
||||
* data, so stroke width and linecaps are stated once and cannot drift between call sites.
|
||||
@@ -15,49 +16,27 @@ function svgIcon(body, cls) {
|
||||
+ body + '</svg>';
|
||||
}
|
||||
|
||||
/* ---- disclosure primitives, shared by the menu ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
|
||||
* and hides. The trigger selector stays a parameter. */
|
||||
|
||||
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
|
||||
* alone tells a sighted user everything and a screen-reader user nothing. `linkSel` is the
|
||||
* layout's trigger (the menu's `:scope > a`). */
|
||||
function setOpen(li, on, linkSel) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(linkSel)?.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires
|
||||
* no mutation record. `value === null` removes the attribute instead of writing the string
|
||||
* "null". Three call sites (menu-footstrap-common.js's meter annotator, fs-chrome.js's indicator
|
||||
* pills, fs-overview.js's card disclosure) carried this same body separately; held here once. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
|
||||
* to answer both. */
|
||||
function wireSpaceKey(link) {
|
||||
link.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== ' ' && ev.key !== 'Spacebar') return;
|
||||
/* Enter/Space activation for an element with no native key handling of its own — a `<span>`
|
||||
* `role="button"`, unlike an `<a role="button">`, gets neither key for free. Calling `activate()`
|
||||
* rather than `el.click()` directly lets a caller forward the activation to a different element
|
||||
* (fs-overview.js's card header activates the pill it labels). */
|
||||
function wireActivate(el, activate) {
|
||||
el.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* A click outside closes; WCAG 2.2 SC 1.4.13 also requires a hover/focus panel to be dismissible
|
||||
* from the keyboard, with focus handed back to the trigger. `when` restricts both to flyout mode,
|
||||
* where `.open` means "popup panel" — an unfolded accordion must not close on an outside click. */
|
||||
function wireDismiss(opts) {
|
||||
const active = () => (opts.when ? opts.when() : true);
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
/* `closest?.`: a document-level listener sees any dispatched click, including one whose
|
||||
* target is not an Element and has no closest(). The throw would kill this listener for
|
||||
* the rest of the session. */
|
||||
if (active() && !ev.target.closest?.(opts.inside))
|
||||
opts.close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !active()) return;
|
||||
const open = document.querySelector(opts.open);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(opts.trigger);
|
||||
opts.close();
|
||||
trigger?.focus();
|
||||
activate();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,7 +47,6 @@ function wireDismiss(opts) {
|
||||
|
||||
return baseclass.extend({
|
||||
svgIcon,
|
||||
setOpen,
|
||||
wireSpaceKey,
|
||||
wireDismiss
|
||||
syncAttr,
|
||||
wireActivate
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
'require fs-router as router';
|
||||
'require fs-prefs as prefs';
|
||||
'require fs-sheets as sheets';
|
||||
'require fs-widgets as widgets';
|
||||
|
||||
/* Page modules: `fs-overview` (Status -> Overview) adds to a stock page rather than owning a
|
||||
* route, so it is required only there. A `require` pragma would make it a hard dependency —
|
||||
@@ -52,25 +53,15 @@ function wirePageModules() {
|
||||
* and the warm pass that uses it, so both live here, in the file every page already loads.
|
||||
*
|
||||
* The palette reads the list back from localStorage when it opens, so the two halves share the key
|
||||
* and nothing else. */
|
||||
const RECENT_KEY = 'fs-recent';
|
||||
const RECENT_MAX = 8;
|
||||
* and nothing else — the constant itself is named once, in fs-search.js (RECENT_KEY, RECENT_MAX),
|
||||
* since that module owns the "recently visited" feature; the literal below has to match it exactly. */
|
||||
const RECENT_WARM = 5;
|
||||
|
||||
/* A key is a menu path, or a page path plus the heading of a section inside it
|
||||
* (`admin/system/system#Footstrap`) — a section has no dispatcher node to name it, and only the
|
||||
* source that produced the row can build that half. Exported for exactly that: the writer stays
|
||||
* one function, or the two halves would drift on the cap and the de-duplication. */
|
||||
/* A key is a menu path — what the router can navigate to and what warmRecent() prefetches. */
|
||||
function remember(key) {
|
||||
if (typeof key !== 'string' || !key) return;
|
||||
const recent = prefs.lsGetArr(RECENT_KEY).filter((x) => typeof x === 'string');
|
||||
prefs.lsSet(RECENT_KEY, JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, RECENT_MAX)));
|
||||
}
|
||||
|
||||
/* the page half of a key: what the router can navigate to and what warmRecent() prefetches */
|
||||
function pageOf(key) {
|
||||
const h = key.indexOf('#');
|
||||
return h < 0 ? key : key.slice(0, h);
|
||||
const recent = prefs.lsGetArr('fs-recent').filter((x) => typeof x === 'string');
|
||||
prefs.lsSet('fs-recent', JSON.stringify([ key ].concat(recent.filter((p) => p !== key)).slice(0, 8)));
|
||||
}
|
||||
|
||||
/* ---- warm the pages this admin actually uses ----
|
||||
@@ -87,10 +78,9 @@ function pageOf(key) {
|
||||
function warmRecent() {
|
||||
try { if (navigator.connection && navigator.connection.saveData) return; } catch (e) {}
|
||||
const here = (L.env.dispatchpath || []).join('/');
|
||||
/* Keys, not paths: a section key names the page it sits on, and two sections of one page must
|
||||
* warm it once — the module chain is the page's. */
|
||||
const keys = prefs.lsGetArr(RECENT_KEY).filter((p) => typeof p === 'string');
|
||||
const paths = [ ...new Set(keys.map(pageOf)) ].filter((p) => p !== here).slice(0, RECENT_WARM);
|
||||
/* already de-duplicated: remember() drops the key from its old slot before re-adding it */
|
||||
const keys = prefs.lsGetArr('fs-recent').filter((p) => typeof p === 'string');
|
||||
const paths = keys.filter((p) => p !== here).slice(0, RECENT_WARM);
|
||||
if (!paths.length) return;
|
||||
const go = () => paths.forEach((p) => router.prefetchSegs(p.split('/')));
|
||||
if (typeof window.requestIdleCallback === 'function')
|
||||
@@ -100,8 +90,8 @@ function warmRecent() {
|
||||
}
|
||||
|
||||
function wireSearch() {
|
||||
/* #fs-search-btn is emitted whenever this module loads (search.ut, !blank_page) */
|
||||
const btn = document.getElementById('fs-search-btn');
|
||||
if (!btn) return;
|
||||
const RT = window.L;
|
||||
|
||||
/* the page this full load landed on; onNavigate covers the SPA path afterwards */
|
||||
@@ -139,23 +129,6 @@ function wireSearch() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- optional companion packages ----
|
||||
*
|
||||
* header.ut prints `window.__fsPlugins` from `footstrap.settings.plugin`, a list a package writes
|
||||
* from its own uci-defaults; each entry is a LuCI module name, already whitelisted there. The
|
||||
* chrome requires each one after everything below is wired — a plugin registers itself through the
|
||||
* seams the theme exports (`fs-router.onNavigate`, `fs-search.addSource`) and the theme names
|
||||
* nobody. A plugin that throws costs only itself.
|
||||
*
|
||||
* No plugin, no cost: an empty list is the shipped state and this loop does nothing. */
|
||||
function loadPlugins() {
|
||||
const RT = window.L;
|
||||
const names = Array.isArray(window.__fsPlugins) ? window.__fsPlugins : [];
|
||||
names.forEach((name) => {
|
||||
RT.require(name).catch((e) => console.error('footstrap: plugin ' + name + ' did not load', e));
|
||||
});
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -191,17 +164,6 @@ const FS_METER_INVERTED_DANGER = 100 - FS_METER_DANGER;
|
||||
const FS_METER_INVERTED = new Set([ _('Total Available'), _('Swap free') ]);
|
||||
const FS_METER_NEUTRAL = new Set([ _('Buffered'), _('Cached') ]);
|
||||
|
||||
/* 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
|
||||
@@ -256,14 +218,14 @@ function annotateMeter(pg) {
|
||||
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);
|
||||
widgets.syncAttr(pg, 'role', 'progressbar');
|
||||
widgets.syncAttr(pg, 'aria-valuemin', '0');
|
||||
widgets.syncAttr(pg, 'aria-valuemax', '100');
|
||||
widgets.syncAttr(pg, 'aria-valuenow', String(level));
|
||||
widgets.syncAttr(pg, 'aria-valuetext', title);
|
||||
const label = findProgressbarLabel(pg);
|
||||
const name = label ? label.textContent.trim() : '';
|
||||
fsSyncAttr(pg, 'aria-label', label ? name : null);
|
||||
widgets.syncAttr(pg, 'aria-label', label ? name : null);
|
||||
/* Polarity: an unrecognised bar (a third-party app's own meter included — see the Sets above)
|
||||
* keeps the plain fill-based rule, on purpose. A wrong red is worse than a missing colour, but a
|
||||
* MISSING colour on the common case — a used-based fill, which is what an app most often draws —
|
||||
@@ -278,12 +240,12 @@ function annotateMeter(pg) {
|
||||
} else {
|
||||
dataLevel = level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null);
|
||||
}
|
||||
fsSyncAttr(pg, 'data-fs-level', dataLevel);
|
||||
widgets.syncAttr(pg, 'data-fs-level', dataLevel);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
* one); `widgets.syncAttr` above makes a re-run over an unchanged bar a no-op read. */
|
||||
function annotateMeters(root) {
|
||||
(root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter);
|
||||
}
|
||||
@@ -366,7 +328,7 @@ ensureOverviewHelpers();
|
||||
*
|
||||
* fs-menutree path <-> menu node, alias/firstchild resolution (a port of dispatcher.uc)
|
||||
* fs-prefs the Appearance axes and their localStorage
|
||||
* fs-widgets the inline-SVG wrapper, the disclosure primitives, the colour control
|
||||
* fs-widgets the inline-SVG wrapper, an idempotent attribute write, Enter/Space activation
|
||||
* fs-chrome mode menu, section tabs, the rail toggle, the "does it still fit" measurements
|
||||
* fs-router the SPA client router (docs/spa-router.md)
|
||||
* fs-sheets the guard against a view's injected CSS repainting every later page
|
||||
@@ -382,10 +344,6 @@ ensureOverviewHelpers();
|
||||
* halves (fs-menutree, fs-prefs) are separate modules. */
|
||||
|
||||
return baseclass.extend({
|
||||
/* the seam a companion package writes its own rows into the recents list through; see
|
||||
* 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,
|
||||
@@ -424,9 +382,6 @@ return baseclass.extend({
|
||||
wirePageModules();
|
||||
router.wire();
|
||||
router.wireVisibility();
|
||||
/* last: a plugin registers against the parts above, and a broken one must not be able
|
||||
* to take the chrome with it */
|
||||
loadPlugins();
|
||||
/* no sane partial recovery — a throw above loses the menu, the router and the Appearance
|
||||
* tab together — so this fails loudly rather than silently */
|
||||
}).catch((e) => console.error('footstrap: chrome init failed', e));
|
||||
|
||||
@@ -53,10 +53,33 @@ function flyoutMode() {
|
||||
document.documentElement.hasAttribute('data-narrow');
|
||||
}
|
||||
|
||||
/* The trigger — a bare <a>. widgets.setOpen keeps `.open` and aria-expanded in step. */
|
||||
/* The trigger — a bare <a>. setOpen() below keeps `.open` and aria-expanded in step. */
|
||||
const TRIGGER = ':scope > a';
|
||||
const OPEN_LI = '#topmenu > li.open';
|
||||
|
||||
/* ---- disclosure primitives ----
|
||||
* A section header is a W3C-APG disclosure control: an <a role="button"> owning a panel it shows
|
||||
* and hides. The one consumer of all three is this file, so they live here rather than behind a
|
||||
* `linkSel`/`opts` parameter in fs-widgets.js — TRIGGER above is the only trigger selector either
|
||||
* ever sees. */
|
||||
|
||||
/* Every open and close goes through here so `.open` and aria-expanded cannot disagree: `.open`
|
||||
* alone tells a sighted user everything and a screen-reader user nothing. */
|
||||
function setOpen(li, on) {
|
||||
li.classList.toggle('open', on);
|
||||
li.querySelector(TRIGGER)?.setAttribute('aria-expanded', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/* An <a role="button"> is given Enter by the browser but not Space, and a disclosure control has
|
||||
* to answer both — contrast fs-widgets.js's wireActivate(), written for an element with neither. */
|
||||
function wireSpaceKey(link) {
|
||||
link.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== ' ') return;
|
||||
ev.preventDefault();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- dropdown edge-clamp (the bar, at every width) ----
|
||||
* A bar panel hangs off its own item (li position:relative, ul left:0 — theme/20-shell.css), so an
|
||||
* item near the right edge would push its panel past the viewport. The rail flies panels out
|
||||
@@ -97,10 +120,6 @@ function clearClamps() {
|
||||
document.querySelectorAll('#topmenu ul').forEach((m) => { m.style.left = ''; });
|
||||
}
|
||||
|
||||
function setOpen(li, on) {
|
||||
widgets.setOpen(li, on, TRIGGER);
|
||||
}
|
||||
|
||||
function closeFlyouts(except) {
|
||||
document.querySelectorAll(OPEN_LI).forEach((o) => {
|
||||
if (o !== except) setOpen(o, false);
|
||||
@@ -139,7 +158,8 @@ function renderMainMenu(tree, url, level) {
|
||||
const ul = level ? E('ul', {}) : document.querySelector('#topmenu');
|
||||
const children = ui.menu.getChildren(tree);
|
||||
|
||||
if (!ul || children.length === 0 || level > 1)
|
||||
/* #topmenu is emitted whenever this module loads (header.ut, !blank_page) */
|
||||
if (children.length === 0 || level > 1)
|
||||
return E([]);
|
||||
|
||||
/* dispatchpath = [mode, section, subsection, …]; sections sit at
|
||||
@@ -239,7 +259,7 @@ function renderMainMenu(tree, url, level) {
|
||||
saveOpenSections();
|
||||
});
|
||||
|
||||
widgets.wireSpaceKey(link);
|
||||
wireSpaceKey(link);
|
||||
|
||||
/* hybrid devices: once a real mouse enters, drop the tap-opened panel so hover is
|
||||
* authoritative and two panels never stack. Guarded on pointerType, since a touch
|
||||
@@ -265,13 +285,23 @@ return baseclass.extend({
|
||||
common.init(renderMainMenu);
|
||||
|
||||
/* click-outside and Escape close an open flyout, gated on flyoutMode(): outside it
|
||||
* `.open` means unfolded accordion, which must not fold on a click elsewhere */
|
||||
widgets.wireDismiss({
|
||||
when: flyoutMode,
|
||||
inside: '#topmenu > li.has-sub',
|
||||
open: OPEN_LI,
|
||||
trigger: TRIGGER,
|
||||
close: () => closeFlyouts()
|
||||
* `.open` means unfolded accordion, which must not fold on a click elsewhere. WCAG 2.2
|
||||
* SC 1.4.13 also requires a hover/focus panel to be dismissible from the keyboard, with
|
||||
* focus handed back to the trigger. */
|
||||
document.addEventListener('click', (ev) => {
|
||||
/* `closest?.`: a document-level listener sees any dispatched click, including one
|
||||
* whose target is not an Element and has no closest(). The throw would kill this
|
||||
* listener for the rest of the session. */
|
||||
if (flyoutMode() && !ev.target.closest?.('#topmenu > li.has-sub'))
|
||||
closeFlyouts();
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape' || !flyoutMode()) return;
|
||||
const open = document.querySelector(OPEN_LI);
|
||||
if (!open) return;
|
||||
const trigger = open.querySelector(TRIGGER);
|
||||
closeFlyouts();
|
||||
trigger?.focus();
|
||||
});
|
||||
|
||||
/* Entering flyout mode folds everything, or a section left open as an accordion
|
||||
|
||||
@@ -57,7 +57,9 @@ set -- $RESERVE_DIRS
|
||||
|
||||
RES="$CSS.reserved.$$"
|
||||
MAP="$CSS.map.$$"
|
||||
trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$CSS.tmp.$$"' EXIT
|
||||
UNSORTED="$CSS.unsorted.$$"
|
||||
SORTED="$CSS.sorted.$$"
|
||||
trap 'rm -f "$RES" "$MAP" "$MAP.ord" "$UNSORTED" "$SORTED" "$CSS.tmp.$$"' EXIT
|
||||
|
||||
if [ -n "$REWRITE" ]; then
|
||||
# nothing is reserved: every name is renamed here and in the far side together
|
||||
@@ -72,7 +74,14 @@ else
|
||||
[ -s "$RES" ] || { echo "mangle-tokens: reserved set came out EMPTY — refusing (a seam name would be renamed and the theme would break silently)" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
# ---- pass 1: count every manglable --fs- name, string-aware, write unsorted ----
|
||||
#
|
||||
# RESFILE/UNSORTFILE travel through ENVIRON, not `-v` (and not a bare `var=value` operand, which
|
||||
# awk treats the same way): both run the value through awk's OWN escape-sequence processing before
|
||||
# use, so a path holding a literal `\t` or `\n` (a `$CSS` an OpenWrt buildbot handed us, not ours to
|
||||
# assume ASCII-clean) is silently rewritten into a tab or newline and no longer names the file that
|
||||
# is actually there. ENVIRON reads the environment string verbatim.
|
||||
RESFILE="$RES" UNSORTFILE="$UNSORTED" awk '
|
||||
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
|
||||
# read the identifier starting at i (which points at the first "-" of "--")
|
||||
function ident(s, i, j, n) {
|
||||
@@ -81,15 +90,13 @@ awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
return substr(s, i, j - i)
|
||||
}
|
||||
BEGIN {
|
||||
RESFILE = ENVIRON["RESFILE"]; UNSORTFILE = ENVIRON["UNSORTFILE"]
|
||||
while ((getline line < RESFILE) > 0) reserved[line] = 1
|
||||
q = ""
|
||||
}
|
||||
{ css = css $0 "\n" }
|
||||
END {
|
||||
n = length(css)
|
||||
|
||||
# ---- pass 1: count every manglable name, string-aware ----
|
||||
i = 1
|
||||
n = length(css); i = 1
|
||||
while (i <= n) {
|
||||
c = substr(css, i, 1)
|
||||
if (q != "") { if (c == "\\") { i += 2; continue } ; if (c == q) q = ""; i++; continue }
|
||||
@@ -101,20 +108,40 @@ awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
}
|
||||
i++
|
||||
}
|
||||
for (x = 1; x <= ncnt; x++) print cnt[order[x]], order[x] > UNSORTFILE
|
||||
}
|
||||
' "$CSS"
|
||||
|
||||
# sort(1) runs here, in the SHELL, never as a string awk pipes to: building that string by
|
||||
# concatenating a path in ("sort ... > \"" PATH "\"") lets a `"` in the path break out of the
|
||||
# quoting. "$SORTED" is a normal shell expansion instead, safe whatever bytes CSS holds.
|
||||
sort -k1,1nr -k2,2 "$UNSORTED" > "$SORTED" || { echo "mangle-tokens: sort failed" >&2; exit 1; }
|
||||
|
||||
# ---- pass 2: short-name alphabet from the sorted order (hottest name shortest), then rewrite ----
|
||||
# SORTFILE/MAPFILE/OUTFILE via ENVIRON too — see the note above pass 1.
|
||||
SORTFILE="$SORTED" MAPFILE="$MAP" OUTFILE="$CSS.tmp.$$" awk '
|
||||
function isname(c) { return (c ~ /[A-Za-z0-9_-]/) }
|
||||
function ident(s, i, j, n) {
|
||||
n = length(s); j = i
|
||||
while (j <= n && isname(substr(s, j, 1))) j++
|
||||
return substr(s, i, j - i)
|
||||
}
|
||||
BEGIN {
|
||||
SORTFILE = ENVIRON["SORTFILE"]; MAPFILE = ENVIRON["MAPFILE"]; OUTFILE = ENVIRON["OUTFILE"]
|
||||
x = 0
|
||||
while ((getline sline < SORTFILE) > 0) { split(sline, sf, " "); order[++x] = sf[2]; cnt[sf[2]] = sf[1] }
|
||||
ncnt = x
|
||||
|
||||
# ---- short-name alphabet, hottest name gets the shortest ----
|
||||
A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
nsh = 0
|
||||
for (a = 1; a <= 52; a++) short[++nsh] = "--" substr(A, a, 1)
|
||||
for (a = 1; a <= 52; a++) for (b = 1; b <= 52; b++) short[++nsh] = "--" substr(A, a, 1) substr(A, b, 1)
|
||||
|
||||
# selection sort by count (asort() is not portable to busybox/mawk)
|
||||
for (x = 1; x <= ncnt; x++) {
|
||||
best = x
|
||||
for (y = x + 1; y <= ncnt; y++) if (cnt[order[y]] > cnt[order[best]]) best = y
|
||||
t = order[x]; order[x] = order[best]; order[best] = t
|
||||
}
|
||||
if (ncnt > nsh) { print "mangle-tokens: more names than short forms" > "/dev/stderr"; exit 1 }
|
||||
q = ""
|
||||
}
|
||||
{ css = css $0 "\n" }
|
||||
END {
|
||||
n = length(css)
|
||||
for (x = 1; x <= ncnt; x++) {
|
||||
map[order[x]] = short[x]
|
||||
# a mangled name must not already exist in the sheet
|
||||
@@ -124,7 +151,6 @@ awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
print order[x] " -> " short[x] " x" cnt[order[x]] > MAPFILE
|
||||
}
|
||||
|
||||
# ---- pass 2: rewrite ----
|
||||
q = ""; i = 1; out = ""
|
||||
while (i <= n) {
|
||||
c = substr(css, i, 1)
|
||||
@@ -141,7 +167,7 @@ awk -v RESFILE="$RES" -v MAPFILE="$MAP" '
|
||||
printf "%s", out > OUTFILE
|
||||
print ncnt " names mangled, " (length(css) - length(out)) " bytes saved" > "/dev/stderr"
|
||||
}
|
||||
' OUTFILE="$CSS.tmp.$$" "$CSS"
|
||||
' "$CSS"
|
||||
|
||||
before=$(wc -c < "$CSS")
|
||||
mv "$CSS.tmp.$$" "$CSS"
|
||||
@@ -166,7 +192,6 @@ if [ -n "$REWRITE" ]; then
|
||||
awk '{ print $1, $3 }' "$MAP" | awk '{ print length($1), $0 }' | sort -rn | cut -d" " -f2- > "$MAP.ord"
|
||||
touched=0
|
||||
for d in $REWRITE_DIRS; do
|
||||
[ -d "$d" ] || { echo "mangle-tokens: --rewrite target $d is not a directory" >&2; exit 1; }
|
||||
for f in $(find "$d" -type f \( -name '*.js' -o -name '*.ut' \)); do
|
||||
awk -v MAPF="$MAP.ord" '
|
||||
BEGIN { while ((getline l < MAPF) > 0) { split(l, a, " "); from[++k] = a[1]; to[k] = a[2] } }
|
||||
|
||||
@@ -11,12 +11,12 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 3.9\n"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Acento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr "Como en el archivo"
|
||||
@@ -25,17 +25,17 @@ msgstr "Como en el archivo"
|
||||
msgid "Authorization Required"
|
||||
msgstr "Se requiere autorización"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Colapso automático"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr "Fondo"
|
||||
@@ -45,30 +45,30 @@ msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr "Apenas visible %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:445
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Bordes"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr "En búfer"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr "En caché"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Tarjetas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Elegir SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr "Elegir imagen"
|
||||
@@ -87,28 +87,28 @@ msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr "Bien visible %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr "Contraer menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Colores"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Compacto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Confirmar restablecimiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr "Ancho del contenido"
|
||||
@@ -118,7 +118,7 @@ msgctxt "footstrap"
|
||||
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
|
||||
msgstr "Contraste %s:1 (WCAG AA exige aquí %s:1)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr "Controles"
|
||||
@@ -128,29 +128,29 @@ msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr "No se pudo procesar la imagen."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:655
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr ""
|
||||
"No se pudo guardar la configuración predeterminada. Recargue la página e "
|
||||
"inténtelo de nuevo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:425
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Peligro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Oscuro"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Densidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr "Oscurecimiento"
|
||||
@@ -160,12 +160,12 @@ msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr "Se lee bien %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr "Expandir menú"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr "Archivo"
|
||||
@@ -176,7 +176,7 @@ msgstr "Archivo"
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:824
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
@@ -190,8 +190,8 @@ msgid ""
|
||||
"Footstrap theme settings saved to the router, the login background and "
|
||||
"pattern uploads, and the Footstrap settings page"
|
||||
msgstr ""
|
||||
"Los ajustes del tema Footstrap guardados en el router, la carga del fondo "
|
||||
"de inicio de sesión y del patrón, y la página de ajustes de Footstrap"
|
||||
"Los ajustes del tema Footstrap guardados en el router, la carga del fondo de "
|
||||
"inicio de sesión y del patrón, y la página de ajustes de Footstrap"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/notices.ut:24
|
||||
msgid "Go to firmware upgrade..."
|
||||
@@ -201,7 +201,7 @@ msgstr "Ir a la actualización del firmware..."
|
||||
msgid "Go to password configuration..."
|
||||
msgstr "Ir a la configuración de contraseñas..."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr "Correcto"
|
||||
@@ -211,7 +211,7 @@ msgctxt "footstrap"
|
||||
msgid "Hard to read %s — large text only"
|
||||
msgstr "Se lee mal %s: solo texto grande"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr "Interfaz"
|
||||
@@ -224,23 +224,23 @@ msgstr "¡Nombre de usuario y/o contraseña incorrectos! Inténtelo de nuevo."
|
||||
msgid "JavaScript required!"
|
||||
msgstr "¡Se requiere JavaScript!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:385
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Mantener abierto"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Grande"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Diseño"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr "Claro"
|
||||
@@ -254,16 +254,16 @@ msgstr "Iniciar sesión"
|
||||
msgid "Log out"
|
||||
msgstr "Cerrar sesión"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/footer.ut:25
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Modo de compatibilidad Lua activo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr "Memoria"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr "Menú"
|
||||
|
||||
@@ -276,7 +276,7 @@ msgstr ""
|
||||
"reiniciar el sistema. Este modo solo debe usarse para instalar una "
|
||||
"actualización de firmware"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr "No se encontraron páginas"
|
||||
@@ -285,23 +285,23 @@ msgstr "No se encontraron páginas"
|
||||
msgid "No password set!"
|
||||
msgstr "¡No se ha establecido ninguna contraseña!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Normal"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr "Apagado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
#: htdocs/luci-static/resources/fs-search.js:233
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr "Páginas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr "Paleta"
|
||||
@@ -310,8 +310,8 @@ msgstr "Paleta"
|
||||
msgid "Password"
|
||||
msgstr "Contraseña"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr "Patrón"
|
||||
@@ -321,7 +321,7 @@ msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr "Elija un archivo SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:487
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr "Por favor, seleccione un archivo de imagen."
|
||||
@@ -330,53 +330,53 @@ msgstr "Por favor, seleccione un archivo de imagen."
|
||||
msgid "Please enter your username and password."
|
||||
msgstr "Por favor, ingrese su nombre de usuario y contraseña."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:329
|
||||
#: htdocs/luci-static/resources/fs-search.js:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Visitado recientemente"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Eliminar"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Restablecer al integrado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr ""
|
||||
"«Restablecer al integrado»: vuelve al aspecto con el que se distribuye el "
|
||||
"tema."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:604
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Restablecer al del router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
"«Restablecer al del router»: descarta los cambios de este navegador y sigue "
|
||||
"al router."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:376
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Redondeo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Guardar en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
@@ -385,22 +385,22 @@ msgstr ""
|
||||
"«Guardar en el router»: guarda este aspecto en el router. Los navegadores "
|
||||
"con un aspecto propio lo conservan."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:720
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Aspecto guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Guardado en el router"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Guardado"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr "Escala"
|
||||
@@ -410,60 +410,60 @@ msgstr "Escala"
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr "Buscar páginas"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
#: htdocs/luci-static/resources/fs-search.js:228
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr "Buscar páginas…"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Barra lateral"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Barra lateral y superior"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr "Saltar al contenido"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr "Empiece a escribir para encontrar una página"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Storage"
|
||||
msgstr "Almacenamiento"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Intensidad"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Submenús"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Superficies"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr "Intercambio libre"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr "Sistema"
|
||||
|
||||
@@ -471,7 +471,7 @@ msgstr "Sistema"
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr "El sistema se está ejecutando en modo de recuperación (initramfs)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:722
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr "Tabla"
|
||||
|
||||
@@ -480,12 +480,12 @@ msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr "De ese SVG no quedó nada tras eliminar las partes no seguras."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:292
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "No se ha podido leer el archivo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr "Ese archivo no es una imagen legible."
|
||||
@@ -495,23 +495,23 @@ msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr "Ese archivo no es una imagen SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:464
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Ese archivo es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Esa imagen es demasiado grande."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr "El router se negó a eliminar el archivo (estado ubus %d)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
@@ -524,7 +524,7 @@ msgstr ""
|
||||
"Este enrutador no tiene contraseña. Configure una contraseña de "
|
||||
"administrador para proteger la interfaz web."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:638
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
@@ -535,17 +535,17 @@ msgstr ""
|
||||
"bloqueados), así que un cambio aquí dura hasta que recargues. Guardar como "
|
||||
"predeterminado sigue funcionando y se aplica a todos los navegadores."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:752
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr "Este tema aún no está traducido en este router."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:395
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Tinte (identificación del enrutador)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr "Intensidad del tinte"
|
||||
@@ -555,12 +555,12 @@ msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr "Demasiado tenue para leerse %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Arriba"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr "Total disponible"
|
||||
|
||||
@@ -569,7 +569,7 @@ msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr "Error al cargar el archivo."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:552
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr "Cargando…"
|
||||
@@ -578,12 +578,12 @@ msgstr "Cargando…"
|
||||
msgid "Username"
|
||||
msgstr "Nombre de usuario"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:581
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Fondo de pantalla"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr "Advertencia"
|
||||
@@ -594,7 +594,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Debe activar JavaScript en su navegador o LuCI no funcionará correctamente."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr "en la barra lateral"
|
||||
@@ -604,17 +604,17 @@ msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr "en una tarjeta"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "en un control"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "en el lienzo"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
|
||||
@@ -7,12 +7,12 @@ msgstr ""
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr "Акцент"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr "Как в файле"
|
||||
@@ -21,17 +21,17 @@ msgstr "Как в файле"
|
||||
msgid "Authorization Required"
|
||||
msgstr "Требуется авторизация"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr "Сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr "Фон"
|
||||
@@ -41,30 +41,30 @@ msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr "Едва заметна %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:445
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr "Границы"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr "Буферизовано"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr "Кэшировано"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr "Карточки"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr "Выбрать SVG"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr "Выбрать изображение"
|
||||
@@ -75,36 +75,36 @@ msgid ""
|
||||
"Cleaned before upload: removed %d unsafe element(s) and %d external "
|
||||
"reference(s)."
|
||||
msgstr ""
|
||||
"Очищено перед загрузкой: удалено небезопасных элементов — %d, внешних "
|
||||
"ссылок — %d."
|
||||
"Очищено перед загрузкой: удалено небезопасных элементов — %d, внешних ссылок "
|
||||
"— %d."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:188
|
||||
msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr "Хорошо видна %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr "Свернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr "Цвета"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr "Компактно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr "Подтвердить сброс"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr "Ширина контента"
|
||||
@@ -114,7 +114,7 @@ msgctxt "footstrap"
|
||||
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
|
||||
msgstr "Контраст %s:1 (WCAG AA требует здесь %s:1)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr "Элементы форм"
|
||||
@@ -124,27 +124,27 @@ msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr "Не удалось обработать изображение."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:655
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr "Не удалось сохранить умолчание. Обновите страницу и повторите."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:425
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr "Опасность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr "Тёмная"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr "Плотность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr "Затемнение"
|
||||
@@ -154,12 +154,12 @@ msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr "Хорошо читается %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr "Развернуть меню"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr "Файл"
|
||||
@@ -170,7 +170,7 @@ msgstr "Файл"
|
||||
msgid "Footstrap"
|
||||
msgstr "Footstrap"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:824
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
@@ -195,7 +195,7 @@ msgstr "Перейти к обновлению прошивки..."
|
||||
msgid "Go to password configuration..."
|
||||
msgstr "Перейти к настройке пароля..."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr "Норма"
|
||||
@@ -205,7 +205,7 @@ msgctxt "footstrap"
|
||||
msgid "Hard to read %s — large text only"
|
||||
msgstr "Плохо читается %s — только крупный текст"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr "Интерфейс"
|
||||
@@ -218,23 +218,23 @@ msgstr "Неверное имя пользователя и/или пароль!
|
||||
msgid "JavaScript required!"
|
||||
msgstr "Требуется JavaScript!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:385
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr "Не сворачивать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr "Крупно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr "Раскладка"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr "Светлая"
|
||||
@@ -248,16 +248,16 @@ msgstr "Войти"
|
||||
msgid "Log out"
|
||||
msgstr "Выйти"
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/footer.ut:25
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr "Включён режим совместимости с Lua"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr "Память"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr "Меню"
|
||||
|
||||
@@ -269,7 +269,7 @@ msgstr ""
|
||||
"Изменения настроек не сохраняются и будут потеряны после перезагрузки. Этот "
|
||||
"режим следует использовать только для установки обновления прошивки"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr "Страницы не найдены"
|
||||
@@ -278,23 +278,23 @@ msgstr "Страницы не найдены"
|
||||
msgid "No password set!"
|
||||
msgstr "Пароль не установлен!"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr "Обычно"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr "Выкл."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
#: htdocs/luci-static/resources/fs-search.js:233
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr "Страницы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr "Палитра"
|
||||
@@ -303,8 +303,8 @@ msgstr "Палитра"
|
||||
msgid "Password"
|
||||
msgstr "Пароль"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr "Узор"
|
||||
@@ -314,7 +314,7 @@ msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr "Выберите файл SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:487
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr "Выберите файл изображения."
|
||||
@@ -323,51 +323,51 @@ msgstr "Выберите файл изображения."
|
||||
msgid "Please enter your username and password."
|
||||
msgstr "Введите имя пользователя и пароль."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:329
|
||||
#: htdocs/luci-static/resources/fs-search.js:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr "Недавние"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr "Убрать"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr "Сбросить к встроенному"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr "«Сбросить к встроенному» — вернуть вид, с которым поставляется тема."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:604
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr "Сбросить к виду роутера"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
"«Сбросить к виду роутера» — отменить правки этого браузера и следовать "
|
||||
"роутеру."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:376
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr "Скругление"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr "Сохранить на роутер"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
@@ -376,22 +376,22 @@ msgstr ""
|
||||
"«Сохранить на роутер» — записать этот вид на роутер. Браузеры со своим видом "
|
||||
"его сохранят."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:720
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr "Сохранённый вид"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr "Сохранено на роутере"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr "Сохранение"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr "Масштаб"
|
||||
@@ -401,60 +401,60 @@ msgstr "Масштаб"
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr "Поиск по страницам"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
#: htdocs/luci-static/resources/fs-search.js:228
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr "Поиск по страницам…"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr "Сбоку"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr "Боковая панель и шапка"
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr "Перейти к содержимому"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr "Начните вводить название страницы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Storage"
|
||||
msgstr "Накопитель"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr "Насыщенность"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr "Подменю"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr "Поверхности"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr "Свободно в подкачке"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr "Система"
|
||||
|
||||
@@ -462,7 +462,7 @@ msgstr "Система"
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr "Система работает в режиме восстановления (initramfs)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:722
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr "Таблица"
|
||||
|
||||
@@ -471,12 +471,12 @@ msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr "От этого SVG ничего не осталось после удаления опасных частей."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:292
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr "Не удалось прочитать файл."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr "Не удалось прочитать файл как изображение."
|
||||
@@ -486,23 +486,23 @@ msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr "Этот файл — не изображение SVG."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:464
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr "Файл слишком большой."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr "Изображение слишком большое."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr "Роутер отказался удалить файл (статус ubus %d)."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr "Тема"
|
||||
@@ -515,7 +515,7 @@ msgstr ""
|
||||
"На этом роутере не задан пароль. Установите пароль root, чтобы защитить веб-"
|
||||
"интерфейс."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:638
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
@@ -526,17 +526,17 @@ msgstr ""
|
||||
"изменение здесь действует до перезагрузки страницы. «Сохранить как "
|
||||
"умолчание» по-прежнему работает и применяется ко всем браузерам."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:752
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr "Тема ещё не переведена на этом роутере."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:395
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr "Оттенок (опознание роутера)"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr "Насыщенность оттенка"
|
||||
@@ -546,12 +546,12 @@ msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr "Слишком блёкло, не читается %s"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr "Сверху"
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr "Свободно"
|
||||
|
||||
@@ -560,7 +560,7 @@ msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr "Не удалось загрузить."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:552
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr "Загрузка…"
|
||||
@@ -569,12 +569,12 @@ msgstr "Загрузка…"
|
||||
msgid "Username"
|
||||
msgstr "Имя пользователя"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:581
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr "Обои"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr "Предупреждение"
|
||||
@@ -585,7 +585,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Включите JavaScript в браузере, иначе LuCI не будет работать корректно."
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr "в боковой панели"
|
||||
@@ -595,17 +595,17 @@ msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr "на карточке"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr "на элементе формы"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr "на холсте"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:421
|
||||
msgctxt "footstrap"
|
||||
msgid "Accent"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:502
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
msgctxt "footstrap"
|
||||
msgid "As in file"
|
||||
msgstr ""
|
||||
@@ -15,17 +15,17 @@ msgstr ""
|
||||
msgid "Authorization Required"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:387
|
||||
msgctxt "footstrap"
|
||||
msgid "Auto-collapse"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:834
|
||||
msgctxt "footstrap"
|
||||
msgid "Background"
|
||||
msgstr ""
|
||||
@@ -35,30 +35,30 @@ msgctxt "footstrap"
|
||||
msgid "Barely visible %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:445
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
msgctxt "footstrap"
|
||||
msgid "Borders"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Buffered"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:193
|
||||
msgid "Cached"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:441
|
||||
msgctxt "footstrap"
|
||||
msgid "Cards"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:475
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:474
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose SVG"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:468
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:467
|
||||
msgctxt "footstrap"
|
||||
msgid "Choose image"
|
||||
msgstr ""
|
||||
@@ -75,28 +75,28 @@ msgctxt "footstrap"
|
||||
msgid "Clearly visible %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: ucode/template/themes/footstrap/header.ut:138
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
#: ucode/template/themes/footstrap/header.ut:142
|
||||
msgid "Collapse menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:486
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
msgctxt "footstrap"
|
||||
msgid "Colours"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
msgctxt "footstrap"
|
||||
msgid "Compact"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:676
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:675
|
||||
msgctxt "footstrap"
|
||||
msgid "Confirm reset"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:372
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:373
|
||||
msgctxt "footstrap"
|
||||
msgid "Content width"
|
||||
msgstr ""
|
||||
@@ -106,7 +106,7 @@ msgctxt "footstrap"
|
||||
msgid "Contrast %s:1 (WCAG AA wants %s:1 here)"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "Controls"
|
||||
msgstr ""
|
||||
@@ -116,27 +116,27 @@ msgctxt "footstrap"
|
||||
msgid "Could not process the image."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:655
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:654
|
||||
msgctxt "footstrap"
|
||||
msgid "Could not save the default. Reload the page and try again."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:425
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
msgctxt "footstrap"
|
||||
msgid "Danger"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:345
|
||||
msgctxt "footstrap"
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:360
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:361
|
||||
msgctxt "footstrap"
|
||||
msgid "Density"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:482
|
||||
msgctxt "footstrap"
|
||||
msgid "Dim"
|
||||
msgstr ""
|
||||
@@ -146,12 +146,12 @@ msgctxt "footstrap"
|
||||
msgid "Easy to read %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:456
|
||||
#: htdocs/luci-static/resources/fs-chrome.js:470
|
||||
msgid "Expand menu"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:507
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:585
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:506
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
msgctxt "footstrap"
|
||||
msgid "File"
|
||||
msgstr ""
|
||||
@@ -160,7 +160,7 @@ msgstr ""
|
||||
msgid "Footstrap"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:824
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:823
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Footstrap theme settings apply at once and are stored permanently in this "
|
||||
@@ -181,7 +181,7 @@ msgstr ""
|
||||
msgid "Go to password configuration..."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:422
|
||||
msgctxt "footstrap"
|
||||
msgid "Good"
|
||||
msgstr ""
|
||||
@@ -191,7 +191,7 @@ msgctxt "footstrap"
|
||||
msgid "Hard to read %s — large text only"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:831
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:830
|
||||
msgctxt "footstrap"
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
@@ -204,23 +204,23 @@ msgstr ""
|
||||
msgid "JavaScript required!"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:385
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:386
|
||||
msgctxt "footstrap"
|
||||
msgid "Keep open"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:364
|
||||
msgctxt "footstrap"
|
||||
msgid "Large"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:336
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:750
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:749
|
||||
msgctxt "footstrap"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:343
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:344
|
||||
msgctxt "footstrap"
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
@@ -234,16 +234,16 @@ msgstr ""
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/partials/footer.ut:25
|
||||
#: ucode/template/themes/footstrap/footer.ut:25
|
||||
msgid "Lua compatibility mode active"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Memory"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:135
|
||||
#: ucode/template/themes/footstrap/header.ut:146
|
||||
#: ucode/template/themes/footstrap/header.ut:139
|
||||
#: ucode/template/themes/footstrap/header.ut:150
|
||||
msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
@@ -253,7 +253,7 @@ msgid ""
|
||||
"mode should only be used to install a firmware upgrade"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "No pages found"
|
||||
msgstr ""
|
||||
@@ -262,23 +262,23 @@ msgstr ""
|
||||
msgid "No password set!"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:362
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:363
|
||||
msgctxt "footstrap"
|
||||
msgid "Normal"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:582
|
||||
msgctxt "footstrap"
|
||||
msgid "Off"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:241
|
||||
#: htdocs/luci-static/resources/fs-search.js:233
|
||||
msgctxt "footstrap"
|
||||
msgid "Pages"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:155
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:347
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:348
|
||||
msgctxt "footstrap"
|
||||
msgid "Palette"
|
||||
msgstr ""
|
||||
@@ -287,8 +287,8 @@ msgstr ""
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:493
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:584
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:492
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:583
|
||||
msgctxt "footstrap"
|
||||
msgid "Pattern"
|
||||
msgstr ""
|
||||
@@ -298,7 +298,7 @@ msgctxt "footstrap"
|
||||
msgid "Please choose an SVG file."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:487
|
||||
#: htdocs/luci-static/resources/fs-assets.js:541
|
||||
msgctxt "footstrap"
|
||||
msgid "Please choose an image file."
|
||||
msgstr ""
|
||||
@@ -307,71 +307,71 @@ msgstr ""
|
||||
msgid "Please enter your username and password."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:329
|
||||
#: htdocs/luci-static/resources/fs-search.js:321
|
||||
msgctxt "footstrap"
|
||||
msgid "Recently visited"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:470
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:477
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:469
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:476
|
||||
msgctxt "footstrap"
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:619
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:686
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:618
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:685
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:714
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:713
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to built-in — go back to the look the theme ships with."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:604
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:603
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:712
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:711
|
||||
msgctxt "footstrap"
|
||||
msgid "Reset to router — drop this browser's changes and follow the router."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:376
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:377
|
||||
msgctxt "footstrap"
|
||||
msgid "Rounding"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:602
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:601
|
||||
msgctxt "footstrap"
|
||||
msgid "Save to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:710
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:709
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"Save to router — store this look on the router. Browsers with a look of "
|
||||
"their own keep it."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:720
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:719
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved look"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:644
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:643
|
||||
msgctxt "footstrap"
|
||||
msgid "Saved to router"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:836
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:835
|
||||
msgctxt "footstrap"
|
||||
msgid "Saving"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:483
|
||||
msgctxt "footstrap"
|
||||
msgid "Scale"
|
||||
msgstr ""
|
||||
@@ -381,60 +381,60 @@ msgstr ""
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:235
|
||||
#: htdocs/luci-static/resources/fs-search.js:261
|
||||
#: htdocs/luci-static/resources/fs-search.js:227
|
||||
#: htdocs/luci-static/resources/fs-search.js:253
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:236
|
||||
#: htdocs/luci-static/resources/fs-search.js:228
|
||||
msgctxt "footstrap"
|
||||
msgid "Search pages…"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:337
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "Sidebar and bar"
|
||||
msgstr ""
|
||||
|
||||
#: ucode/template/themes/footstrap/header.ut:115
|
||||
#: ucode/template/themes/footstrap/header.ut:119
|
||||
msgid "Skip to content"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-search.js:330
|
||||
#: htdocs/luci-static/resources/fs-search.js:322
|
||||
msgctxt "footstrap"
|
||||
msgid "Start typing to find a page"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "Storage"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:485
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:484
|
||||
msgctxt "footstrap"
|
||||
msgid "Strength"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:383
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:384
|
||||
msgctxt "footstrap"
|
||||
msgid "Submenus"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:833
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:832
|
||||
msgctxt "footstrap"
|
||||
msgid "Surfaces"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Swap free"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-overview.js:23
|
||||
#: htdocs/luci-static/resources/fs-overview.js:24
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
|
||||
@@ -442,7 +442,7 @@ msgstr ""
|
||||
msgid "System running in recovery (initramfs) mode."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-select.js:722
|
||||
#: htdocs/luci-static/resources/fs-select.js:721
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
|
||||
@@ -451,12 +451,12 @@ msgctxt "footstrap"
|
||||
msgid "That SVG had nothing left once the unsafe parts were removed."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:292
|
||||
#: htdocs/luci-static/resources/fs-assets.js:346
|
||||
msgctxt "footstrap"
|
||||
msgid "That file could not be read."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:372
|
||||
#: htdocs/luci-static/resources/fs-assets.js:426
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is not a readable image."
|
||||
msgstr ""
|
||||
@@ -466,23 +466,23 @@ msgctxt "footstrap"
|
||||
msgid "That file is not an SVG image."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:464
|
||||
#: htdocs/luci-static/resources/fs-assets.js:518
|
||||
msgctxt "footstrap"
|
||||
msgid "That file is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:489
|
||||
#: htdocs/luci-static/resources/fs-assets.js:543
|
||||
msgctxt "footstrap"
|
||||
msgid "That image is too large."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:326
|
||||
#: htdocs/luci-static/resources/fs-assets.js:380
|
||||
msgctxt "footstrap"
|
||||
msgid "The router refused to delete the file (ubus status %d)."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:341
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:501
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:342
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:500
|
||||
msgctxt "footstrap"
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
@@ -493,7 +493,7 @@ msgid ""
|
||||
"protect the web interface."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:638
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:637
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"This browser is not storing preferences (site data is blocked), so a change "
|
||||
@@ -501,17 +501,17 @@ msgid ""
|
||||
"every browser."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:752
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:751
|
||||
msgctxt "footstrap"
|
||||
msgid "This theme is not translated on this router yet."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:395
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:396
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint (router identification)"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:408
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:407
|
||||
msgctxt "footstrap"
|
||||
msgid "Tint strength"
|
||||
msgstr ""
|
||||
@@ -521,12 +521,12 @@ msgctxt "footstrap"
|
||||
msgid "Too faint to read %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:338
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:339
|
||||
msgctxt "footstrap"
|
||||
msgid "Top"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:191
|
||||
#: htdocs/luci-static/resources/menu-footstrap-common.js:192
|
||||
msgid "Total Available"
|
||||
msgstr ""
|
||||
|
||||
@@ -535,7 +535,7 @@ msgctxt "footstrap"
|
||||
msgid "Upload failed."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:552
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:551
|
||||
msgctxt "footstrap"
|
||||
msgid "Uploading…"
|
||||
msgstr ""
|
||||
@@ -544,12 +544,12 @@ msgstr ""
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:581
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:580
|
||||
msgctxt "footstrap"
|
||||
msgid "Wallpaper"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:424
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:423
|
||||
msgctxt "footstrap"
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
@@ -559,7 +559,7 @@ msgid ""
|
||||
"You must enable JavaScript in your browser or LuCI will not work properly."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:444
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
msgctxt "footstrap"
|
||||
msgid "in the sidebar"
|
||||
msgstr ""
|
||||
@@ -569,17 +569,17 @@ msgctxt "footstrap"
|
||||
msgid "on a card"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:443
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid "on a control"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:400
|
||||
#: htdocs/luci-static/resources/fs-appearance.js:399
|
||||
msgctxt "footstrap"
|
||||
msgid "on the canvas"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/fs-assets.js:388
|
||||
#: htdocs/luci-static/resources/fs-assets.js:442
|
||||
msgctxt "footstrap"
|
||||
msgid ""
|
||||
"the uploaded file could not be removed either; it is still on the router."
|
||||
|
||||
@@ -23,9 +23,11 @@ found=0
|
||||
# spaces are data. Only `> <` is collapsed to `><`.
|
||||
for f in $(find "$DIR" -type f -name '*.svg' | sort); do
|
||||
tmp="$f.tmp$$"
|
||||
# portable slurp: RS tricks are not POSIX — docs/development.md, "Building a package locally".
|
||||
awk '
|
||||
BEGIN { RS = "\0" }
|
||||
{
|
||||
{ buf = buf $0 "\n" }
|
||||
END {
|
||||
$0 = buf
|
||||
# comments first: they may span lines and may contain angle brackets
|
||||
while (match($0, /<!--([^-]|-[^-]|--[^>])*-->/)) {
|
||||
$0 = substr($0, 1, RSTART - 1) substr($0, RSTART + RLENGTH)
|
||||
@@ -41,6 +43,13 @@ for f in $(find "$DIR" -type f -name '*.svg' | sort); do
|
||||
echo "strip-assets: $f came out implausibly small — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
# a silent no-op on some other awk must never ship either: the comment is the one byte
|
||||
# sequence stripping exists to remove, so its survival is the cheapest proof of a no-op.
|
||||
if grep -q '<!--' "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f still has an XML comment after stripping — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$f"
|
||||
found=$((found + 1))
|
||||
done
|
||||
@@ -53,15 +62,16 @@ for f in $(find "$DIR" -type f -name '*.json' ! -path '*/rpcd/acl.d/*' | sort);
|
||||
# nobody and only Save-as-default and the upload would break, on someone else's router. Those
|
||||
# files are also never fetched over the wire. Not worth the risk for ~200 B.
|
||||
awk '
|
||||
BEGIN { RS = "\0"; q = 0 }
|
||||
{
|
||||
{ buf = buf $0 "\n" }
|
||||
END {
|
||||
q = 0
|
||||
out = ""
|
||||
n = length($0)
|
||||
n = length(buf)
|
||||
for (i = 1; i <= n; i++) {
|
||||
c = substr($0, i, 1)
|
||||
c = substr(buf, i, 1)
|
||||
if (q) {
|
||||
out = out c
|
||||
if (c == "\\") { out = out substr($0, i + 1, 1); i++; continue }
|
||||
if (c == "\\") { out = out substr(buf, i + 1, 1); i++; continue }
|
||||
if (c == "\"") q = 0
|
||||
continue
|
||||
}
|
||||
@@ -77,6 +87,12 @@ for f in $(find "$DIR" -type f -name '*.json' ! -path '*/rpcd/acl.d/*' | sort);
|
||||
echo "strip-assets: $f came out empty — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
# a silent no-op must never ship: a surviving newline means stripping did not happen.
|
||||
if [ "$(wc -l < "$tmp")" -gt 0 ]; then
|
||||
rm -f "$tmp"
|
||||
echo "strip-assets: $f still has a newline after stripping — refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$f"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
@@ -10,16 +10,7 @@
|
||||
* Copyright 2011 Twitter, Inc
|
||||
*/
|
||||
|
||||
/* Layer order, declared here and nowhere else. A later layer beats an earlier one regardless of
|
||||
* specificity, so a theme rule never needs !important to outrank base; one that needs it to beat
|
||||
* another footstrap rule belongs in a later layer. Unlayered rules beat every layer, and that slot
|
||||
* is deliberately empty.
|
||||
*
|
||||
* tokens design tokens + the outbound bridge to LuCI's --*-color-* names styles/0*
|
||||
* base widget defaults every LuCI view assumes exist styles/base/*
|
||||
* theme footstrap components and layouts styles/theme/*
|
||||
* page per-page corrections styles/pages/*
|
||||
*
|
||||
* build-css.sh concatenates those four directories into cascade.css, which is generated and
|
||||
* gitignored — never edit it. */
|
||||
/* Layer order, declared here and nowhere else. Directory-to-layer map, concatenation order and
|
||||
* the hijack risk from a foreign sheet naming a layer first: docs/css.md "One directory per
|
||||
* cascade layer" and "Layer order is fixed by the FIRST mention of a name". */
|
||||
@layer tokens, base, theme, page;
|
||||
@@ -1,84 +1,28 @@
|
||||
@layer tokens {
|
||||
/* Design tokens, in two tiers, and the split is load-bearing:
|
||||
*
|
||||
* PRIVATE --fs-* every rule in this theme reads only these
|
||||
* EXPORT --*-color-* LuCI's conventional names, defined FROM the private tier and read
|
||||
* by nobody here — they exist so a third-party luci-app-* keeps
|
||||
* working and keeps following the palette and dark mode
|
||||
*
|
||||
* :root is a shared scope and every luci-app-* drops CSS into the same document, unlayered,
|
||||
* which outranks every cascade layer. An app declaring --accent or the conventional
|
||||
* --text-color-high, which base itself once read 158 times, repaints the theme: 312 of 336
|
||||
* gallery elements with a hostile :root before the split, 0 after. The bridge is outward-only,
|
||||
* and tools/audit.py fails on any read of an export name inside styles/.
|
||||
*
|
||||
* docs/design-system.md. Light = :root; dark = :root[data-darkmode="true"]. */
|
||||
/* PRIVATE --fs-* only; the EXPORT --*-color-* tier is derived elsewhere and unread here.
|
||||
* docs/design-system.md "Two tiers, and the split is load-bearing". */
|
||||
:root {
|
||||
/* Palette colours are in 03-palettes.css, one swappable block per colourway x mode;
|
||||
* everything here is palette-independent or var()-derived. The inks (--fs-on-accent and
|
||||
* friends) live in those blocks too, beside the fills they must be legible against: a
|
||||
* dark palette has light fills and needs a dark ink, and one global `#fff` failed WCAG AA
|
||||
* on seven of the eight dark-palette fills (down to 1.69:1). */
|
||||
/* pop surfaces (menus, dropdown lists, the select picker): one value rather than a
|
||||
* literal per site */
|
||||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30);
|
||||
/* ---- two focus rings, and which one a control takes is the contract ----
|
||||
* -soft is a TINT (10-15% of the accent), measuring 1.15-1.29:1 on the surface it lands
|
||||
* on: a glow, not an indicator, where WCAG 1.4.11 wants 3:1. It is legitimate only beside
|
||||
* a second channel — every field taking it also flips border-color to --fs-accent, which
|
||||
* measures 4.57-7.04:1 across palette x mode x surface, so the border is the indicator and
|
||||
* the tint is the halo.
|
||||
*
|
||||
* -solo is for a control where the ring is the WHOLE indicator and nothing else changes on
|
||||
* focus: the sidebar menu links and chrome icon buttons, the section tabs, both range
|
||||
* sliders. Its 2px of surface is what lets it read against a FILLED control, where an
|
||||
* accent ring would otherwise sit on an accent box; solid accent then measures 5.19-7.04
|
||||
* on panel and 4.61-7.55 on the canvas.
|
||||
*
|
||||
* The invalid twin takes -fill (18%), not -soft: a red ring must read as an alarm rather
|
||||
* than a hover tint, and it is paired with a red border. */
|
||||
/* palette colours live in 03-palettes.css */
|
||||
--fs-shadow-pop: 0 12px 34px rgba(0,0,0,.30); /* pop surfaces: menus, dropdown lists, the select picker */
|
||||
/* which of the three a control takes: docs/design-system.md "The derived ladder" */
|
||||
--fs-focus-ring: 0 0 0 3px var(--fs-accent-soft);
|
||||
--fs-focus-ring-solo: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-accent);
|
||||
--fs-focus-ring-invalid: 0 0 0 3px var(--fs-danger-fill);
|
||||
/* The chosen-row rail: a chosen row says so with an opaque surface and an inset bar, never
|
||||
* with a tint of the accent under accent text (4.21:1 in dark — theme/65-dropdown.css).
|
||||
* Named because it is written in three places that cannot be one rule: the open dropdown's
|
||||
* row, a native `select option:checked`, and the ring the focused row wears on top. The
|
||||
* first two are pinned to each other by tools/mirror.mjs. */
|
||||
/* opaque, not a tint (4.21:1 in dark); pinned by tools/mirror.mjs
|
||||
* (`selected-row/paint`), docs/css.md "@mirror" */
|
||||
--fs-row-rail: inset 2px 0 0 var(--fs-accent);
|
||||
|
||||
/* The hairline: every separator in this theme is the same one-pixel line in the same
|
||||
* border colour, so it is stated once rather than in 69 longhand declarations that would
|
||||
* each have to change for a 2px high-contrast line or a softer colour. The shorthand is
|
||||
* the token, so `border`, `border-top` and friends all take it. */
|
||||
/* every separator is this one line, stated once rather than in 69 longhand declarations */
|
||||
--fs-hairline: 1px solid var(--fs-border);
|
||||
/* a field reacting to focus: the ring arrives and the edge changes colour together, and a
|
||||
* transition list that drifts apart shows the border moving before the ring */
|
||||
/* ring and border-colour transition together, or the ring arrives before the edge does */
|
||||
--fs-field-transition: border-color var(--fs-dur), box-shadow var(--fs-dur);
|
||||
|
||||
/* the theme's one colour literal, deliberately: black at .7 is the absence of light behind
|
||||
* a dialog, not a tint of any token — a palette-tinted scrim over a dark page dims
|
||||
* nothing. A token, so the rules stay literal-free. */
|
||||
/* the theme's one colour literal on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-scrim: rgba(0, 0, 0, .7);
|
||||
|
||||
/* ---- role tint ladder (derived) ----
|
||||
* A tint OF a role, mixed FROM it, so it follows the palette with nothing hand-maintained.
|
||||
* Unnamed, these levels drift: the same outline border was 40% in a table and 45% in the
|
||||
* action bar, the same hover fill 12% in one file and 18% in another.
|
||||
*
|
||||
* -soft 12% a quiet FILL — outline-button :hover
|
||||
* -fill 18% a stronger FILL — callout/diff surface, the invalid ring
|
||||
* -line 40% a HAIRLINE — outline-button border, diff border
|
||||
* -line-hi 55% that hairline on :hover
|
||||
*
|
||||
* The role x step matrix is complete on purpose, read or not: a gap is how the drift
|
||||
* starts, since a rule wanting a level that does not exist invents a percentage.
|
||||
* --fs-accent-soft is the one member not here — its strength differs per MODE (10% light,
|
||||
* 15% dark; hicontrast dark 14%). LIGHT is identical across every palette and lives as one
|
||||
* declaration in 03-palettes.css's shared :root instead; DARK still varies by palette (one
|
||||
* differs by a digit) and stays declared per palette block. */
|
||||
/* Role tint ladder, derived so it follows the palette. docs/design-system.md "The derived
|
||||
* ladder". */
|
||||
--fs-good-soft: color-mix(in srgb, var(--fs-good) 12%, transparent);
|
||||
--fs-warn-soft: color-mix(in srgb, var(--fs-warn) 12%, transparent);
|
||||
--fs-danger-soft: color-mix(in srgb, var(--fs-danger) 12%, transparent);
|
||||
|
||||
--fs-accent-fill: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||
@@ -88,208 +32,79 @@
|
||||
|
||||
--fs-accent-line: color-mix(in srgb, var(--fs-accent) 40%, transparent);
|
||||
--fs-good-line: color-mix(in srgb, var(--fs-good) 40%, transparent);
|
||||
--fs-warn-line: color-mix(in srgb, var(--fs-warn) 40%, transparent);
|
||||
--fs-danger-line: color-mix(in srgb, var(--fs-danger) 40%, transparent);
|
||||
|
||||
--fs-accent-line-hi: color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
--fs-good-line-hi: color-mix(in srgb, var(--fs-good) 55%, transparent);
|
||||
--fs-warn-line-hi: color-mix(in srgb, var(--fs-warn) 55%, transparent);
|
||||
--fs-danger-line-hi: color-mix(in srgb, var(--fs-danger) 55%, transparent);
|
||||
/* The warn row has no reader today and is kept on purpose: LuCI emits no warning BUTTON,
|
||||
* so only -fill is consumed. The row is what makes the next component reach for a token
|
||||
* instead of mixing its own percentage. A grep finding no `var(--fs-warn-line)` is
|
||||
* expected. */
|
||||
|
||||
/* The circular-arrows refresh glyph, as a mask so its colour follows the palette
|
||||
* (--fs-good active, --fs-dim paused). One copy: the poll pill collapses to it both in the
|
||||
* rail and in the bar's compact cluster. A static glyph on purpose — a spinner makes an
|
||||
* idle poll look busy and moves the click target that pauses it.
|
||||
*
|
||||
* OURS, and redrawn to keep it so. It was derived from Lucide's `refresh-cw` (ISC) —
|
||||
* `M21 3v5h-5` was byte-identical — which is a second outside licence obligation this theme
|
||||
* never declared. Redrawn as two OPEN arcs with solid triangular heads: a different
|
||||
* construction, not a nudge of the same one (Lucide caps a continuous stroke with an
|
||||
* L-shaped hook). Solid heads rather than chevrons because chevrons dissolve at the 18px
|
||||
* this renders at. */
|
||||
/* ours, redrawn to stay so — not Lucide's. docs/design-system.md "Two glyphs drawn as
|
||||
* tokens, not fetched". */
|
||||
--fs-icon-refresh: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M4.4 9.6A8.5 8.5 0 0 1 18.6 7.6'/%3E%3Cpath d='M19.6 14.4A8.5 8.5 0 0 1 5.4 16.4'/%3E%3Cpath fill='%23000' stroke='none' d='M21.6 10.6 15.4 8.4 20.4 4.2Z'/%3E%3Cpath fill='%23000' stroke='none' d='M2.4 13.4 8.6 15.6 3.6 19.8Z'/%3E%3C/svg%3E");
|
||||
/* …and the size it is drawn at, which css-dup cannot police for a glyph inlined once and
|
||||
* sized at two call sites. The two sites legitimately differ in technique — the rail masks
|
||||
* the element and drops the border, the bar keeps the bordered square — but not in size. */
|
||||
/* css-dup cannot police a glyph inlined once, sized at two call sites — keep in step by hand */
|
||||
--fs-icon-refresh-sz: calc(18px * var(--fs-density-type));
|
||||
|
||||
/* Hover cue for a solid control — the one cue that cannot be a colour, the fill it shifts
|
||||
* being whatever the role token says. Per mode, and the DIRECTION FLIPS: `filter`
|
||||
* recolours the text too, and a light-mode solid button is a saturated fill with white
|
||||
* ink, which cannot get brighter — brightening drops white on --fs-accent from 5.19:1 to
|
||||
* 4.59:1 at 1.08 and to 4.08:1 at a visible 1.15, an AA failure caused by hovering. So
|
||||
* light darkens (.90 lands at 4.94:1, still clear of the 4.5 floor) and dark, whose fill
|
||||
* is light and ink dark, brightens (1.15). */
|
||||
/* direction flips by mode: light darkens, dark brightens (`filter` recolours the ink too) */
|
||||
--fs-hover-lift: brightness(.90);
|
||||
|
||||
/* ---- motion: four durations, because the UI does four things ----
|
||||
* There is deliberately no easing token: every transition and animation omits the timing
|
||||
* function and takes the CSS default (ease), which is one curve, nothing to keep in sync
|
||||
* and fewer bytes than naming it. A rule needing another curve is making a design decision
|
||||
* and must justify it in a comment.
|
||||
*
|
||||
* The scale governs `animation` too, not only `transition`: one gesture ridden at three
|
||||
* durations is drift css-dup cannot see, the declaration bodies differing.
|
||||
* 95-a11y-media.css kills all of this under prefers-reduced-motion. */
|
||||
/* four durations, no easing token. docs/design-system.md "Scales" ("Motion") */
|
||||
--fs-dur: .15s; /* a state change: colour, border, shadow, background, filter */
|
||||
--fs-dur-move: .2s; /* something that moves or unfolds: transform, max-height */
|
||||
--fs-dur-fade: .25s; /* a transient thing fading in or out: spinner, notification */
|
||||
--fs-dur-fill: .4s; /* a progress bar growing to its value */
|
||||
--fs-content-max: 1280px; /* content column cap */
|
||||
/* content column side padding, read by .fs-content, the top bar's centering calc and the
|
||||
* notification width calc, so the three cannot drift */
|
||||
/* also read by the bar's centering calc and the notification-width calc */
|
||||
--fs-content-pad: calc(28px * var(--fs-density-space));
|
||||
/* The shell's geometry: tokens, not literals, because the JS reads them back — the CSS
|
||||
* lays the sidebar out and fs-chrome.js's fitShell() subtracts that cut from the viewport
|
||||
* to decide whether what is left is readable. With copies in JS, narrowing the rail here
|
||||
* leaves the measurement subtracting the old width. */
|
||||
/* tokens, not literals — fs-chrome.js's fitShell() reads them back. docs/design-system.md
|
||||
* "Scales" ("Shell geometry"). */
|
||||
--fs-sidebar-w: calc(224px * var(--fs-density-box)); /* the expanded vertical sidebar's column */
|
||||
--fs-rail-w: calc(68px * var(--fs-density-box)); /* ...and the same sidebar collapsed to its icon rail */
|
||||
--fs-content-min: 500px; /* below this a content column stops being readable */
|
||||
/* the bar's surface: the top-nav bar and the sidebar's phone bar are one component in two
|
||||
* markups, so one token keeps the two identical. OPAQUE on purpose. It was 88% of the panel
|
||||
* under a 12px backdrop blur, and the pair was measured on the page most favourable to it —
|
||||
* a pattern wallpaper at full strength, the bar sitting over scrolled content: dropping the
|
||||
* blur moved 0.04% of the pixels, and what the translucency actually bought was the page
|
||||
* showing through the chrome, which reads as a glitch rather than as glass. */
|
||||
/* opaque on purpose. docs/design-system.md "The derived ladder". */
|
||||
--fs-bar-bg: var(--fs-panel);
|
||||
/* height of that bar, and the scroll-margin every anchor target needs to clear it
|
||||
* (base/10-reset.css): if the two drift, an in-page jump lands under the bar */
|
||||
/* The chrome's own controls — the square buttons (search, appearance, log out) and the poll
|
||||
* pill — ride the bar rather than cutting panel-coloured holes in it: the bar is one surface,
|
||||
* and a control on it is carried by its hairline, not by a second fill. `transparent`, so the
|
||||
* control shows the bar itself rather than a second surface stacked on it.
|
||||
*
|
||||
* The backdrop a control lands on is therefore --fs-bar-bg, which is --fs-panel: the same
|
||||
* construction `.label`'s comment in theme/35-alerts.css leans on, so a coloured variant keeps
|
||||
* its >=4.5:1 by the palette's guarantee rather than by measurement. A palette that wants the
|
||||
* old panel2 hole sets this back to var(--fs-panel2). */
|
||||
--fs-bar-ctl-bg: transparent;
|
||||
/* The select's chevron, drawn rather than fetched. It used to be an inline SVG data-URI, and
|
||||
* a URI cannot read var() — the stroke colour had to be written INTO the string, so the
|
||||
* declaration was repeated once per palette and once per mode: ten copies of one glyph, ~2.4 KB
|
||||
* of the shipped sheet, and ten chances for a palette to be recoloured while its chevron was
|
||||
* not. Two gradient bands cost one declaration and take their colour from the palette's own
|
||||
* --fs-dim at paint time, so a new palette gets a correct chevron for free. The 45% / 55% stops
|
||||
* are the band's thickness; the two layers are positioned by the rules that use this, since
|
||||
* only they know the field's inset. What is given up is the round line cap the SVG had, which
|
||||
* at 12px is not visible — compared side by side before the swap. */
|
||||
/* base/10-reset.css's scroll-margin must equal this or an in-page jump lands under the bar */
|
||||
/* drawn, not fetched. docs/design-system.md "Two glyphs drawn as tokens, not fetched". */
|
||||
--fs-select-chevron:
|
||||
linear-gradient(-45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%),
|
||||
linear-gradient(45deg, transparent 45%, var(--fs-dim) 45% 55%, transparent 55%);
|
||||
--fs-bar-h: calc(46px * var(--fs-density-box));
|
||||
/* the square icon button in the bar and rail: the logo box, the .fs-themerow controls,
|
||||
* Log out and the collapsed poll pill are one size, so the right cluster reads as a row
|
||||
* of equal squares */
|
||||
/* logo box, .fs-themerow controls, Log out and the poll pill share this size */
|
||||
--fs-btn-size: calc(34px * var(--fs-density-box));
|
||||
|
||||
/* Corner-radius scale: one user-tunable base (Appearance -> Rounding, 0-20px, pre-painted
|
||||
* on :root by partials/head.ut) with three semantic radii scaling from it proportionally,
|
||||
* so every surface rounds in step and pills stay round. At base 12: card 12, control 10,
|
||||
* chip 8. Every border-radius in theme/ and pages/ reads one of these; the only bare px
|
||||
* left are 0, 50% and three that are not a card's corner at all. */
|
||||
/* one base (Appearance -> Rounding); three radii scale from it */
|
||||
--fs-radius-base: 12px;
|
||||
--fs-radius-lg: var(--fs-radius-base); /* cards, panels, modals, table frame, popovers */
|
||||
--fs-radius: calc(var(--fs-radius-base) * 5 / 6); /* controls: inputs, buttons, dropdowns, tabs (10 @12) */
|
||||
--fs-radius-sm: calc(var(--fs-radius-base) * 2 / 3); /* chips, code, small insets, rail toggle (8 @12) */
|
||||
--fs-radius-pill: 999px; /* pills, toggles, badges — always round */
|
||||
|
||||
/* A card's inner gutter (.cbi-section). A token because it is read back to UNDO it:
|
||||
* theme/45-misc.css bleeds the realtime graphs out to the card's border edge by this
|
||||
* padding plus the 1px border, so a literal in both places would re-clip every graph when
|
||||
* the gutter narrows. */
|
||||
/* .cbi-section's own gutter; theme/45-misc.css reads it back to undo it (bleed to the card edge) */
|
||||
--fs-card-pad: calc(16px * var(--fs-density-space));
|
||||
/* the vertical rhythm between cards (.cbi-section, tab groups, alerts, action bars):
|
||||
* an unnamed level drifts, so it is stated once */
|
||||
/* vertical rhythm between cards: .cbi-section, tab groups, alerts, action bars */
|
||||
--fs-card-gap: calc(16px * var(--fs-density-space));
|
||||
|
||||
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* ---- type scale ----
|
||||
*
|
||||
* 13px is the base deliberately: a router UI is dense tables of addresses and counters, and
|
||||
* the size was chosen for that. The rhythm around it is a unitless 1.5 rather than a hard
|
||||
* 18px — `line-height: 18px` is inherited as 18px into a 20px heading and clips it, while
|
||||
* 1.5 is re-resolved against each element's own size.
|
||||
*
|
||||
* Every step is a whole px; the line box 1.5 computes is not (13 -> 19.5), which is what
|
||||
* unitless leading gives on an odd size. What must stay whole is a BOX: never size an
|
||||
* element `--fs-type * --fs-leading` — take the nearer --fs-space step.
|
||||
*
|
||||
* Five steps, and the count is the point: a scale with 1px gaps builds no hierarchy, it
|
||||
* just makes two rules look unrelated in a grep. Ratio is roughly 1.25 per step, and
|
||||
* anything needing a size off this list is making a design decision and should say so. */
|
||||
/* ---- density: one axis, three multipliers ----
|
||||
*
|
||||
* Compact / Normal / Large is a client preference stamped as :root[data-density]; absent
|
||||
* means Normal, so the default costs no attribute and no override.
|
||||
*
|
||||
* Three multipliers, because one knob gets all three kinds of size wrong:
|
||||
*
|
||||
* --fs-density-type TEXT, moving least (.9 / 1.15). Compact is a request for less air,
|
||||
* not for smaller writing; .9 puts the base at 12px, which is the
|
||||
* floor.
|
||||
* --fs-density-space pure AIR — padding, margin, gap — which is what buys the room, so
|
||||
* it moves most (.65 at Compact). It does not grow at Large, where
|
||||
* inflating the air just reads as zoom.
|
||||
* --fs-density-box boxes that must HOLD text or an icon: the sidebar, the rail, the
|
||||
* bar height, the icon buttons, the control-height floors. They
|
||||
* cannot follow the air — the type gives up only a tenth, so a
|
||||
* sidebar cut by 35% no longer fits the labels it exists to show.
|
||||
*
|
||||
* This is only expressible because every size in the theme reads one of these tokens: a
|
||||
* size written outside the ladders silently stops responding to the axis.
|
||||
*
|
||||
* Type is rounded to whole pixels, a fractional font size rendering soft; space is not,
|
||||
* since a sub-pixel padding is invisible and rounding it would collide steps together. */
|
||||
/* five steps, ~1.25 apart */
|
||||
/* three multipliers, one knob each for text/air/boxes */
|
||||
--fs-density-type: 1;
|
||||
--fs-density-space: 1;
|
||||
--fs-density-box: 1;
|
||||
|
||||
/* ---- control heights: three steps, following the density ----
|
||||
* A minimum height stops a button collapsing onto its text, so it is part of how dense the
|
||||
* UI feels: left literal, Compact shrinks the writing while the buttons stay put.
|
||||
* Not --fs-control-h below, which is a FIELD's height derived from the text it must hold;
|
||||
* these are the floors a chrome control gets when its content is not text. */
|
||||
/* chrome-control floors, not a field's height (--fs-control-h below) */
|
||||
--fs-ctl-h-sm: calc(30px * var(--fs-density-box)); /* dense/small: .btn.small, table-row buttons */
|
||||
--fs-ctl-h: calc(38px * var(--fs-density-box)); /* the default control */
|
||||
--fs-ctl-h-lg: calc(44px * var(--fs-density-box)); /* prominent: .btn.large, the login submit */
|
||||
|
||||
/* Unrounded here, rounded in the @supports below, and the split is the difference between
|
||||
* a soft failure and no page: `round()` is the youngest thing this theme depends on
|
||||
* (Chromium 125, Firefox 118, Safari 15.4), and a custom property holds any token stream,
|
||||
* so an engine without it parses these fine and fails at SUBSTITUTION — `var(--fs-type)`
|
||||
* is then invalid at computed-value time and computes to `unset`, taking every font-size,
|
||||
* line-height and field min-height with it. Measured by serving a sheet with `round(`
|
||||
* renamed: 5881 of 5912 elements on Overview change, body type goes 13px -> 16px, and 5880
|
||||
* font-size and 5733 line-height declarations are lost. Declared this way, such an engine
|
||||
* keeps the whole ladder and pays a fractional pixel at the two non-default densities. */
|
||||
/* One step BELOW the ladder's smallest, and it exists for one measured reason: the Port
|
||||
* status card's traffic figures. They are the widest thing a card must fit whole, so their
|
||||
* size is what sets how narrow a card may be — at --fs-type-xs the floor is 106px, at 10px
|
||||
* it is 94px, which is what turns ten cards plus a lonely eleventh into one row of eleven.
|
||||
* A figure read as a pair of numbers beside a label tolerates the step that body text would
|
||||
* not. Declared here rather than as a literal in the page rule so the density axis stays in
|
||||
* one place. */
|
||||
/* rounded in @supports below (round() is younger than the floor) */
|
||||
--fs-type-2xs: calc(10px * var(--fs-density-type));
|
||||
--fs-type-xs: calc(11px * var(--fs-density-type)); /* eyebrow, badges, tooltips, dense metadata */
|
||||
--fs-type: calc(13px * var(--fs-density-type)); /* body, tables, fields, menu items — the base */
|
||||
--fs-type-lg: calc(16px * var(--fs-density-type)); /* section titles (h3/legend), alert headings */
|
||||
--fs-type-xl: calc(20px * var(--fs-density-type)); /* page titles */
|
||||
--fs-type-2xl: calc(26px * var(--fs-density-type)); /* h1 — only a third-party app emits a bare one */
|
||||
/* ---- WEIGHT: there are only ever three, and they had six spellings ----------
|
||||
* The theme ships no font at all — the stacks name Manrope and JetBrains Mono first and
|
||||
* fall through to the system faces — so these are not a ramp to pick from: they are the
|
||||
* two weights a reader is likely to actually have, plus the "un-bold this"
|
||||
* value. `700` and `bold` were both in use and are the same weight; so are `400` and
|
||||
* `normal`, and 400 has no face at all (it resolves onto the 600 one — that is why body
|
||||
* text is semibold by design, and why adding a 400 face would silently restyle every page).
|
||||
* Naming them leaves one spelling each. */
|
||||
/* one spelling per weight — `700`/`bold` and `400`/`normal` were both in use. */
|
||||
--fs-weight-normal: 400; /* "not bold" — no face of its own; resolves onto the 600 */
|
||||
--fs-weight: 600; /* the UI default: body, labels, controls */
|
||||
--fs-weight-bold: 700; /* titles, table headers, the eyebrow */
|
||||
@@ -297,45 +112,12 @@
|
||||
--fs-leading: 1.5; /* unitless ON PURPOSE — see above (13 -> 19.5px) */
|
||||
--fs-leading-tight: 1.2; /* headings and single-line plates, where 1.5 looks loose */
|
||||
|
||||
/* ---- THE EYEBROW — one microlabel idiom, named once -------------------------
|
||||
*
|
||||
* Uppercase + tracked + bold + muted is ONE thing this theme draws in a dozen places: table
|
||||
* column headers, the stacked-card `data-title` labels, the Appearance group labels, the rail
|
||||
* flyout titles, the login field titles, the search-result section note. It had drifted into
|
||||
* four spellings of that one idiom — letter-spacing .04/.05/.06em, weight 600 vs 700, colour
|
||||
* --fs-dim vs --fs-faint — the same unnamed-level drift the role x step matrix above exists to
|
||||
* stop, only in typography. An eyebrow is a LABEL, so it takes the quietest ink and the widest
|
||||
* tracking of the set; the alert table's 600 was the odd one out and read lighter than every
|
||||
* other header on the page.
|
||||
*
|
||||
* SIZE is deliberately NOT here: 10px (a card/chrome label) and 11px (a table header, i.e.
|
||||
* --fs-type-xs) are two real tiers, and collapsing them would resize half the tables.
|
||||
*
|
||||
* Two things that look like eyebrows are NOT and must stay out: `h6` is a HEADING (it takes the
|
||||
* type scale and the heading ink), and `.fs-navlabel` is the menu's section separator, whose
|
||||
* .1em is deliberately wider than any label — it separates, it does not caption. */
|
||||
/* one microlabel idiom, named once */
|
||||
--fs-eyebrow-tracking: .06em;
|
||||
--fs-eyebrow-weight: 700;
|
||||
--fs-eyebrow-color: var(--fs-faint);
|
||||
|
||||
/* ---- SPACE SCALE — a 4px grid ----------------------------------------------
|
||||
*
|
||||
* Same story: the 18px/9px pair base used was upstream's line-height and half of it, so
|
||||
* every gutter in the theme was a function of Twitter's leading. A 4px grid is independent
|
||||
* of the type scale, which is the point — changing the font size must not silently
|
||||
* re-space every widget. `--fs-card-pad` (16px) and `--fs-content-pad` (28px) are already
|
||||
* on this grid and stay named for their role; these are the generic steps.
|
||||
*
|
||||
* Do NOT add a step because one rule wants 10px. An unnamed level is how the radius tints
|
||||
* drifted into four strengths for two decisions (see the ladder above): pick the nearer
|
||||
* step, or argue in a comment for why this rule is genuinely different. */
|
||||
/* The grid is 4px, but the theme genuinely works at 2px between the low steps: a chip's
|
||||
* inset, a nav item's row padding and a card's inner gutter all landed on 6/10/14 and no
|
||||
* amount of rounding makes them 4/8/12 without visibly changing the control. So the half
|
||||
* steps are NAMED rather than pretended away — they were the single largest group of
|
||||
* magic numbers left in the tree (6px x26, 10px x32, 14px x13, 2px x13). Above 16px the
|
||||
* grid stays 4px: nothing needs finer, and every value that had drifted between the steps
|
||||
* up there (22, 26, 42) was snapped onto one. */
|
||||
/* pick the nearer step; do not add one for a single rule */
|
||||
--fs-space-0-5: calc(2px * var(--fs-density-space));
|
||||
--fs-space-1: calc(4px * var(--fs-density-space));
|
||||
--fs-space-1-5: calc(6px * var(--fs-density-space));
|
||||
@@ -347,49 +129,17 @@
|
||||
--fs-space-5: calc(20px * var(--fs-density-space));
|
||||
--fs-space-6: calc(24px * var(--fs-density-space));
|
||||
--fs-space-7: calc(28px * var(--fs-density-space));
|
||||
/* The top of the ladder is sparse on purpose — these three are gutters and insets, not
|
||||
* rhythm, and each has two or three users: the sidebar accordion's indent and the search
|
||||
* palette's bottom padding (-7), the room a field leaves for its own chevron (-8), the login
|
||||
* card's bottom margin and the gauge's two right gutters (-10). They are named so that no
|
||||
* padding in this theme is an unexplained number, not because a fourth caller is expected. */
|
||||
/* sparse on purpose above -7 — gutters and insets, not rhythm */
|
||||
--fs-space-8: calc(32px * var(--fs-density-space));
|
||||
--fs-space-10: calc(40px * var(--fs-density-space));
|
||||
|
||||
/* the spinner glyph's box (base/95-luci.css `.spinning::before`, width and height): one
|
||||
* number, one name, read in one place */
|
||||
--fs-spin-size: var(--fs-space-5);
|
||||
|
||||
|
||||
/* One control height for every single-line field — input, select, .cbi-select,
|
||||
* .cbi-dropdown and the checkbox/radio row beside them. Derived rather than measured by
|
||||
* hand: the text box (--fs-type x --fs-leading = 19.5px) plus the vertical inset those
|
||||
* fields carry (2 x --fs-space-1) plus both 1px borders = 29.5 -> ceil 32 at the default
|
||||
* density. A literal here is arithmetic over one particular leading, so
|
||||
* re-scaling the type silently leaves every control the wrong height for its own text. */
|
||||
/* every single-line field's height, derived not measured */
|
||||
--fs-control-h: calc(var(--fs-type) * var(--fs-leading) + var(--fs-space-1) * 2 + 2px);
|
||||
|
||||
/* How wide an elastic control gets in a form row. A `.cbi-value-field` is `flex: 1`, so
|
||||
* anything asking for 100% takes the whole card, and a form whose controls each end at a
|
||||
* different x reads as broken. Fixed-width controls keep their own 210px; this is the
|
||||
* ceiling for the ones that stretch. It is a COLUMN width, like the label column beside
|
||||
* it, so it is deliberately not on the 4px step scale. */
|
||||
/* ceiling for an elastic `.cbi-value-field` (flex: 1) — a COLUMN width, off the 4px scale */
|
||||
--fs-field-max: 440px;
|
||||
|
||||
/* ---- stacking scale ----
|
||||
* Every z-index in the theme comes from here. Bare numbers across seven files record
|
||||
* nothing about what should sit above what, which is how a popover came to paint over an
|
||||
* open modal; as an ordered list that is obvious.
|
||||
*
|
||||
* Bottom to top:
|
||||
* raise in-flow child that must clear its siblings (zonebadge tooltip)
|
||||
* sticky the sticky bars: top-nav, sidebar phone bar
|
||||
* flyout nav panels: rail flyouts, top-nav dropdowns, rail tooltips
|
||||
* header base's sticky <header> (above content, below any popup)
|
||||
* popover the Appearance popover — ABOVE the chrome, BELOW a modal
|
||||
* overlay #modal_overlay: a modal is modal, it covers the chrome
|
||||
* tooltip .cbi-tooltip — must read over modal content
|
||||
* dropdown an OPEN .cbi-dropdown list — over everything, including inside a modal
|
||||
* ------------------------------------------------------------------------ */
|
||||
/* every z-index comes from here */
|
||||
--fs-z-raise: 2;
|
||||
--fs-z-sticky: 50;
|
||||
--fs-z-flyout: 70;
|
||||
@@ -399,59 +149,24 @@
|
||||
--fs-z-tooltip: 1000;
|
||||
--fs-z-dropdown: 1100;
|
||||
|
||||
/* ---- export tier: outbound only ----
|
||||
* Nothing in styles/ may read these (tools/audit.py fails the build if it does). They
|
||||
* exist so a third-party luci-app-* addressing them still gets our palette and dark mode,
|
||||
* and so an app overwriting one restyles only its own widgets.
|
||||
*
|
||||
* high/medium/low is a PROMINENCE scale, not decoration: apps do ask for the gradation —
|
||||
* one paints its "no data" latency with --primary-color-low, another reads eleven of these
|
||||
* names, and stock firewall.js and status/cpu.js read them too — so three aliases of one
|
||||
* token hand back a flat colour.
|
||||
*
|
||||
* Direction is bootstrap's, which every app was calibrated against: `high` is the most
|
||||
* pronounced value of the role, except --background-color-*, which is an ELEVATION axis
|
||||
* (high = raised). The mechanism differs per family, by measurement — see each block.
|
||||
*
|
||||
* Binding constraint: apps read a level as `color:` about as often as as `background:`, so
|
||||
* every level must clear WCAG AA as text on --fs-bg/--fs-panel/--fs-panel2 and carry a
|
||||
* legible --on-*-color as a fill. */
|
||||
/* outbound only (audit.py --strict fails a styles/ read of one). docs/design-system.md
|
||||
* "The export tier is a RAMP" */
|
||||
--background-color-high: var(--fs-panel);
|
||||
--background-color-medium: color-mix(in srgb, var(--fs-panel) 55%, var(--fs-panel2));
|
||||
--background-color-low: var(--fs-panel2);
|
||||
|
||||
/* -low is --fs-faint, the theme's own third ink, and not a duplicate of -medium: making the
|
||||
* two the same token flattens the export (`spread(high, low)` falls to 0.082 in the
|
||||
* default palette, under the 0.10 the ramp promises) and hands an app asking for a
|
||||
* gradation one colour twice. Legibility is measured rather than assumed — every level is
|
||||
* held to AA as text on all three surfaces and as a fill under its --on-* ink, in both
|
||||
* modes, across the whole palette x tint matrix. */
|
||||
--text-color-highest: var(--fs-text);
|
||||
--text-color-high: var(--fs-text);
|
||||
--text-color-medium: var(--fs-dim);
|
||||
--text-color-low: var(--fs-faint);
|
||||
|
||||
/* borders carry no text, so -low may fade toward the surface: a hairline is supposed to
|
||||
/* borders carry no text, so -low may fade toward the surface — a hairline is meant to
|
||||
* disappear into it */
|
||||
--border-color-high: color-mix(in srgb, var(--fs-border) 60%, var(--fs-text));
|
||||
--border-color-medium: var(--fs-border);
|
||||
--border-color-low: color-mix(in srgb, var(--fs-border) 55%, var(--fs-panel));
|
||||
|
||||
/* Accent ramps: the axis is CHROMA at constant lightness — `high` is the pure palette
|
||||
* colour and each step mixes it toward --fs-dim in oklch, which interpolates lightness
|
||||
* linearly and so drains saturation while leaving luminance put, keeping every level
|
||||
* printable as text. Both alternatives were measured and rejected:
|
||||
*
|
||||
* fading `low` toward the surface spends contrast we do not have — in dark every accent
|
||||
* already sits at 4.56-4.59:1, and an 8% fade took one level to 4.18:1;
|
||||
*
|
||||
* pushing `high` toward --fs-text collapses the ramp in dark, where --fs-text is
|
||||
* near-white: the strong end comes back 0.055 from the weak end, i.e. flat.
|
||||
*
|
||||
* Bootstrap's own ramp walks lightness, which is why it reads inverted in dark mode and
|
||||
* lands at 3.6:1. Keep its direction, drop its mechanism. Every step is mixed FROM the
|
||||
* token it grades, so there is no hand-kept second copy of a colour, and
|
||||
* tools/export-tier.mjs proves the whole tier across palette x mode. */
|
||||
/* tools/export-tier.mjs proves the whole tier across palette x mode */
|
||||
--primary-color-high: var(--fs-accent);
|
||||
--primary-color-medium: color-mix(in oklch, var(--fs-accent) 78%, var(--fs-dim));
|
||||
--primary-color-low: color-mix(in oklch, var(--fs-accent) 45%, var(--fs-dim));
|
||||
@@ -476,16 +191,7 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- the rounded half of the type ladder, for engines that can round ----
|
||||
*
|
||||
* A fractional font size renders soft, so every size is meant to land on a whole pixel — but a
|
||||
* token using `round()` on an engine without it is not a fallback, it is a deleted token that
|
||||
* takes every rule reading it (see the ladder above). So the ladder is declared plainly there
|
||||
* and rounded here, where the at-rule can be asked first. --fs-control-h is derived from
|
||||
* --fs-type and rounds UP to the 4px control ladder for the same reason.
|
||||
*
|
||||
* `@supports (width: round(1px, 1px))` rather than a `calc()` probe: the function has to be
|
||||
* tested where a length is expected. */
|
||||
/* round(…) tested where a length is expected, not by a calc() probe */
|
||||
@supports (width: round(1px, 1px)) {
|
||||
:root {
|
||||
--fs-type-2xs: calc(round(10px * var(--fs-density-type), 1px));
|
||||
@@ -498,72 +204,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the density axis ----
|
||||
*
|
||||
* Normal is a bare :root, so the default path costs no attribute and no override.
|
||||
*
|
||||
* These multiply the LADDERS, not the rules: a size on a ladder follows the axis for free and
|
||||
* one that is not silently ignores it. */
|
||||
/* bare :root is Normal; these multiply the LADDERS, not the rules */
|
||||
:root[data-density="compact"] {
|
||||
/* type moves least (.9 against .65 for the air): padding is where the room is, so leaning
|
||||
* on the text would cost legibility to save very little. At .9 the base is 12px and the
|
||||
* eyebrow 10px, which is the floor. */
|
||||
--fs-density-type: .9;
|
||||
--fs-density-box: .85;
|
||||
--fs-density-space: .65;
|
||||
}
|
||||
:root[data-density="large"] {
|
||||
/* Large is about the TEXT, so the air does not grow with it: only the type and the boxes
|
||||
* that hold it get bigger. Inflating the spacing too pushes content off the screen and
|
||||
* makes the setting read as zoom, which is the browser's job. */
|
||||
--fs-density-type: 1.15;
|
||||
--fs-density-box: 1.15;
|
||||
--fs-density-space: 1;
|
||||
/* --fs-density-space stays at the bare :root default of 1 here — large density widens type
|
||||
* and controls, not the space/gutter scale. */
|
||||
}
|
||||
|
||||
/* ---- the content-width axis ----
|
||||
*
|
||||
* No :root[data-content-width] blocks here (issue #44 moved this from a three-step picker to a
|
||||
* slider): the value is a real px length written straight onto --fs-content-max as an inline
|
||||
* style by fs-axes.js's propAxis, the same shape Rounding uses, and read back exactly as before
|
||||
* by `.fs-content`, the modal width calc and the top layout's centring padding
|
||||
* (theme/20-shell.css, theme/50-toplayout.css) — deliberately including the modal, which follows
|
||||
* the column on purpose rather than keeping its own separate cap the reader would have to
|
||||
* reconcile against.
|
||||
*
|
||||
* The range is 1280-3840, and neither end is derived from this file: 1280 (--fs-content-max
|
||||
* above) is the value the theme has always drawn, so the slider's left end IS today's look and
|
||||
* the axis can never go narrower than the default — a difference from every other numeric axis,
|
||||
* none of which has a floor that also has to be the default. --fs-content-min (500px) was the
|
||||
* rule this axis had to stay clear of; with 1280 as the floor it is now a property of the range
|
||||
* instead, 780px below the nearest point the slider can reach. 3840 stands in for "uncapped": no
|
||||
* CSS viewport width in ordinary use reaches it, since even a physical 4K panel reports a scaled
|
||||
* CSS width well under it — and `columnWidth()`'s `Math.min(state.outerW - cut, g.contentMax)`
|
||||
* needs a real px length either way, `none`/`100%` failing both the `calc()` in
|
||||
* 50-toplayout.css and `px()`'s getComputedStyle probe in fs-chrome.js.
|
||||
*
|
||||
* Step 128px: 2,560px of travel at 20 stops, matching Rounding/Photo dim/Pattern strength's stop
|
||||
* count rather than Pattern scale's near-pixel drag — a stop here should move a table's column
|
||||
* count, not shave a pixel off a margin, and 128 is the largest round number that divides the
|
||||
* range into stops that few. */
|
||||
/* No :root[data-content-width] block: fs-axes.js's propAxis writes --fs-content-max inline
|
||||
* instead. docs/design-system.md "The appearance axes" ("Content width"). */
|
||||
|
||||
:root[data-darkmode="true"] {
|
||||
/* palette colours are in 03-palettes.css; only mode-level, palette-independent
|
||||
* tokens live here */
|
||||
/* palette colours are in 03-palettes.css; only mode-level tokens live here */
|
||||
--fs-disabled-opacity: .45;
|
||||
/* the hover cue flips direction here (see --fs-hover-lift): a dark-mode solid button is a
|
||||
* light fill with dark ink, so brightening moves it away from both the page and its own
|
||||
* ink */
|
||||
/* dark fill is light, so brightening (not darkening) moves it away from page AND ink */
|
||||
--fs-hover-lift: brightness(1.15);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* No component bridge in any notation (--*-hsl triples, --*-rgb read as `rgba(var(--x), .3)`):
|
||||
* a hand-kept second copy of a colour goes stale in silence when a palette is recoloured, and
|
||||
* a missing triple makes the declaration invalid at computed-value time, so the tint vanishes
|
||||
* with no error tools/audit.py can catch — the var IS defined, only elsewhere. A tint of X is
|
||||
* mixed FROM X, and a component copy must not be reintroduced in any notation:
|
||||
* rgba(var(--error-color-high-rgb), .3) -> color-mix(in srgb, var(--fs-danger) 30%, transparent)
|
||||
* The one surviving --*-rgb is --zone-color-rgb, which is not ours: luci-mod-network writes it
|
||||
* inline on a zone badge (base/95-luci.css). */
|
||||
/* no component bridge (--*-hsl, --*-rgb): docs/conventions.md "Writing CSS" ("No colour
|
||||
* literals"). The one surviving --*-rgb is luci-mod-network's own (base/95-luci.css), not ours. */
|
||||
}
|
||||
@@ -1,260 +1,72 @@
|
||||
@layer tokens {
|
||||
/* ---- colourways (palettes) ----
|
||||
*
|
||||
* Every colour is a variable and every palette is one self-contained block per mode, so a new
|
||||
* colourway is copy-one-pair-and-recolour. Five ship: footstrap (GitHub Primer, the default),
|
||||
* hicontrast, bootstrap (the stock LuCI theme's colours), 2020 (the OpenWrt 2020 theme's
|
||||
* colourway) and forum (the OpenWrt forum's Discourse colourway).
|
||||
*
|
||||
* TO ADD A COLOURWAY "sunset":
|
||||
* 1. Copy the two hicontrast blocks below, rename data-palette to "sunset".
|
||||
* 2. Set every colour. FIVE are declared as -base with the live token derived from them,
|
||||
* so the colour axes can recolour them with no second copy to keep in sync: the canvas
|
||||
* --fs-bg-base → --fs-bg (TINT block), the accent --fs-accent-base → --fs-accent and
|
||||
* the three status colours --fs-good-base / --fs-warn-base / --fs-danger-base (COLOUR
|
||||
* AXES block). A palette never declares --fs-good itself.
|
||||
* 3. Set the four INKS (--fs-on-accent/-good/-warn/-danger) — see below.
|
||||
* 4. Register the name in all four places, because each one fails DIFFERENTLY and quietly:
|
||||
* the PALETTE axis in fs-prefs.js (live switching), the `_sd_pal` whitelist AND the
|
||||
* pre-paint switch in partials/head.ut (a router default outside the whitelist becomes
|
||||
* '' and the page paints in the default palette), the label map in fs-appearance.js (an
|
||||
* unnamed palette is missing from the picker, not broken), and matrix() in
|
||||
* tools/lib/gallery.mjs — a palette absent there is one export-tier.mjs,
|
||||
* a11y-gallery.mjs and placeholder-ink.mjs never measure, so it ships ungated.
|
||||
* Nothing else: layout, components and the --*-color-* export bridge key off these tokens, and
|
||||
* every tint, shadow and hairline is a color-mix() over them.
|
||||
*
|
||||
* The inks are per palette and per mode. A single global `--fs-on-accent: #fff` holds up on
|
||||
* the dark fills of a LIGHT palette and nowhere else — on a dark palette's light fills it
|
||||
* measures 1.69-2.80:1, where AA wants 4.5:1 for text and 3:1 for a UI shape. Check a new
|
||||
* colourway's inks against all four of its own fills; every pair below clears 4.5:1.
|
||||
*
|
||||
* A tint of X is mixed FROM X — do not reintroduce a component copy of a colour in any
|
||||
* notation (foot of 02-tokens.css).
|
||||
*
|
||||
* Default: footstrap fills bare :root, so a no-JS load still has colours; every other
|
||||
* colourway is opt-in. :root[data-palette=…][data-darkmode=…] (0,3,0) beats the bare blocks,
|
||||
* and each block sets a COMPLETE token set so the winner fully applies. */
|
||||
/* five colourways, one block per (palette x mode). docs/design-system.md "Palettes";
|
||||
* adding one: "Adding a palette". */
|
||||
|
||||
/* Native <select> closed-control chevron (theme/60-inputs.css). A data-URI cannot
|
||||
* reference var() or currentColor, so each palette block bakes its own --fs-dim into
|
||||
* the stroke — the only way the glyph can follow palette and mode. */
|
||||
|
||||
/* ---- background tint: "which router am I on?" ----
|
||||
*
|
||||
* One hue (Appearance -> Tint, 0-360°, localStorage `fs-tint`, pre-painted on :root by
|
||||
* partials/head.ut) washed into the CANVAS — --fs-bg, the surface the cards float on — so a
|
||||
* whole install reads as green, violet or amber at a glance. localStorage is keyed
|
||||
* by origin, so the cue is per router with nothing server-side. Off (no data-tint) is the
|
||||
* palette exactly as it was.
|
||||
*
|
||||
* Nothing else moves: the cards, the chrome and the semantic colours keep the palette's own
|
||||
* values, and re-hueing a status colour would make it lie about status.
|
||||
*
|
||||
* --fs-bg-base exists because a custom property cannot be defined from itself:
|
||||
* `--fs-bg: color-mix(…, var(--fs-bg), …)` is a cycle, invalid at computed-value time, and
|
||||
* drops the colour silently. A palette names the raw canvas, this block owns the derivation,
|
||||
* and every rule goes on reading --fs-bg.
|
||||
*
|
||||
* It sets chroma and hue rather than mixing a colour in, because in a POLAR space the mix's
|
||||
* hue lands on the tint's almost immediately and the percentage never controls the strength:
|
||||
* measured on the dark canvas, "2%" and "6%" at hue 165 gave the same green. Light mode failed
|
||||
* the other way, the mix leaving sRGB and clipping, so the cue's strength depended on which
|
||||
* hue was picked. So the canvas takes the user's hue at a chroma we choose and keeps its own
|
||||
* LIGHTNESS, in one relative-colour declaration:
|
||||
* one honest knob --fs-tint-c is the strength, in chroma. Light mode has less headroom and
|
||||
* does clip at the top of the wheel, which costs a few units of strength
|
||||
* but not the direction. On a canvas of pure WHITE the cue is nearly
|
||||
* hue-blind — on bootstrap's #fff, 180° and 258° land 1/255 apart —
|
||||
* which is the price of copying a stock theme's surfaces.
|
||||
* contrast is free `l` is copied through, so AA margins barely move; the mix dragged
|
||||
* lightness toward the anchor and cost one export level 4.61 -> 4.40:1.
|
||||
*
|
||||
* tools/export-tier.mjs runs the AA matrix over six hues x both modes x every palette and
|
||||
* tools/a11y-gallery.mjs runs axe over the tinted gallery at two extreme hues. Change
|
||||
* --fs-tint-c and re-run both.
|
||||
*
|
||||
* The shipped chroma is deliberately tiny — the dark floor is below the chroma the dark canvas
|
||||
* already carries. This is an identity cue, not a colourway: a large flat field is where the
|
||||
* eye is most sensitive to a cast. Loud belongs in a palette, where the cards and chrome can
|
||||
* move too. */
|
||||
/* --fs-bg washed by hue tint; --fs-bg-base dodges a self-reference cycle (invalid at
|
||||
* computed-value time). docs/design-system.md "Tint and Accent". */
|
||||
:root {
|
||||
--fs-bg: var(--fs-bg-base);
|
||||
/* the UI accent, derived from the palette's own -base hex so the accent-hue axis below can
|
||||
* rotate it with no second copy to keep in sync. Absent an accent hue, --fs-accent IS the
|
||||
* palette's -base value. */
|
||||
/* derived from -base so the accent-hue axis has one copy to rotate */
|
||||
--fs-accent: var(--fs-accent-base);
|
||||
/* a tint OF --fs-accent, mixed FROM it: a restated literal goes stale the moment the accent
|
||||
* is recoloured or the accent-hue axis rotates it. LIGHT strength only — every palette's
|
||||
* light block wanted the identical 10%, so this one declaration replaces five copies; a
|
||||
* dark block still declares its own (15%, hicontrast 14%: 02-tokens.css line ~81) and wins
|
||||
* on specificity. */
|
||||
/* tint OF --fs-accent, mixed FROM it so it never goes stale. LIGHT only; each dark block
|
||||
* below overrides its own. */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
/* the three status colours, derived from the palette's -base hex for the reason the accent
|
||||
* is: an axis rotating a token the palettes declared directly would need a second copy of
|
||||
* every value to rotate from */
|
||||
/* The surfaces, split the same way and for the same reason as the accent. A palette
|
||||
* declares the raw value; every rule reads the derived one.
|
||||
*
|
||||
* Do not add a transparency axis over these: thinning the surface thins the TEXT's
|
||||
* background out from under it, and the contrast readouts cannot even report it — a ratio
|
||||
* against a semi-transparent backdrop is a ratio against whatever is behind that pixel.
|
||||
* The wallpaper axis is the honest way to have a picture. */
|
||||
/* surfaces + status: raw per palette, derived form is read everywhere else */
|
||||
--fs-panel: var(--fs-panel-base);
|
||||
--fs-panel2: var(--fs-panel2-base);
|
||||
--fs-border: var(--fs-border-base);
|
||||
--fs-good: var(--fs-good-base);
|
||||
--fs-warn: var(--fs-warn-base);
|
||||
--fs-danger: var(--fs-danger-base);
|
||||
/* The ink of a value that is NOT there — the `placeholder` attribute and the dropdown's
|
||||
* `li[placeholder]` row. LuCI writes an option's DEFAULT there (`form.Value.placeholder`:
|
||||
* 128 for the log buffer, 514 for the log port), so an ink that reads as body text tells
|
||||
* the reader a field is set when it is empty — reported against 0.14.8, forum topic 251930
|
||||
* posts 82-83. --fs-dim was that ink and is not far enough away: on footstrap light's
|
||||
* --fs-panel2 it measures 11.12:1 against the text's 14.84:1, which is two blacks.
|
||||
*
|
||||
* Derived, not hand-listed per palette, so it follows a palette that has not been written
|
||||
* yet. The formula is spelt once and the STRENGTH is a token of its own, because it is the
|
||||
* only part that varies — per mode here, and again under prefers-contrast:more
|
||||
* (theme/95-a11y-media.css); restating the whole mix in each of those four places cost 79 B
|
||||
* of shipped sheet and gave three more copies to keep in step. That is also why it lives
|
||||
* here rather than in 02-tokens.css's ladder — the same reason --fs-accent-soft does.
|
||||
*
|
||||
* Light lands at 3.99-4.48:1 across the four palettes, 43-44% of the way from the value's
|
||||
* ink to the field: under AA on purpose, see the dark half below for the whole argument.
|
||||
* oklch because the step is a LIGHTNESS one — srgb at the same 55% stops at 3.58:1. Held by
|
||||
* `npm run placeholder`. */
|
||||
/* deliberately under AA — the "nothing here" ink, held by `npm run placeholder`.
|
||||
* docs/design-system.md "The fourth ink is the one". */
|
||||
--fs-placeholder-mix: 55%;
|
||||
--fs-placeholder: color-mix(in oklch, var(--fs-text) var(--fs-placeholder-mix), var(--fs-panel2));
|
||||
/* The hues the user picked, in degrees. Declared here and not only inline by
|
||||
* head.ut/fs-prefs.js, so the stylesheet stands on its own: a var() with no definition
|
||||
* anywhere is invalid at computed-value time and takes the whole declaration down. */
|
||||
/* declared here too, so the sheet stands alone — an undefined var() invalidates the rule */
|
||||
--fs-accent-h: 0;
|
||||
--fs-tint-h: 0;
|
||||
--fs-good-h: 0;
|
||||
--fs-warn-h: 0;
|
||||
--fs-danger-h: 0;
|
||||
/* Tint strength (Appearance -> Tint strength — NOT the Density select, which is the
|
||||
* UI-density axis and owns that word): a multiplier on the tint chroma below, 1 being the
|
||||
* designed strength and 2 the cap. Set inline (value/100) by head.ut and fs-prefs.js.
|
||||
*
|
||||
* 0 is not "the palette back": the rule below is a relative colour that REPLACES chroma, so
|
||||
* a multiplier of 0 also strips the cast the canvas already had (every palette's canvas is
|
||||
* a blue-grey) and leaves it neutral at the same lightness — footstrap dark rgb(28,33,40)
|
||||
* paints as rgb(33,33,33). It cannot be made into the
|
||||
* untinted page here — a conditional would have to sit on :root, which a style query cannot
|
||||
* reach. The true off is clearing the Tint hue, which drops data-tint and this rule. */
|
||||
/* multiplier on the tint chroma below (Tint strength, not Density). docs/design-system.md
|
||||
* "Tint and Accent". */
|
||||
--fs-tint-strength: 1;
|
||||
/* The uploaded login/page background. --fs-login-bg-url is the url() head.ut and fs-prefs.js
|
||||
* stamp inline; `none` here is the stand-on-its-own default the hues carry above.
|
||||
* --fs-photo-scrim is the wash laid over the photo so on-canvas text stays legible on an
|
||||
* arbitrary image — derived from --fs-bg, so it follows palette, mode and tint, and its
|
||||
* opacity bounds the effective canvas contrast whatever the photo is. Both are inert until
|
||||
* data-login-bg is present (theme/16-login-bg.css). */
|
||||
/* inert default; head.ut/fs-prefs.js stamp the real url() inline */
|
||||
--fs-login-bg-url: none;
|
||||
/* --fs-photo-dim is the scrim opacity over a File photo, a per-browser axis like the rest.
|
||||
* The scrim is mixed from --fs-bg-BASE, not --fs-bg, so it stays neutral: the tint colours
|
||||
* --fs-bg and must never leak onto a photo. */
|
||||
/* scrim over a File photo, mixed from --fs-bg-base so tint never leaks onto it */
|
||||
--fs-photo-dim: 74%;
|
||||
--fs-photo-scrim: color-mix(in srgb, var(--fs-bg-base) var(--fs-photo-dim), transparent);
|
||||
/* The uploaded pattern, tiled behind the content by 15-wallpaper.css. --fs-pattern-url is
|
||||
* stamped inline and is `none` here for the same reason --fs-login-bg-url is. The other two
|
||||
* are ordinary per-browser axes: the tile's edge length and the layer's opacity, where .2
|
||||
* is where a neutral line reads as a drawing rather than texture (.15 is swallowed by a
|
||||
* tinted canvas). */
|
||||
/* tiled pattern axes; .2 is where a hairline reads as a drawing, not texture (.15 is swallowed) */
|
||||
--fs-pattern-url: none;
|
||||
--fs-pattern-size: 440px;
|
||||
--fs-pattern-strength: .2;
|
||||
/* How strong the cue is: a chroma, and a function of the HUE, because the canvas is not
|
||||
* neutral — every palette's canvas is a blue-grey (oklch hue 248-264) and the dark one
|
||||
* carries chroma .0153 of it. A flat chroma leaves the blue/violet end of the wheel
|
||||
* invisible, painting the canvas less blue than the palette already was. The cue is read
|
||||
* against the untinted page, so it must out-chroma the canvas's own cast where the two
|
||||
* agree: a floor plus two cos() terms, each peaking on one hue and gone 90° away.
|
||||
*
|
||||
* +boost at 258° the canvas's own hue; without it blue and violet do nothing
|
||||
* -damp at 55° red-through-yellow, which the eye picks up first, so at a shared floor
|
||||
* the warm half shouts while the cold half whispers (dark only — on a
|
||||
* near-white canvas warm is the quietest hue there is)
|
||||
*
|
||||
* One expression per mode, with no per-hue table to keep in sync. Light runs the HIGHER
|
||||
* floor: near-white has almost no chroma of its own (.0034), so the tint is the only
|
||||
* colour there. */
|
||||
/* floor + two cos() terms, not flat chroma. docs/design-system.md "Tint and Accent". */
|
||||
--fs-tint-c: calc((.016 + .012 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))) * var(--fs-tint-strength));
|
||||
}
|
||||
:root[data-darkmode="true"] {
|
||||
--fs-tint-c: calc((.011 + .016 * max(0, cos((var(--fs-tint-h) - 258) * 1deg))
|
||||
- .004 * max(0, cos((var(--fs-tint-h) - 55) * 1deg))) * var(--fs-tint-strength));
|
||||
/* 64%, against the 55% above, and the difference is what a dark palette can afford: its ink
|
||||
* starts at 6.45:1 on --fs-panel2 where the light one has 14.84:1, so the same percentage
|
||||
* would buy a hint nobody can read. It lands at 3.43-6.05:1, 33-35% of the range.
|
||||
*
|
||||
* BOTH modes ship below AA, and that is the decision this token exists to record: a hint
|
||||
* mistaken for a value makes a reader configure the wrong thing, while a hint at 3.43:1
|
||||
* makes them look twice. The AA-clearing mixes (62/80%) were measured first and moved only
|
||||
* 37% and 20% of the range — the same fault, quieter. 3:1 (SC 1.4.11, and SC 1.4.3's
|
||||
* large-text floor) is the line NOT crossed, worst 3.43:1; prefers-contrast:more buys the
|
||||
* AA ink back in both modes (theme/95-a11y-media.css); and `placeholder-ink` holds all
|
||||
* three states, axe-core being excluded from this row for the same reason. */
|
||||
/* per-mode, not per-palette — see the placeholder-mix note above */
|
||||
--fs-placeholder-mix: 64%;
|
||||
}
|
||||
:root[data-tint="hue"] { --fs-bg: oklch(from var(--fs-bg-base) l var(--fs-tint-c) var(--fs-tint-h)); }
|
||||
|
||||
/* ---- the colour axes: five colours, two ways to set each ----
|
||||
*
|
||||
* Accent, Tint, Good, Warn and Danger are one axis pointed at five tokens (fs-prefs.js's
|
||||
* colorAxis). Each is OFF (no attribute = the palette exactly as it shipped) or set one of
|
||||
* two ways, which is what the attribute's VALUE says:
|
||||
*
|
||||
* data-x="hue" the slider — oklch(from <the palette's own> l c H). Lightness and chroma
|
||||
* are the palette's, so every contrast margin the palette was measured at
|
||||
* survives the rotation and the ink does NOT need recomputing.
|
||||
* data-x="hex" the colour field — the user's colour EXACTLY, stamped inline on :root by
|
||||
* head.ut/fs-prefs.js. An inline custom property outranks every layer, so
|
||||
* the declaration is not repeated here; what IS here is the consequence.
|
||||
*
|
||||
* The ink is derived, and only in hex mode. --fs-on-accent is the ink ON the accent fill — a
|
||||
* solid button's label, the toggle knob, the active tab pill — and the palettes pick it per
|
||||
* mode against fills they were measured against. A user-entered colour is measured against
|
||||
* nothing, so keeping the palette's ink puts white on #ffd400: 1.43:1, on the one control
|
||||
* whose whole job is to be pressed.
|
||||
*
|
||||
* So in hex mode the ink follows the LIGHTNESS of whatever was entered:
|
||||
* `oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0)` is black above L .62 and white
|
||||
* below it, chroma zeroed so it is a neutral either way. The multiplier only has to be big
|
||||
* enough to saturate the clamp — there is deliberately no ramp between the two, because a
|
||||
* mid-grey ink is the one answer that fails against both.
|
||||
*
|
||||
* L .62 is where black and white cross over in sRGB (a mid grey is ~4.6:1 against each), so
|
||||
* the worse of the two is never much under AA and the better is far over it. It is derived
|
||||
* from the fill rather than picked per palette on purpose: an axis is set per BROWSER and
|
||||
* composes with either palette and either mode, so there is no block a per-palette answer
|
||||
* could live in.
|
||||
*
|
||||
* Tint has no ink row: it colours the CANVAS, whose text is --fs-text, a palette token this
|
||||
* axis must not move — which is also why the Appearance page reports the contrast a custom
|
||||
* canvas lands at instead of correcting it. */
|
||||
/* hue mode rotates via oklch(from…); hex mode is stamped inline and re-derives the ink below */
|
||||
:root[data-accent="hue"] { --fs-accent: oklch(from var(--fs-accent-base) l c var(--fs-accent-h)); }
|
||||
:root[data-good="hue"] { --fs-good: oklch(from var(--fs-good-base) l c var(--fs-good-h)); }
|
||||
:root[data-warn="hue"] { --fs-warn: oklch(from var(--fs-warn-base) l c var(--fs-warn-h)); }
|
||||
:root[data-danger="hue"] { --fs-danger: oklch(from var(--fs-danger-base) l c var(--fs-danger-h)); }
|
||||
|
||||
/* The attribute is repeated to win on specificity, which is load-bearing: a palette's dark
|
||||
* block is `:root[data-darkmode="true"]`, also (0,2,0), and it declares the ink too — so at
|
||||
* equal specificity the later rule wins and the single-attribute form loses in dark mode only
|
||||
* (a grey accent kept the dark palette's near-black ink at 1.9:1 while light derived white).
|
||||
* Repeating the attribute makes it (0,3,0) and wins in both modes, never on source order. */
|
||||
:root[data-accent="hex"][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-good="hex"][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-warn="hex"][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-danger="hex"][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
/* hex ink must outrank a NAMED palette's dark block ([data-palette=…][data-darkmode="true"],
|
||||
* (0,3,0)); triple attribute makes it (0,4,0) */
|
||||
:root[data-accent="hex"][data-accent][data-accent] { --fs-on-accent: oklch(from var(--fs-accent) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-good="hex"][data-good][data-good] { --fs-on-good: oklch(from var(--fs-good) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-warn="hex"][data-warn][data-warn] { --fs-on-warn: oklch(from var(--fs-warn) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
:root[data-danger="hex"][data-danger][data-danger] { --fs-on-danger: oklch(from var(--fs-danger) clamp(0, (l - .62) * -100, 1) 0 0); }
|
||||
|
||||
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ----
|
||||
* `[data-palette="footstrap"]` never matches — both appliers REMOVE the attribute for the
|
||||
* default rather than stamp it (fs-prefs.js listAxis, partials/head.ut:216) — and it is kept
|
||||
* anyway: dropping it leaves a bare `:root` that duplicates the derive rule above, and merging
|
||||
* those two would put one palette's raw values in the block that derives every palette's. 86 B
|
||||
* to keep the two roles apart. */
|
||||
/* ---- footstrap — GitHub Primer (DEFAULT, also fills bare :root) ---- */
|
||||
/* never matches (both appliers REMOVE it for the default) — kept so :root need not duplicate
|
||||
* the derive rule above */
|
||||
:root,
|
||||
:root[data-palette="footstrap"] {
|
||||
/* LIGHT */
|
||||
@@ -263,10 +75,8 @@
|
||||
--fs-panel2-base: #f6f8fa;
|
||||
--fs-border-base: #d0d7de;
|
||||
--fs-text: #1f2328;
|
||||
/* The secondary tier carries LuCI's page descriptions and every field title, which at the
|
||||
* GitHub-light greys (5.2:1 and 4.9:1 on the card) is help text a reader has to lean into.
|
||||
* Now 11.8:1 and 10.0:1, still a tier below the 15.8:1 body ink so a label stays a
|
||||
* label. */
|
||||
/* raised past AA — GitHub-light greys read as 5.2/4.9:1 on the card (page descriptions,
|
||||
* field titles), now 11.8/10.0:1 */
|
||||
--fs-dim: #33383d;
|
||||
--fs-faint: #3d4348;
|
||||
--fs-accent-base: #0969da;
|
||||
@@ -274,7 +84,10 @@
|
||||
--fs-good-base: #197c36;
|
||||
--fs-warn-base: #946300;
|
||||
--fs-danger-base: #cf222e;
|
||||
/* ink ON the fills above — white clears AA on all four: 5.19/5.28/5.19/5.36 */
|
||||
/* ink ON the fills above — white clears AA on all four: 5.19/5.28/5.19/5.36. --fs-panel-base
|
||||
* and these four inks are the same #fff in every light palette below, so THIS is their
|
||||
* only definition — hicontrast/bootstrap/2020/forum's light blocks inherit them, unset.
|
||||
* docs/design-system.md "Adding a palette". */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
@@ -296,30 +109,21 @@
|
||||
--fs-good-base: #5aad5d;
|
||||
--fs-warn-base: #c79128;
|
||||
--fs-danger-base: #ea7a74;
|
||||
/* dark ink: these fills are LIGHT. White measures 2.79/2.77/2.80/2.79 — a fail on
|
||||
* all four. #10151c (a shade of --fs-bg) gives 6.58/6.61/6.55/6.57. */
|
||||
/* dark ink: fills are light; white fails all four (2.79 worst), #10151c (a shade of
|
||||
* --fs-bg) clears them (6.55 worst) */
|
||||
--fs-on-accent: #10151c;
|
||||
--fs-on-good: #10151c;
|
||||
--fs-on-warn: #10151c;
|
||||
--fs-on-danger: #10151c;
|
||||
}
|
||||
|
||||
/* ---- hicontrast — deeper, more saturated (opt-in) ---- */
|
||||
/* ---- hicontrast — deeper, more saturated (opt-in); docs/design-system.md "Palettes" ---- */
|
||||
:root[data-palette="hicontrast"] {
|
||||
/* LIGHT
|
||||
* The four accents below are DARKER than the default palette's, not brighter: brighter
|
||||
* made the palette named "hicontrast" the lower-contrast one — --fs-good #17b978 as a
|
||||
* label on --fs-panel measured 2.55:1 against the default's 5.08:1. They serve both as
|
||||
* text on --fs-panel (status labels, outline buttons, port speeds) and as fills under
|
||||
* white ink, and both uses want >=4.5:1 against white. At 5.22/5.35/5.93/5.80 they are
|
||||
* genuinely above the default palette, which is what the name promises. */
|
||||
/* LIGHT — accents DARKER than the default's on purpose: 5.22/5.35/5.93/5.80 vs its 5.08. */
|
||||
--fs-bg-base: #eef1f6;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f6f8fb;
|
||||
--fs-border-base: #e4e9f1;
|
||||
--fs-text: #101725;
|
||||
/* …and the secondary tier moves with it, the promise above being relative: 13.5:1 and
|
||||
* 12.0:1 against the default palette's 11.8 and 10.0 */
|
||||
--fs-dim: #272f3d;
|
||||
--fs-faint: #2e3745;
|
||||
--fs-accent-base: #0b6fbd;
|
||||
@@ -327,11 +131,6 @@
|
||||
--fs-good-base: #0f7a52;
|
||||
--fs-warn-base: #8a5a00;
|
||||
--fs-danger-base: #c02b2b;
|
||||
/* white on the fills above: 5.22/5.35/5.93/5.80 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="hicontrast"][data-darkmode="true"] {
|
||||
/* DARK */
|
||||
@@ -348,50 +147,21 @@
|
||||
--fs-good-base: #43e0a0;
|
||||
--fs-warn-base: #ffb454;
|
||||
--fs-danger-base: #ff6b6b;
|
||||
/* dark ink: white on these neons measures 2.57/1.69/1.76/2.78, while #0a0d13 (= --fs-bg-base)
|
||||
* gives 7.55/11.49/11.03/7.01 */
|
||||
/* dark ink: white on these neons fails (2.57 worst), #0a0d13 (=--fs-bg-base) clears
|
||||
* (7.01 worst) */
|
||||
--fs-on-accent: #0a0d13;
|
||||
--fs-on-good: #0a0d13;
|
||||
--fs-on-warn: #0a0d13;
|
||||
--fs-on-danger: #0a0d13;
|
||||
}
|
||||
|
||||
/* ---- bootstrap — the colours of luci-theme-bootstrap, the LuCI default (opt-in) ----
|
||||
*
|
||||
* The surfaces, greys and semantic colours of the stock theme, so an admin who wants the look
|
||||
* they already know keeps it and still gets the chrome, the client navigation and the axes.
|
||||
*
|
||||
* Bootstrap computes everything from HSL axes; these are those axes evaluated:
|
||||
* --background-color high/medium/low #fff / #f9f9f9 / #f5f5f5 (dark #222 / #282828 / #2c2c2c)
|
||||
* --text-color high/medium #404040 / #808080 (dark #bfbfbf / #7f7f7f)
|
||||
* --border-color high/medium #ccc / #ddd (dark #555 / #444)
|
||||
* The canvas and the card are both --background-color-high: bootstrap is flat and lets the
|
||||
* border carry the structure. panel2 is its --background-color-low, the surface it stripes
|
||||
* tables and hovers rows with.
|
||||
*
|
||||
* Where this deviates: bootstrap's semantic colours do not clear AA on bootstrap's own
|
||||
* surfaces (on --fs-panel2, success 2.73:1, warn 1.61:1, error 3.65:1, primary 4.22:1; in dark
|
||||
* error 3.44:1 and success 4.44:1), and
|
||||
* every palette here is held to 4.5:1 on all three because the export tier is what other
|
||||
* people's apps print text in. So each is taken from the step of bootstrap's own ramp that
|
||||
* clears it where one exists, and otherwise keeps hue and saturation while the value moves
|
||||
* until it clears 4.95:1. Warn is the only large move: no yellow readable as text on white is
|
||||
* still that yellow.
|
||||
*
|
||||
* Bootstrap's dark header gradient is not here: the chrome reads --fs-panel, so a palette
|
||||
* cannot give the bar its own colour — that would need a chrome token pair, which is a change
|
||||
* to the chrome, not a colourway. */
|
||||
/* ---- bootstrap — luci-theme-bootstrap's own colours (opt-in). docs/design-system.md
|
||||
* "Palettes" ("bootstrap"). ---- */
|
||||
:root[data-palette="bootstrap"] {
|
||||
/* LIGHT */
|
||||
/* LIGHT — white ink on the fills below clears AA on all four: 5.81/5.54/5.40/5.70. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f5f5f5;
|
||||
--fs-border-base: #ccc;
|
||||
/* The ink is the one place this colourway leaves bootstrap's own values behind, the same
|
||||
* decision the default palette made: its #404040 body and #6a6a6a muted tier measure
|
||||
* 10.4:1 and 5.4:1 on white, and the muted one carries every field title. Matched to the
|
||||
* default palette's light ramp ratio for ratio (15.7 / 11.7 / 10.0), in neutral greys, so
|
||||
* what is copied is the hue and not the readability. */
|
||||
--fs-text: #232323;
|
||||
--fs-dim: #383838;
|
||||
--fs-faint: #424242;
|
||||
@@ -401,30 +171,16 @@
|
||||
--fs-good-base: #007936; /* --success-color-low */
|
||||
--fs-warn-base: #826706; /* #efbd0b darkened; 1.61:1 as text is not a rounding error */
|
||||
--fs-danger-base: #d00000; /* --error-color-low */
|
||||
/* ink ON the fills above — white clears AA on all four: 5.81/5.54/5.40/5.70 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="bootstrap"][data-darkmode="true"] {
|
||||
/* DARK */
|
||||
--fs-bg-base: #222;
|
||||
--fs-panel-base: #282828;
|
||||
/* bootstrap's own dark --background-color-low (#2c2c2c) sits 0.016 from --fs-panel, under
|
||||
* the 0.02 the ramp check wants, so an app asking high/-low for a gradation gets one colour
|
||||
* twice; #303030 is 0.031. Its light pair is 0.039 apart, so the same check catches this in
|
||||
* the theme this palette is named after. */
|
||||
/* raised past bootstrap's own value: docs/design-system.md "Palettes" ("bootstrap") */
|
||||
--fs-panel2-base: #303030;
|
||||
--fs-border-base: #444;
|
||||
--fs-text: #bfbfbf;
|
||||
/* Bootstrap's dark has no third ink, so the two quiet tiers are this palette's own and AA
|
||||
* decides where the fainter one sits: the export tier's -low follows --fs-faint, and on a
|
||||
* tinted --fs-panel2 #969696 measures 4.46:1, under the floor for a name apps print text
|
||||
* in. #9a9a9a is the first step that clears it, so the floor pins --fs-faint and the step
|
||||
* between the tiers comes from above (--fs-dim moves up by the 12 channel steps the
|
||||
* hicontrast dark ramp uses). Both are already a deviation, and one step more is cheaper
|
||||
* than collapsing --text-color-medium and -low onto one colour. */
|
||||
/* --fs-dim/--fs-faint construction, not bootstrap's own — see the panel2 note above */
|
||||
--fs-dim: #a6a6a6;
|
||||
--fs-faint: #9a9a9a;
|
||||
--fs-accent-base: #53a4c2;
|
||||
@@ -433,33 +189,18 @@
|
||||
--fs-good-base: #1aaf7b;
|
||||
--fs-warn-base: #aa9968;
|
||||
--fs-danger-base: #dd807e;
|
||||
/* dark ink: these fills are light, and bootstrap's own --on-*-color in dark is likewise its
|
||||
* --background-color-high. #222 gives 5.65/5.65/5.66/5.65. */
|
||||
/* dark ink: light fills, dark ink — matches bootstrap's own --on-*-color; #222 gives
|
||||
* 5.65 on all four */
|
||||
--fs-on-accent: #222;
|
||||
--fs-on-good: #222;
|
||||
--fs-on-warn: #222;
|
||||
--fs-on-danger: #222;
|
||||
}
|
||||
/* ---- 2020 — the OpenWrt 2020 theme's colourway ----
|
||||
*
|
||||
* `luci-theme-openwrt-2020` is one colour scheme and no dark mode: the OpenWrt CI cyan
|
||||
* (#00B5E2) on the deep navy (#002B49), white content, and three semantic colours. This
|
||||
* palette carries that identity onto footstrap's surfaces.
|
||||
*
|
||||
* The cyan is the whole identity and is unreadable as text on white (2.09:1 against the 4.5:1
|
||||
* the export tier wants), so the LIGHT mode keeps the hue and darkens it while the DARK mode,
|
||||
* whose surfaces are the navy the cyan was designed against, carries it near its own value.
|
||||
* That split is the point of the pair.
|
||||
*
|
||||
* The three semantic colours get the same treatment: on white, 2020's green is 2.28:1 and its
|
||||
* amber 3.42:1, while its red clears AA at 5.25:1 and is the one kept close to the original.
|
||||
*
|
||||
* The navy is not the chrome here. In 2020 it is the header and the sidebar, but the chrome
|
||||
* reads --fs-panel, so in dark mode the navy is the CANVAS and the chrome sits on it. */
|
||||
/* ---- 2020 — the OpenWrt 2020 theme's colourway (opt-in), split into a pair.
|
||||
* docs/design-system.md "Palettes" ("2020"). ---- */
|
||||
:root[data-palette="2020"] {
|
||||
/* LIGHT — white content, the CI cyan darkened until it can be read on it */
|
||||
/* LIGHT — white content, the CI cyan darkened until it can be read on it. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #eef4f8;
|
||||
--fs-border-base: #c3d2dd;
|
||||
/* navy-tinted neutrals rather than grey: the ink belongs to the same scheme as the accent */
|
||||
@@ -472,11 +213,6 @@
|
||||
--fs-good-base: #276e2a; /* #5CB85C darkened; 2.28:1 as text is not a rounding error */
|
||||
--fs-warn-base: #8a5a00; /* #C80, same move */
|
||||
--fs-danger-base: #c1121f; /* #C11 is 5.25:1 already — the one that stays where it was */
|
||||
/* ink ON the fills above — white clears AA on all four */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="2020"][data-darkmode="true"] {
|
||||
/* DARK — 2020's own scheme: the navy is the canvas and the cyan is nearly untouched */
|
||||
@@ -500,55 +236,24 @@
|
||||
--fs-on-warn: #002b49;
|
||||
--fs-on-danger: #002b49;
|
||||
}
|
||||
/* ---- forum — the OpenWrt forum's Discourse colourway (opt-in) ----
|
||||
*
|
||||
* Sampled from forum.openwrt.org's own `getComputedStyle(document.documentElement)`, one read
|
||||
* per colour scheme: LIGHT --primary #222 (ink) / --secondary #fff (page) / --tertiary #08c
|
||||
* (accent) / --quaternary and --danger both #e45735 / --success #090 / --highlight #ffff4d;
|
||||
* DARK --primary #ddd / --secondary #222 / --tertiary #0f82af / --danger #e45735 (unchanged
|
||||
* from light) / --success #1ca551 / --highlight #a87137. Discourse names quaternary and
|
||||
* danger as one colour in light and two in dark (#c14924); footstrap has no fifth semantic
|
||||
* slot, so the dark quaternary is not carried — --fs-danger reads the site's own --danger.
|
||||
*
|
||||
* The header bar's navy (rgb(0,43,73) = #002b49) is not here: it is the forum's masthead, not
|
||||
* its dark canvas — --secondary is #222 in BOTH the forum's own light and dark schemes (ink
|
||||
* and page swap places, the page itself never turns navy). 2020's dark canvas is navy because
|
||||
* that theme has no dark scheme of its own to sample; forum does, and it says #222. The chrome
|
||||
* reads --fs-panel regardless (2020's own note, above), so the bar could not carry it anyway.
|
||||
*
|
||||
* None of the six sampled colours clears 4.5:1 as TEXT on the surface its own scheme pairs it
|
||||
* with — Discourse checks contrast for its own use (a filled pill, an icon), not for the
|
||||
* export tier every app prints text in — so every one of them is moved the way 2020's cyan
|
||||
* was: hue and saturation held, lightness walked until the ratio clears. --fs-highlight has no
|
||||
* footstrap analogue (it marks a search hit, not a status) and the sampled #ffff4d is a
|
||||
* background wash — 1.07:1 on white — so it fills the one status slot forum's own scheme has
|
||||
* no colour for, --fs-warn, exactly as its LIGHTNESS says: the brightest of the four before the
|
||||
* move, still the one furthest from AA after it. */
|
||||
/* ---- forum — sampled from forum.openwrt.org's own getComputedStyle (opt-in).
|
||||
* docs/design-system.md "Palettes" ("forum"). ---- */
|
||||
:root[data-palette="forum"] {
|
||||
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink */
|
||||
/* LIGHT — the forum's own page, exactly: white canvas, near-black ink. white on the
|
||||
* fills below: 5.19/5.14/5.17/5.17. */
|
||||
--fs-bg-base: #fff;
|
||||
--fs-panel-base: #fff;
|
||||
--fs-panel2-base: #f0f0f0;
|
||||
--fs-border-base: #ddd;
|
||||
--fs-text: #222;
|
||||
--fs-dim: #3d3d3d;
|
||||
--fs-faint: #4a4a4a;
|
||||
/* --tertiary #08c measures 3.89:1 on white; #0074ad held the hue at 5.12:1 there but
|
||||
* only 4.49:1 on --fs-panel2, under the 4.5:1 floor the export tier holds every surface to;
|
||||
* #0073ac holds it at 5.19:1 on white and 4.55:1 on --fs-panel2 */
|
||||
/* sampled #08c 3.89:1/4.49:1 (panel2 fails the 4.5:1 floor); held hue to 5.19:1/4.55:1 */
|
||||
--fs-accent-base: #0073ac;
|
||||
--fs-track: #eee;
|
||||
/* --success #090 measured 3.78:1, #008000 5.14:1; --quaternary/--danger #e45735
|
||||
* measured 3.68:1, #c63c1b 5.17:1; --highlight #ffff4d measured 1.07:1 as text, #717100
|
||||
* 5.17:1 — see the block comment above for why highlight fills warn */
|
||||
/* sampled #090 3.78:1; #ffff4d (highlight, fills warn) 1.07:1 as text */
|
||||
--fs-good-base: #008000;
|
||||
--fs-warn-base: #717100;
|
||||
--fs-danger-base: #c63c1b;
|
||||
/* white on the fills above: 5.19/5.14/5.17/5.17 */
|
||||
--fs-on-accent: #fff;
|
||||
--fs-on-good: #fff;
|
||||
--fs-on-warn: #fff;
|
||||
--fs-on-danger: #fff;
|
||||
}
|
||||
:root[data-palette="forum"][data-darkmode="true"] {
|
||||
/* DARK — the forum's own dark scheme: #222 canvas, #ddd ink, both exactly as sampled */
|
||||
@@ -559,15 +264,10 @@
|
||||
--fs-text: #ddd;
|
||||
--fs-dim: #a3a3a3;
|
||||
--fs-faint: #969696;
|
||||
/* --tertiary #0f82af measured 3.66:1 on --fs-bg and 3.12:1 on --fs-panel2, both under the
|
||||
* 4.5:1 the export tier wants for a colour printed as text; #13a8e3 holds the hue at
|
||||
* 5.86:1 on --fs-bg and 5.00:1 on --fs-panel2 */
|
||||
/* sampled #0f82af 3.66:1/3.12:1 on bg/panel2, under 4.5:1; held the hue to 5.86:1/5.00:1 */
|
||||
--fs-accent-base: #13a8e3;
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
--fs-track: #333;
|
||||
/* --success #1ca551 measured 4.24:1 on --fs-panel2, #1fb65a 5.11:1; --danger #e45735
|
||||
* measured 3.69:1, #eb8066 5.05:1; --highlight #a87137 measured 3.29:1, #c99259 5.00:1 —
|
||||
* same reasoning as the light block for why highlight fills warn */
|
||||
--fs-good-base: #1fb65a;
|
||||
--fs-warn-base: #c99259;
|
||||
--fs-danger-base: #eb8066;
|
||||
@@ -576,12 +276,7 @@
|
||||
--fs-on-good: #222;
|
||||
--fs-on-warn: #222;
|
||||
--fs-on-danger: #222;
|
||||
/* The masthead, and the one place the forum's navy belongs. rgb(0,43,73) is what
|
||||
forum.openwrt.org paints its header with in its DARK scheme — measured live; its light
|
||||
scheme has a white header (--header_background #fff, ink #333), which is what this
|
||||
palette's light half already gives, so the navy is declared here and only here.
|
||||
A flat fill, like --fs-bar-bg everywhere (02-tokens.css): the bar's own ink (#ddd)
|
||||
measures 10.7:1 on it and its dimmed ink (#a3a3a3) 5.8:1. */
|
||||
/* overrides --fs-bar-bg: forum's own masthead navy, measured live */
|
||||
--fs-bar-bg: #002b49;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,35 @@
|
||||
@layer tokens {
|
||||
/* Static twins for every token whose value is a color-mix(), for a browser that has none.
|
||||
*
|
||||
* Last in the tokens layer on purpose: it has to outrank 02-tokens.css AND the per-palette
|
||||
* definitions in 03-palettes.css, and directory order is source order (build-css.sh).
|
||||
*
|
||||
* Why a whole file rather than a fallback declaration in front of each color-mix(): a custom
|
||||
* property keeps whatever token stream it is given, so the failure is deferred to the point of USE
|
||||
* — `background: var(--fs-good-soft)` becomes invalid at computed-value time and computes to
|
||||
* `unset`, which is `initial` for a non-inherited property. IACVT does not fall back to an earlier
|
||||
* declaration in the same block, so the two-declaration trick that works for `background:
|
||||
* color-mix(…)` written literally does nothing for a token. 36 tokens are affected; without this
|
||||
* block a browser below the floor paints borders in currentcolor and loses every soft surface.
|
||||
*
|
||||
* color-mix() lands in Chrome 111, Firefox 113 and Safari 16.2 (all 2022-2023). See docs/css.md
|
||||
* for the theme's browser floor and which feature sets it.
|
||||
*
|
||||
* The rules: a tint over transparent degrades to `transparent` — an absent surface never lowers
|
||||
* contrast, while a solid version of the same colour under its own text does. A hairline degrades
|
||||
* to the solid colour it is a fraction of. A focus ring degrades to the -solo ring, which is
|
||||
* already color-mix-free, because an invisible focus ring is an accessibility regression rather
|
||||
* than a cosmetic one. */
|
||||
/* Static twin for each of the 33 color-mix() tokens, below Chrome 111 / Firefox 113 / Safari
|
||||
* 16.2. Last in the tokens layer, to outrank 02-tokens.css and 03-palettes.css. A fallback
|
||||
* declaration cannot do this job — a custom property fails at the point of USE, not of
|
||||
* definition. Degradation rules: docs/css.md "The browser floor". */
|
||||
@supports not (color: color-mix(in srgb, red 50%, transparent)) {
|
||||
:root {
|
||||
/* tints: absent, not solid */
|
||||
--fs-accent-soft: transparent;
|
||||
--fs-good-soft: transparent;
|
||||
--fs-warn-soft: transparent;
|
||||
--fs-danger-soft: transparent;
|
||||
--fs-accent-fill: transparent;
|
||||
--fs-good-fill: transparent;
|
||||
--fs-warn-fill: transparent;
|
||||
--fs-danger-fill: transparent;
|
||||
|
||||
/* hairlines: the solid colour they are a fraction of */
|
||||
--fs-accent-line: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good);
|
||||
--fs-warn-line: var(--fs-warn);
|
||||
--fs-danger-line: var(--fs-danger);
|
||||
--fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line-hi: var(--fs-good);
|
||||
--fs-warn-line-hi: var(--fs-warn);
|
||||
--fs-danger-line-hi: var(--fs-danger);
|
||||
|
||||
/* the two rings, restated: -soft and -fill are transparent above, and a ring nobody can
|
||||
* see is worse than a heavy one. -solo is already color-mix-free and measured. */
|
||||
/* -solo is already color-mix-free */
|
||||
--fs-focus-ring: var(--fs-focus-ring-solo);
|
||||
--fs-focus-ring-invalid: 0 0 0 2px var(--fs-panel), 0 0 0 5px var(--fs-danger);
|
||||
|
||||
--background-color-medium: var(--fs-panel2);
|
||||
|
||||
/* the scrim over an uploaded photo: --fs-photo-dim is a percentage this cannot apply, so
|
||||
* take the fixed dialog scrim. Losing it entirely would leave login text over a photo. */
|
||||
/* --fs-photo-dim is a percentage this cannot apply; the fixed dialog scrim beats no scrim */
|
||||
--fs-photo-scrim: var(--fs-scrim);
|
||||
|
||||
/* placeholder ink: the quietest ink the palette states outright. Below the floor the
|
||||
* hint is back to reading like a value, which is the pre-0.14.9 look and still the
|
||||
* safer degradation — an unresolvable token takes `color` down to `unset`, and an
|
||||
* inherited near-black placeholder over a light field beats an unstyled one. */
|
||||
/* an unresolved token computes `color` to unset; near-black over a light field beats that */
|
||||
--fs-placeholder: var(--fs-faint);
|
||||
|
||||
/* export tier — third-party apps read these, so they must resolve to something.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@layer base {
|
||||
/* Element reset (Meyer, trimmed to what a LuCI view can emit). */
|
||||
|
||||
/* scroll-margin-top: an anchor jump must clear the sticky bar or it lands underneath it
|
||||
* (WCAG 2.2 SC 2.4.11). The token, never a literal — the bar is 46px. */
|
||||
/* scroll-margin-top: anchor jump must clear the sticky bar (WCAG 2.2 SC 2.4.11), token not a
|
||||
* literal — bar is 46px. */
|
||||
*, *::before, *::after {
|
||||
scroll-margin-top: var(--fs-bar-h);
|
||||
margin: 0;
|
||||
@@ -22,16 +22,8 @@
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* `hidden` is how a VIEW says an element is not there, and the only way available to code that
|
||||
* ships no stylesheet. The UA gives it `display: none` at the weakest possible strength, so any
|
||||
* `display` a theme sets on a class beats it and the element paints anyway: measured here on
|
||||
* `.tr` (table-row), `.td` (table-cell), `ul.nav > li` (block), `.cbi-page-actions` (flex) and
|
||||
* `.ifacebox` (inline-flex).
|
||||
*
|
||||
* The flag is the point, not a workaround for a layer mistake: this must outrank every display
|
||||
* in tokens/base/theme/page at once, including the id-specific ones in the page layer, and it
|
||||
* has to keep doing so for rules not written yet. `until-found` is excluded
|
||||
* because there `display: none` would break the find-in-page reveal the value exists for. */
|
||||
/* Outranks every layer's `display`, id rules included: docs/css.md "Reset: [hidden] must
|
||||
* outrank every layer, not patch one mistake". */
|
||||
[hidden]:not([hidden="until-found"]) { display: none !important; }
|
||||
|
||||
html {
|
||||
@@ -39,9 +31,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* :focus-visible, not :focus — the ring is for the keyboard, and the UA heuristic keeps it off a
|
||||
* mouse click. A 1px dotted outline is near-invisible on a dark panel, and it was the only focus
|
||||
* indicator generic links had. */
|
||||
/* :focus-visible: UA heuristic keeps the ring off a mouse click. */
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
outline-offset: 2px;
|
||||
@@ -62,7 +52,7 @@
|
||||
bottom: -.25em;
|
||||
}
|
||||
|
||||
/* font-size: theme/60-inputs.css (the control-typography rule + `option`) — absorbed */
|
||||
/* font-size: theme/60-inputs.css (control-typography rule + option) — absorbed */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
@@ -85,9 +75,7 @@
|
||||
/* disabled-opacity fade: 30-forms.css, same elements + pointer-events/cursor */
|
||||
|
||||
input[type="search"] {
|
||||
appearance: textfield;
|
||||
/* no box-sizing: content-box — it contradicts the universal border-box above, and the
|
||||
* theme sizes fields by padding, so a search box comes out ~22px too wide */
|
||||
appearance: textfield; /* not content-box: contradicts border-box above, ~22px too wide */
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-decoration {
|
||||
@@ -100,14 +88,8 @@
|
||||
font-family: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* LuCI's .control-group is bootstrap's JOINED input-group: the trailing button pulled back over
|
||||
* the input so their borders coincided, with its left corners squared to match. This theme does
|
||||
* not join controls — every button takes the full token radius from a later layer, so the
|
||||
* squared corners never apply while the pull-back still does and the button's rounded corner
|
||||
* sits on the input's (issue #7). Separate controls with a real gap instead.
|
||||
*
|
||||
* The password reveal is the one group that IS joined, and theme/60-inputs.css builds that seam
|
||||
* itself (one border around the group, `gap: 0`). */
|
||||
/* Unjoined (issue #7): bootstrap's joins square the trailing button's corner; this theme's
|
||||
* buttons keep full radius. Password reveal stays joined (theme/60-inputs.css). */
|
||||
.control-group {
|
||||
display: inline-flex;
|
||||
gap: var(--fs-space-2);
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
@layer base {
|
||||
/* The body box, links, and the type scale a view inherits from plain markup.
|
||||
* body background/padding/font-family: theme/20-shell.css — absorbed.
|
||||
* Every size and gutter here reads the scale in 02-tokens.css rather than stating a literal. */
|
||||
/* body/link box + the type scale a view inherits. background/padding/font-family:
|
||||
* theme/20-shell.css — absorbed. */
|
||||
body {
|
||||
font-size: var(--fs-type);
|
||||
font-weight: var(--fs-weight-normal);
|
||||
/* unitless: it inherits as a FACTOR, so a 20px heading gets 30px of leading instead of the
|
||||
* flat length that would clip it */
|
||||
line-height: var(--fs-leading);
|
||||
line-height: var(--fs-leading); /* unitless: inherits as a FACTOR, not a clipping flat length */
|
||||
color: var(--fs-text);
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gutter, not `overflow-y: scroll`: the same no-reflow guarantee without a dead scrollbar
|
||||
* down every short page */
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-gutter: stable; /* no-reflow guarantee without a dead scrollbar on a short page */
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -135,32 +130,24 @@
|
||||
}
|
||||
|
||||
code {
|
||||
/* panel2 (6.45:1), not a border-level surface: --fs-text on the latter is 4.40:1 in dark,
|
||||
* under AA */
|
||||
/* panel2 (6.45:1): --fs-text on a border-level surface is 4.40:1 in dark, under AA */
|
||||
background-color: var(--fs-panel2);
|
||||
color: var(--fs-text);
|
||||
padding: 1px var(--fs-space-1);
|
||||
/* task 0162: a WireGuard public key (`admin/status/wireguard`, one 44-char base64 run, no
|
||||
* space or hyphen anywhere in it) sits inside an inline `<code>` with no width of its own —
|
||||
* `white-space` stays `normal` (inherited), so the run never finds a space to wrap at and
|
||||
* measured 64px past the content column at 320px. `overflow-wrap: anywhere`, the same
|
||||
* escape hatch every other opaque, user-supplied token in this sheet already carries
|
||||
* (a MAC in pages/60-assoclist.css, a hostname in pages/10-login.css): it only ever fires
|
||||
* once a line would otherwise overflow, so a short inline snippet that already fits wraps
|
||||
* nowhere differently. */
|
||||
/* docs/css.md "Typography and widgets: an opaque token needs overflow-wrap: anywhere, a
|
||||
* tooltip needs pre-wrap" — a WireGuard key measured 64px past the column at 320px. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* border-radius: the shared `code, pre` rule above already sets it — a second copy of the
|
||||
* same value here restates, not overrides. */
|
||||
pre {
|
||||
--border-color: color-mix(in srgb, var(--fs-border) 15%, transparent);
|
||||
|
||||
background-color: var(--fs-panel2);
|
||||
display: block;
|
||||
padding: var(--fs-space-2);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-border) 15%, transparent);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
@layer base {
|
||||
/* Forms: input types, the .cbi-value row layout, and the focus/disabled/
|
||||
* readonly/invalid states shared by every CBI widget. */
|
||||
/* Forms: input types, .cbi-value row layout, focus/disabled/readonly/invalid states. */
|
||||
fieldset {
|
||||
margin-bottom: var(--fs-space-2);
|
||||
padding-top: var(--fs-space-2);
|
||||
}
|
||||
|
||||
/* size: theme/30-tables.css (`legend`) — absorbed */
|
||||
/* size: theme/30-tables.css (legend) — absorbed */
|
||||
fieldset legend {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
@@ -14,11 +13,8 @@
|
||||
padding-top: var(--fs-space-5);
|
||||
}
|
||||
|
||||
/* Control typography stays in BASE: moved to the theme layer, a bare-element rule out-ranks
|
||||
* every classed base refinement regardless of specificity, and the firewall's zone badges grew
|
||||
* from 10.8 to 13px. In base, classed rules beat it on specificity, which is the contract they
|
||||
* were written against. `select` is not a member — its face, size and leading live in
|
||||
* 60-inputs.css. */
|
||||
/* Stays in BASE: docs/css.md "Forms: control typography stays in base, on purpose". select is
|
||||
* excluded — face/size/leading live in theme/60-inputs.css. */
|
||||
label,
|
||||
input,
|
||||
button {
|
||||
@@ -36,45 +32,18 @@
|
||||
}
|
||||
|
||||
.cbi-value-field {
|
||||
/* logical, not margin-left: this is the gap AFTER the label column, and LuCI ships four RTL
|
||||
* languages (ar/fa/he/ur). Identical to margin-left while nothing in openwrt/luci stamps
|
||||
* dir=rtl, so it costs nothing and is already correct if one ever does. */
|
||||
margin-inline-start: var(--fs-space-5);
|
||||
margin-inline-start: var(--fs-space-5); /* logical: LuCI ships four RTL languages */
|
||||
flex: 1;
|
||||
/* a flex item will not shrink below min-content by default, so a nested table or one long
|
||||
* unbreakable value pushes the whole row out of the card */
|
||||
min-width: 0;
|
||||
min-width: 0; /* else a nested table or one long value pushes the row out of the card */
|
||||
}
|
||||
|
||||
/* size + ink: theme/60-inputs.css (`.cbi-value-title`) — absorbed. Left: the label
|
||||
* column's geometry, which theme does not touch. */
|
||||
/* size + ink: theme/60-inputs.css (.cbi-value-title) — absorbed. Left: label column geometry. */
|
||||
.cbi-value label.cbi-value-title {
|
||||
/* the nudge that lands the label's first baseline on the field's, tuned for the unitless
|
||||
* leading in 20-typography.css */
|
||||
padding-top: var(--fs-space-1);
|
||||
/* 180px is a column WIDTH, not a rhythm — what fits LuCI's longest option labels beside a
|
||||
* field — so it is deliberately not on the 4px step scale */
|
||||
flex: 0 0 180px;
|
||||
/* `end`, not `right`: the label is aligned TOWARDS its field, which is a logical intent.
|
||||
* Not the same as base/95-luci.css's `.left/.right/.center`, which are LuCI's forcing
|
||||
* utilities — a class named `right` means right in any direction. Never sweep those onto
|
||||
* start/end. */
|
||||
text-align: end;
|
||||
/* theme/90-responsive.css restates `flex: 1 1 100%` under 767px (label above field), and a
|
||||
* PERCENTAGE flex-basis is still a "specified size suggestion" only once it is smaller than
|
||||
* the automatic (content) minimum — with no `min-width` stated there or here, a label whose
|
||||
* text has too few break points renders at its own min-content instead of the row's width, a
|
||||
* flex item's automatic minimum size doing exactly what it always does. Sweep-measured on
|
||||
* ssclash's settings form at 320px: a Russian label rendered 320px wide inside a 254-276px
|
||||
* row (Compact/Normal/Large), the section clipped 33-50px with no scroller to reach the rest,
|
||||
* 0 with the English string in the same box. Reproduced locally the same shape with a
|
||||
* synthetic unbroken run in the gallery's own fixture (docs/gallery.html) — real RU option
|
||||
* text with normal word breaks does not need to hit this floor at all once the item can
|
||||
* shrink to the row's own width instead of its content's.
|
||||
*
|
||||
* `min-width: 0` here, not in theme/90-responsive.css: that file restates `flex`, never
|
||||
* `min-width`, so a value stated on the desktop rule survives the later layer's flex-basis
|
||||
* change untouched — the cascade only overrides the PROPERTIES a later rule actually sets. */
|
||||
padding-top: var(--fs-space-1); /* lands the label's first baseline on the field's */
|
||||
flex: 0 0 180px; /* column WIDTH (longest option label), not on the 4px step scale */
|
||||
text-align: end; /* towards the field — not base/95-luci.css's physical .left/.right/.center */
|
||||
/* docs/css.md "Forms: the label column's min-width: 0 survives the responsive layer" —
|
||||
* stated here, not in theme/90-responsive.css. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -93,34 +62,28 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The shared field box (the 210px x control-height default, #localtime's widening, and the
|
||||
* per-widget escapes: file, button types, checkbox/radio, .cbi-select, readonly, the
|
||||
* cell-filling widths) lives in theme/60-inputs.css — absorbed whole. The escapes had to move
|
||||
* with the box: each beat it here in-layer, and left behind they would lose to a theme rule by
|
||||
* layer whatever their specificity. */
|
||||
/* Shared field box (210px x control-height, #localtime widening, per-widget escapes):
|
||||
* theme/60-inputs.css — absorbed whole, in-layer, or it loses to a theme rule by layer. */
|
||||
|
||||
.cbi-dynlist {
|
||||
height: auto;
|
||||
/* min-height: theme/60-inputs.css — absorbed */
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* item paint: theme/60-inputs.css — absorbed. Left: the drag-and-drop machinery
|
||||
* UIDynamicList needs. */
|
||||
/* item paint: theme/60-inputs.css — absorbed. Left: drag-and-drop machinery (UIDynamicList). */
|
||||
.cbi-dynlist > .item {
|
||||
margin-bottom: var(--fs-space-1);
|
||||
padding: var(--fs-space-1) 2em var(--fs-space-1) var(--fs-space-2);
|
||||
position: relative;
|
||||
pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
cursor: move; /* drag-and-drop */
|
||||
user-select: text; /* text selection in drag-and-drop */
|
||||
cursor: move;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* display/padding/border + radius + × ink: theme/60-inputs.css (the bare `.item::after`
|
||||
* plate + its dynlist refinement) — absorbed. Left: what pins the plate to the chip. */
|
||||
/* plate paint: theme/60-inputs.css — absorbed. Left: pins the × to the chip. */
|
||||
.cbi-dynlist > .item::after {
|
||||
content: "×";
|
||||
position: absolute;
|
||||
@@ -132,17 +95,14 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .item.drag-over {
|
||||
.cbi-dynlist > .item.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
/* Make item being dragged in UIDynamicList partially transparent*/
|
||||
.cbi-dynlist > .item.dragging {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* prevent pointer changing when over the span element in UIDynamicList */
|
||||
.cbi-dynlist > .item > span {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -151,12 +111,10 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
|
||||
/* indication line for drag-and-drop in UIDynamicList*/
|
||||
.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
|
||||
border-top: 1px solid var(--fs-text);
|
||||
}
|
||||
@@ -169,10 +127,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the same one-line control height, so a toggle lines up with the field above it */
|
||||
.cbi-value-field > .cbi-checkbox,
|
||||
.cbi-value-field > div > .cbi-radio {
|
||||
height: var(--fs-control-h);
|
||||
height: var(--fs-control-h); /* lines a toggle up with the field above it */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -182,12 +139,9 @@
|
||||
gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* .cbi-select lives in theme/60-inputs.css — absorbed whole. Do not re-add an option rule here
|
||||
* either: layer beats specificity, so theme's bare `option` outranks even
|
||||
* `.cbi-select select option:checked`. */
|
||||
|
||||
/* Checkbox/radio drawing: theme/60-inputs.css — absorbed whole (it undoes the generic input
|
||||
* box, which lives there now). */
|
||||
/* .cbi-select and the checkbox/radio drawing: theme/60-inputs.css — absorbed whole. Do not
|
||||
* re-add an option rule here either: layer beats specificity, so theme's bare `option`
|
||||
* outranks even `.cbi-select select option:checked`. */
|
||||
|
||||
/* --fs-placeholder, not --fs-dim: a placeholder carries the option's DEFAULT and must not read
|
||||
* as a value the reader typed (03-palettes.css has the numbers). */
|
||||
@@ -195,9 +149,8 @@
|
||||
color: var(--fs-placeholder);
|
||||
}
|
||||
|
||||
/* Transitions and the focus/hover ring are the theme's one ring token (--fs-focus-ring /
|
||||
* --fs-focus-ring-invalid). A generic glow here rings every clicked button and every hovered
|
||||
* widget in a second, unnamed focus style. */
|
||||
/* Transitions and the focus/hover ring: the theme's one ring token
|
||||
* (--fs-focus-ring/--fs-focus-ring-invalid). No generic glow here. */
|
||||
|
||||
input[disabled],
|
||||
button[disabled],
|
||||
@@ -220,12 +173,10 @@
|
||||
padding: 0 0 var(--fs-space-3) var(--fs-space-3);
|
||||
}
|
||||
|
||||
/* `gap`, not a negative-margin pair: that trick predates flex gap and leaks, the outer -3px
|
||||
* pulling the row into whatever sits beside it */
|
||||
.cbi-section-create {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
gap: var(--fs-space-2); /* not a negative-margin pair — that trick predates flex gap, leaks */
|
||||
}
|
||||
|
||||
.cbi-section-create > * {
|
||||
@@ -234,29 +185,23 @@
|
||||
|
||||
/* the input's cell-filling width: theme/60-inputs.css — absorbed with the generic box */
|
||||
|
||||
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. The legacy `.actions` plate has no
|
||||
* theme override, so it stays: some luci-app-* still emits it. */
|
||||
/* .cbi-page-actions paint: theme/55-buttons.css — absorbed. .actions has no theme override, so
|
||||
* it stays: some luci-app-* still emits it. */
|
||||
.actions {
|
||||
background: var(--fs-panel2);
|
||||
margin-bottom: var(--fs-space-4);
|
||||
/* a plate, so it takes the card's own gutter on every side */
|
||||
padding: var(--fs-card-pad);
|
||||
padding: var(--fs-card-pad); /* a plate, so it takes the card's own gutter on every side */
|
||||
border-top: var(--fs-hairline);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* Physical, mirrored by hand — see `.close` in base/90-widgets.css for why `float` takes no
|
||||
* logical keyword. Unmirrored it contradicts its own container: `.actions` above sets
|
||||
* `text-align: end`, which follows the writing direction, so in RTL the plate aligns its
|
||||
* content one way and the secondary action the other. Measured in a live document at 1400px:
|
||||
* 582px from the reading edge in LTR, 10px in RTL. */
|
||||
.actions .secondary-action,
|
||||
.cbi-page-actions .secondary-action{
|
||||
/* Physical, hand-mirrored: docs/css.md "Widgets: .actions .secondary-action still floats, and
|
||||
* needs a hand RTL mirror". */
|
||||
.actions .secondary-action {
|
||||
float: right;
|
||||
}
|
||||
:root[dir="rtl"] .actions .secondary-action,
|
||||
:root[dir="rtl"] .cbi-page-actions .secondary-action { float: left; }
|
||||
:root[dir="rtl"] .actions .secondary-action { float: left; }
|
||||
|
||||
.actions .secondary-action a,
|
||||
.cbi-page-actions .secondary-action a {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@layer base {
|
||||
/* Tables. LuCI builds them out of divs carrying .table/.tr/.th/.td rather than
|
||||
* real table markup, so the display roles have to be assigned by hand. */
|
||||
/* LuCI tables are div soup (.table/.tr/.th/.td), display roles assigned by hand. */
|
||||
.tr { display: table-row; }
|
||||
.table[width="33%"], .th[width="33%"], .td[width="33%"] { width: 33%; }
|
||||
.table[width="100%"], .th[width="100%"], .td[width="100%"] { width: 100%; }
|
||||
@@ -15,66 +14,20 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* No per-cell border-top, and that is a fix rather than an omission: the theme draws a table's
|
||||
* separators on the ROW (theme/30-tables.css), so a cell border here is a second copy of every
|
||||
* separator — and because the cell line is straight and runs to the table's very edge while the
|
||||
* frame is rounded, a square corner cuts across the radius (issue #7). */
|
||||
/* Separator on the ROW (theme/30-tables.css), not the cell — else doubled and squares off
|
||||
* the frame's rounded corner (issue #7). */
|
||||
.table .th, .table .td {
|
||||
display: table-cell;
|
||||
/* middle is deliberate: a cell's content is a mix of text, badges and buttons, and
|
||||
* top-aligning mixed-height rows reads as a layout fault */
|
||||
vertical-align: middle;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* An unbreakable value must not leave the card, in ANY .table. Asking what KIND of table it is
|
||||
* first leaves a third-party key/value `<div class="table">` matching nothing, so one unbroken
|
||||
* token — an ICCID, a base64 key, a DUID — holds the column open and pushes the table outside
|
||||
* its card (measured on 3ginfo-lite).
|
||||
*
|
||||
* Not scoped to the phone tier: that leaves a hole where the column is narrow but the WINDOW is
|
||||
* not (with a 130-character token the table escaped its card by 147px at 900px and 279px at
|
||||
* 768px), which is the argument the fit engine is built on — whether something fits is a
|
||||
* property of the content and its column, not of the viewport. The cost is nil: across 8 pages
|
||||
* x 4 widths x 849 text cells nothing else changes.
|
||||
*
|
||||
* `anywhere`, not `break-word`: `break-word` permits the break without reducing the cell's
|
||||
* min-content contribution, so a `display: table` box still cannot shrink below its widest
|
||||
* token and the column stays open — tried, and the table stayed 355px wide in a 346px column.
|
||||
*
|
||||
* That is a CONTAINMENT default, right for every tier with nowhere to put an overflow — a
|
||||
* key/value include, the config table, the Overview meter rows. It is wrong for the one tier
|
||||
* that IS measured: a data table can card, break one column or scroll, so it wants the truth
|
||||
* instead, and theme/30-tables.css gives it `break-word` for as long as it is a table. Do not
|
||||
* unify the two by moving either.
|
||||
*
|
||||
* Set per ROLE: a value may break anywhere, a label breaks at words. `anywhere` costs a
|
||||
* min-content of ONE CHARACTER, which is the point on a value — the column may collapse rather
|
||||
* than push the table out — and the bug it looks like on a header, since an auto-layout table
|
||||
* hands the surplus to whichever column asks loudest and the header row pays: measured on
|
||||
* Processes, `PID` came out as "PI/D" and the header row stood 104px tall, against 54px with
|
||||
* the floor at its longest word (issue #32).
|
||||
*
|
||||
* `break-word` for the label, not `normal`: a header that IS one long token must still break
|
||||
* rather than overflow, it just may not shrink the column below that word. */
|
||||
/* Containment default, not the data-table floor: docs/css.md "Tables: overflow-wrap follows
|
||||
* the tier, not the file". First column is a label too; exempt once carded (.fs-stacked .td,
|
||||
* theme/30-tables.css). */
|
||||
.table .td { overflow-wrap: anywhere; }
|
||||
.table .th { overflow-wrap: break-word; }
|
||||
|
||||
/* …and the first column is a label too, whichever element it is made of.
|
||||
*
|
||||
* `anywhere` costs a min-content of one character, and auto table layout hands each column its
|
||||
* share by MAX-content, so a two-column key/value table whose value is one enormous token
|
||||
* starves the key column down to that character — measured at 8000 characters of value, the key
|
||||
* column fell from 95px to 55px, and on the reporter's host the header read one glyph per line
|
||||
* (issue #36, on Firefox and Blink alike).
|
||||
*
|
||||
* `break-word` puts the floor back at the longest WORD, the same trade `.th` makes above: the
|
||||
* first column is the row's identity, and it is what you read the table by.
|
||||
*
|
||||
* A carded row is exempt and must be: `.table.fs-stacked .td` (theme/30-tables.css) restates
|
||||
* `anywhere` from a later layer, and once every cell is on its own line there is no
|
||||
* neighbouring column left to starve. */
|
||||
.table .tr > .td:first-child { overflow-wrap: break-word; }
|
||||
|
||||
.table .tr:first-child .th {
|
||||
@@ -83,23 +36,8 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* The empty-table plate: three lines of leading plus the cell inset it sits in, so it keeps the
|
||||
* table's own height when there is nothing to show.
|
||||
*
|
||||
* Out of flow on purpose: a single-cell row in a `display: table` spans only the FIRST column,
|
||||
* so in flow the plate is a narrow box under column one and its sentence becomes that column's
|
||||
* max-content, widening it (275px -> 502px on an empty section). Absolute takes it
|
||||
* out of the column sizing entirely and `inset-inline: 0` spans it across the table.
|
||||
*
|
||||
* WHAT IT MUST NOT DO IS PIN ITSELF TO THE BOTTOM. It used to say `bottom: 0`, which inside the
|
||||
* `position: relative` .table means the bottom of the whole TABLE — the same place as its own row
|
||||
* only while the placeholder is the last row there is. `form.TableSection` renders its `footer`
|
||||
* into a `<tfoot>` AFTER the `<tbody>`, so on every empty section that declares one the plate
|
||||
* printed straight across the totals: measured on luci-app-dockerman's Containers, Images and
|
||||
* Volumes tables (issue #36) as a 1190x35 overlap, the full width of the table, identically on
|
||||
* Blink and on Gecko 153. With no `bottom` at all an absolutely positioned box keeps its STATIC
|
||||
* position — where the row actually is — which is what was meant all along, and the reserved row
|
||||
* height below is what holds that space open. */
|
||||
/* Out-of-flow, static position (never `bottom: 0`): docs/css.md "Tables: the empty-table
|
||||
* placeholder must not pin to the table's bottom" (issue #36). */
|
||||
.tr.placeholder {
|
||||
height: calc(3em + var(--fs-space-4));
|
||||
}
|
||||
@@ -109,22 +47,15 @@
|
||||
inset-inline: 0;
|
||||
text-align: center;
|
||||
line-height: 3em;
|
||||
/* …and the plate must be exactly as tall as the row reserved for it, or it hangs out of the
|
||||
* table at whichever end it is anchored to. `--fs-space-4` is two `--fs-space-2` at every
|
||||
* density, so this and the row height above are the same number twice — which is what lets
|
||||
* the cell keep its own size while the table flavours pad their ordinary cells
|
||||
* differently. Measured on Wireless, whose table pads 10px a side: the plate came out 67px
|
||||
* inside a 63px row and printed 4px below the frame's bottom border. */
|
||||
padding-block: var(--fs-space-2);
|
||||
padding-block: var(--fs-space-2); /* two --fs-space-2 = the row height above, one number */
|
||||
}
|
||||
|
||||
.tr.drag-over-above,
|
||||
.tr.drag-over-below {
|
||||
border: 2px solid var(--fs-accent);
|
||||
border-width: 2px 0 0 0;
|
||||
/* Physical: border-block-start/-end need a css-floor.mjs support decision first. */
|
||||
.tr.drag-over-above {
|
||||
border-top: 2px solid var(--fs-accent);
|
||||
}
|
||||
|
||||
.tr.drag-over-below {
|
||||
border-width: 0 0 2px 0;
|
||||
border-bottom: 2px solid var(--fs-accent);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
@layer base {
|
||||
/* the modal overlay ui.js shows for config diffs, uploads and login, plus the colour variants
|
||||
* shared by .btn / .alert-message / .cbi-tooltip */
|
||||
/* the modal overlay ui.js shows for config diffs, uploads and login */
|
||||
#modal_overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -15,26 +14,18 @@
|
||||
|
||||
.modal {
|
||||
width: 90%;
|
||||
/* clear of the sticky bar, and centred */
|
||||
margin: var(--fs-space-6) auto;
|
||||
margin: var(--fs-space-6) auto; /* clear of the sticky bar, and centred */
|
||||
min-height: var(--fs-control-h);
|
||||
max-width: 600px;
|
||||
/* a modal is a CARD — the large radius, like every other panel */
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border-radius: var(--fs-radius-lg); /* a modal is a card — the large radius, like every panel */
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
/* box-shadow: theme/70-modal.css — absorbed; the old one was a shadow in the colour of the
|
||||
* page, and the theme gives every .modal --fs-shadow-pop instead */
|
||||
/* the card's own gutter on every side; the button row's spacing is its own business */
|
||||
padding: var(--fs-card-pad);
|
||||
min-width: 270px;
|
||||
}
|
||||
|
||||
/* .modal > * (normal leading, the .5em rhythm, max-width): theme/70-modal.css — absorbed
|
||||
* with the heading ramp, which would out-layer the leading here */
|
||||
|
||||
/* .modal > pre/textarea (pre-wrap, fill the dialog): theme/70-modal.css — absorbed with
|
||||
* the generic field box (its 210px would out-layer the width here) */
|
||||
/* .modal > * / pre / textarea: theme/70-modal.css — absorbed with the heading ramp and the
|
||||
* generic field box, both of which would out-layer a restatement here. */
|
||||
|
||||
.modal > .button-row {
|
||||
display: flex;
|
||||
@@ -52,10 +43,7 @@
|
||||
|
||||
body.modal-overlay-active {
|
||||
overflow: hidden;
|
||||
/* svh: `100vh` counts the mobile URL bar as if it were not there and crops the modal, while
|
||||
* `dvh` re-lays the dialog out on every frame of the bar's slide. The small viewport height
|
||||
* is the one that is both correct and still. */
|
||||
height: 100svh;
|
||||
height: 100svh; /* not vh (crops under URL bar) or dvh (re-lays out each frame it slides) */
|
||||
}
|
||||
|
||||
body.modal-overlay-active #modal_overlay {
|
||||
@@ -64,18 +52,11 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* the cross inside a button or alert: `line-height: 1` centres the glyph in its own box, where a
|
||||
* fixed leading stops matching the moment the type scale moves */
|
||||
.btn .close, .alert-message .close {
|
||||
font-family: var(--fs-font-sans);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The whole colour-word block is absorbed — do not re-add any of it. `.btn.<word>` lives in
|
||||
* theme/55-buttons.css, `.alert-message.<word>` and `.cbi-tooltip.<word>` in
|
||||
* theme/35-alerts.css. The alert variants were already dead: theme paints plain
|
||||
* `.alert-message`, and a later layer beats this one whatever the specificity. The tooltip
|
||||
* variants were not — they were alive and un-themed, which is why the note that used to sit
|
||||
* here said the theme did not claim them; the gallery renders all four now, and that is what
|
||||
* turned the claim into a measurement. */
|
||||
/* Colour-word block absorbed, do not re-add: .btn.<word> theme/55-buttons.css,
|
||||
* .alert-message/.cbi-tooltip.<word> theme/35-alerts.css. */
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
@layer base {
|
||||
/* `.btn` and `.cbi-button` are interchangeable in LuCI markup; a row action carries both plus a
|
||||
* `-<variant>` class. The paint — box, fill, radius — is theme/55-buttons.css's, along with the
|
||||
* `.item::after` member, whose plate now lives with the dynlist in theme/60-inputs.css. What is
|
||||
* left here is --on-color, which is not paint: base/95-luci.css masks the spinner glyph with
|
||||
* it, so it must track the variant. */
|
||||
/* .btn/.cbi-button are interchangeable in LuCI markup. Paint (box, fill, radius) is
|
||||
* theme/55-buttons.css's; --on-color stays here because base/95-luci.css masks the spinner
|
||||
* glyph with it, so it must track the variant regardless of layer. */
|
||||
.btn,
|
||||
.cbi-button {
|
||||
--on-color: var(--fs-text);
|
||||
@@ -13,7 +11,7 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* border-color: theme/60-inputs.css — absorbed. The ink stays: theme paints no colour here. */
|
||||
/* border-color: theme/60-inputs.css — absorbed. */
|
||||
.cbi-input-invalid,
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li,
|
||||
@@ -21,12 +19,7 @@
|
||||
color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* `--on-color` only. A `border-color` here would be dead: theme/55-buttons.css sets `border` on
|
||||
* `.cbi-button, .btn`, and a later layer beats this one whatever the specificity, so the
|
||||
* variant wears the theme's colour either way — proven, not reasoned: stripping the ones that
|
||||
* used to sit here changes no computed style over docs/gallery.html, which renders every one of
|
||||
* these variants. `--on-color` stays because it is not paint — base/95-luci.css masks the
|
||||
* spinner glyph with it. */
|
||||
/* --on-color only: docs/css.md "Buttons: --on-color survives the absorption into theme". */
|
||||
.cbi-button-positive,
|
||||
.cbi-button-fieldadd,
|
||||
.cbi-button-add,
|
||||
@@ -49,46 +42,31 @@
|
||||
--on-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* Not a clearfix — flex container, nothing floats: docs/css.md "Buttons: the flex clearfix,
|
||||
* and why float is dead on .cbi-page-actions". */
|
||||
.cbi-page-actions::after {
|
||||
display: table;
|
||||
content: "";
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.cbi-page-actions > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* No `float` here: theme/55-buttons.css makes `.cbi-page-actions` a flex container, and float
|
||||
* does not apply to a flex item, so one would compute to `none` and move nothing while reading
|
||||
* like a physical side the logical-property sweep had missed. No context revives it — print
|
||||
* hides the whole container (theme/97-print.css). Verified in a live document in both writing
|
||||
* directions: the button sits 569px from the reading edge with the float and 569px without. */
|
||||
.cbi-page-actions > :not([method="post"]):not(.cbi-button-apply):not(.cbi-button-negative):not(.cbi-button-save):not(.cbi-button-reset) {
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* fills and borders: theme/55-buttons.css — absorbed. --on-color stays (the spinner mask). */
|
||||
/* fills/borders absorbed (theme/55-buttons.css); Save/Apply's --on-color is re-set there too
|
||||
* regardless of layer, so no base copy is needed for that pair. */
|
||||
.btn.primary,
|
||||
.cbi-button-action.important,
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-section-actions .cbi-button-edit {
|
||||
--on-color: var(--fs-on-accent);
|
||||
}
|
||||
|
||||
.cbi-button-positive.important,
|
||||
.cbi-page-actions .cbi-button-save {
|
||||
.cbi-button-positive.important {
|
||||
--on-color: var(--fs-on-good);
|
||||
}
|
||||
|
||||
.cbi-button-negative.important {
|
||||
--on-color: var(--fs-on-danger);
|
||||
/* fill + border: theme/55-buttons.css — absorbed */
|
||||
}
|
||||
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
--on-color: var(--fs-good);
|
||||
/* fill + border: theme/55-buttons.css — absorbed */
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
@layer base {
|
||||
/* small widgets a view or a third-party luci-app-* can drop anywhere: tooltip, progress bar,
|
||||
* zone badge, button states, close cross, alert box, .label pill */
|
||||
/* .cbi-title-ref colour: theme/45-misc.css — absorbed. The arrow stays: content,
|
||||
* not paint. */
|
||||
/* small widgets any luci-app-* can drop anywhere: tooltip, progress bar, zone badge, close
|
||||
* cross, alert box, .label pill. Arrow: content, not paint — colour is theme/45-misc.css. */
|
||||
.cbi-title-ref::after {
|
||||
content: "➙";
|
||||
}
|
||||
@@ -14,19 +12,13 @@
|
||||
.cbi-tooltip {
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-tooltip);
|
||||
/* logical: `left: -10000px` in an RTL document parks the tooltip past the INLINE-END edge,
|
||||
* which is scrollable overflow — every page grew a 10000px horizontal scrollbar in ar/fa/he
|
||||
* (measured). Past the inline-start edge is unreachable in both directions. */
|
||||
/* docs/css.md "Widgets: the tooltip parks past the unreachable edge, and the close cross
|
||||
* needs the same mirror" — a physical `left` grows a 10000px RTL scrollbar. */
|
||||
inset-inline-start: -10000px;
|
||||
/* box-shadow: theme/35-alerts.css — absorbed */
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-panel);
|
||||
/* `pre`, not `pre-wrap`, NEVER wraps — task 0162: live-audit measured a firewall rule-jump
|
||||
* tooltip (`nftables/iptables`, "Chain POSTROUTING, Rule #1") 65-72px past the column at
|
||||
* 320px, spaces and all, because `pre` disables wrapping outright regardless of where a space
|
||||
* sits. `pre-wrap` keeps every literal run of whitespace this rule was carried for — the same
|
||||
* reason `35-alerts.css`'s validation tooltip and a multi-line firewall hint still read
|
||||
* correctly — while letting the browser wrap at one of them once the line is too long. */
|
||||
/* pre-wrap, never pre: docs/css.md "Typography and widgets: an opaque token needs
|
||||
* overflow-wrap: anywhere, a tooltip needs pre-wrap". */
|
||||
white-space: pre-wrap;
|
||||
padding: var(--fs-space-0-5) var(--fs-space-1);
|
||||
opacity: 0;
|
||||
@@ -51,7 +43,6 @@
|
||||
margin: -1.6em 0 0 -5px;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
pointer-events: none;
|
||||
/* box-shadow: theme/35-alerts.css states one elevation for every .cbi-tooltip */
|
||||
}
|
||||
|
||||
.zonebadge .cbi-tooltip > * {
|
||||
@@ -82,28 +73,16 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* pressed-state shadow (.btn.active/:active): theme/55-buttons.css — absorbed */
|
||||
.btn.disabled,
|
||||
.btn[disabled] {
|
||||
cursor: default;
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
}
|
||||
|
||||
/* `.btn.large` / `.btn.small`: theme/55-buttons.css, in full — absorbed. */
|
||||
|
||||
/* close cross — colour, shadow, opacities: theme/70-modal.css — absorbed. Left:
|
||||
* geometry. */
|
||||
/* `line-height: 1` centres the glyph in its own box; it was 13.5px under a 20px face, and a
|
||||
* hand-nudged length only lands for one font size */
|
||||
/* Physical, and mirrored by hand below — the same shape as `::-webkit-resizer`
|
||||
* (theme/60-inputs.css) and the two chevrons (theme/20-shell.css). `float` takes no logical
|
||||
* keyword this theme can rely on: `inline-end` is newer than
|
||||
* the `oklch(from …)` and `:has()` this sheet already requires only in Chromium, and a browser
|
||||
* that does not know the keyword drops the declaration and leaves the cross floating
|
||||
* nowhere. Measured in a live document at 1400px: the cross sat 549px from the reading edge in
|
||||
* LTR and 41px in RTL, i.e. it jumped from the end of the heading to its start — in front of
|
||||
* the title, where a dismissal control is not. It appears in every modal and in every
|
||||
* `.alert-message`, so this is the RTL bug with the widest reach. */
|
||||
/* .btn.large/.btn.small, close cross colour/shadow/opacities: theme/55-buttons.css and
|
||||
* theme/70-modal.css — absorbed. Left here: geometry only. */
|
||||
/* Physical, hand-mirrored: docs/css.md "Widgets: the tooltip parks past the unreachable edge,
|
||||
* and the close cross needs the same mirror" — appears in every modal and .alert-message. */
|
||||
.close {
|
||||
float: right;
|
||||
font-size: var(--fs-type-xl);
|
||||
@@ -127,14 +106,11 @@
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* colour, margin and the content reset: theme/35-alerts.css — absorbed with the heading ramp */
|
||||
|
||||
.alert-message button {
|
||||
margin: var(--fs-space-1) 0;
|
||||
}
|
||||
|
||||
/* `.label` pill and variants: theme/35-alerts.css — absorbed, as is the indicator pill
|
||||
* (showIndicator() only ever emits into #indicators, which theme/20-shell.css paints). */
|
||||
/* .label pill and the indicator pill: theme/35-alerts.css / theme/20-shell.css — absorbed. */
|
||||
header [data-indicator] {
|
||||
white-space: nowrap;
|
||||
text-shadow: none;
|
||||
|
||||
@@ -10,11 +10,9 @@
|
||||
/* #syslog: absorbed into pages/70-syslog.css — in base it LOST to the theme layer's generic
|
||||
* form-field box (a layer beats specificity), so the log rendered as a 210px column. */
|
||||
|
||||
/* `:not(.placeholder)`: a hover cue promises a row you can act on, and the empty-table row is
|
||||
* not one. The theme's row hover already spares it (theme/30-tables.css, issue #5.2), but this
|
||||
* rule paints the CELL, and a placeholder row carrying no `.cbi-section-table-row` class
|
||||
* escapes that guard — measured on UPnP (issue #15).
|
||||
* Fixed here rather than repainted later, so there is one hover contract. */
|
||||
/* :not(.placeholder): the empty-table row gets no hover cue (theme/30-tables.css spares it),
|
||||
* but this rule paints the CELL and a placeholder row carrying no `.cbi-section-table-row`
|
||||
* class escapes that guard — measured on UPnP (issue #15). */
|
||||
.cbi-section-table .tr:not(.placeholder):hover .td,
|
||||
.cbi-section-table .tr:not(.placeholder):hover .th,
|
||||
.cbi-section-table .tr:not(.placeholder):hover::before {
|
||||
@@ -29,32 +27,9 @@
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* The GridSection name column, where the two releases' markup genuinely differs and one
|
||||
* stylesheet serves both: 25.12's form.js renders the name as a real `th` plus
|
||||
* `td.cbi-section-table-titles` and keeps `data-title` on the row only for the drag handle,
|
||||
* while 24.10 renders no cell at all and expects `::before` to generate the column from
|
||||
* `data-title`. Upstream ships a different cascade.css per branch (25.12's says
|
||||
* `content: none`, 24.10's `content: attr(data-title) " "`), so taking 25.12's copy verbatim
|
||||
* deletes the Name column on every 24.10 router (issue #9).
|
||||
*
|
||||
* Each row type therefore asks the MARKUP, never the release:
|
||||
* header — carries `data-title` on 24.10 only; 25.12 appends a real `th` instead.
|
||||
* row — BOTH set `data-title`, so the discriminator is the name cell 25.12 adds.
|
||||
* descr — has neither, so it follows the header that already proved the branch. It needs the
|
||||
* empty spacer (`data-title` is absent, so the content is just " ") or its column
|
||||
* labels sit one cell left of the values they describe. A descr row with no header
|
||||
* row above it (every column carrying a description but no title) therefore keeps no
|
||||
* spacer — no shipped page renders that, so it is untested rather than handled.
|
||||
* Never key on the VALUE: the header's data-title is _('Name'), i.e. translated.
|
||||
*
|
||||
* This rule states only that the column EXISTS — its padding, type and separator are the cell
|
||||
* rules' job in theme/30-tables.css, next to the real cells it has to line up with. (Upstream
|
||||
* declares the border-top here; ours would draw the stray line above the header that issue #7
|
||||
* removed from every other cell.) */
|
||||
/* SPLIT OFF THE :has() HALF, and the split is the point: an engine that cannot parse `:has()`
|
||||
* throws away the WHOLE rule, siblings included — so a Firefox older than 121 lost the two
|
||||
* plain selectors above with it, and stacked tables came up with no card labels at all. Same
|
||||
* declarations, two rules, and the half that needs nothing new survives on its own. */
|
||||
/* GridSection name column, discriminated by markup not release: docs/css.md "base/95-luci.css:
|
||||
* the GridSection name column, read from the markup, not the release" (issue #9). Column
|
||||
* geometry lives in theme/30-tables.css, next to the real cells it lines up with. */
|
||||
.cbi-section-table-titles.named[data-title]::before,
|
||||
.cbi-section-table-titles.named[data-title] ~ .cbi-section-table-descr.named::before {
|
||||
content: attr(data-title) " ";
|
||||
@@ -79,7 +54,7 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* cell-filling field widths (+ the numeric-cell refinement): theme/30-tables.css —
|
||||
/* cell-filling field widths (+ numeric-cell refinement): theme/30-tables.css —
|
||||
* absorbed with the generic field box, which would out-layer them here */
|
||||
|
||||
.table.cbi-section-table .td.cbi-section-table-cell {
|
||||
@@ -100,9 +75,7 @@
|
||||
.td.cbi-section-actions > :not(.cbi-dropdown) > *,
|
||||
.td.cbi-section-actions > * > form > * {
|
||||
flex: 1 1 4em;
|
||||
/* at 1px apart, adjacent row actions sat inside each other's 24px target circle
|
||||
* (WCAG 2.2 SC 2.5.8) — see the min-height in theme/55-buttons.css */
|
||||
margin: 0 var(--fs-space-1);
|
||||
margin: 0 var(--fs-space-1); /* WCAG 2.2 SC 2.5.8: 1px apart, targets sat inside each other */
|
||||
}
|
||||
|
||||
.td.cbi-section-actions > * > form {
|
||||
@@ -124,9 +97,8 @@
|
||||
th[data-sort-direction="asc"]::after { content: "\a0\25b2"; }
|
||||
th[data-sort-direction="desc"]::after { content: "\a0\25bc"; }
|
||||
|
||||
/* The help sentence, indented to clear the ? glyph its ::before hangs in the margin. The
|
||||
* indent and the glyph's own `left` are ONE relationship — both said 1.25em, stated twice — so
|
||||
* they now derive from a single local. */
|
||||
/* Indent and the ::before glyph's own `left` are ONE relationship (both 1.25em, stated twice
|
||||
* before) — now one local. */
|
||||
.cbi-value-description {
|
||||
--help-indent: 1.25em;
|
||||
|
||||
@@ -135,10 +107,8 @@
|
||||
}
|
||||
|
||||
.cbi-value-description:not(:empty)::before {
|
||||
/* OURS, and drawn as a STROKE like every other glyph this theme owns (--spinner-icon below,
|
||||
* --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid). It replaces a solid
|
||||
* filled path copied verbatim from luci-theme-bootstrap: a drawn path is authored expression
|
||||
* in a way `padding: 8px` is not, which made it the sharpest item in that inheritance. */
|
||||
/* OURS, drawn as a stroke like --spinner-icon below and --fs-icon-refresh in 02-tokens.css
|
||||
* (fill:none, round caps, 24 grid) — not the solid filled path luci-theme-bootstrap used. */
|
||||
--help-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='12' cy='12' r='9.4'/><path d='M8.9 9.4a3.2 3.2 0 1 1 3.9 3.6v1.4'/><path d='M12.8 17.6h-.02'/></svg>");
|
||||
|
||||
position: absolute;
|
||||
@@ -155,7 +125,7 @@
|
||||
-webkit-mask-size: cover;
|
||||
}
|
||||
|
||||
/* tint (and the 6%-not-12% reasoning): theme/30-tables.css — absorbed */
|
||||
/* tint (6%-not-12% reasoning): theme/30-tables.css — absorbed */
|
||||
.cbi-section-error {
|
||||
border: 1px solid var(--fs-danger);
|
||||
border-radius: var(--fs-radius);
|
||||
@@ -166,8 +136,7 @@
|
||||
.cbi-section-error ul { margin: 0 0 0 var(--fs-space-5); }
|
||||
|
||||
.cbi-section-error ul li {
|
||||
/* colour: theme/35-alerts.css — absorbed */
|
||||
font-weight: var(--fs-weight-bold);
|
||||
font-weight: var(--fs-weight-bold); /* colour: theme/35-alerts.css — absorbed */
|
||||
}
|
||||
|
||||
.ifacebox {
|
||||
@@ -175,47 +144,29 @@
|
||||
margin: 0 var(--fs-space-3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
/* surface: theme/45-misc.css (flat) — absorbed */
|
||||
border-radius: var(--fs-radius-sm);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
/* a plate of single-line labels, not prose */
|
||||
line-height: var(--fs-leading-tight);
|
||||
line-height: var(--fs-leading-tight); /* a plate of single-line labels, not prose */
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.ifacebox .ifacebox-head {
|
||||
border-bottom: var(--fs-hairline);
|
||||
/* the head sits INSIDE the box's 1px border, so its own radius is the box's less that
|
||||
* border — restating a smaller literal is how the two drifted apart before. */
|
||||
/* inside the box's 1px border, so radius = the box's less that border */
|
||||
border-radius: calc(var(--fs-radius-sm) - 1px) calc(var(--fs-radius-sm) - 1px) 0 0;
|
||||
padding: var(--fs-space-0-5);
|
||||
/* surface + ink: theme/45-misc.css — absorbed */
|
||||
}
|
||||
|
||||
/* The !important fights luci-mod-network's inline zone-colour `style=`: a raw zone colour is too
|
||||
* bright on a dark panel, so it is re-mixed at .4/.3.
|
||||
*
|
||||
* No fallback on --zone-color-rgb, and that is load-bearing: LuCI sets that var inline only
|
||||
* where there IS a zone colour, so elsewhere the declaration is invalid at computed-value time
|
||||
* and, being !important, resolves to `unset` and wipes the plain inline background — which is
|
||||
* the desired outcome. A fallback grey makes the rule apply there and tints five interface
|
||||
* boxes that correctly show the panel behind them (measured, computed-style diff). */
|
||||
/* !important fights luci-mod-network's inline zone-colour style=: docs/css.md "base/95-luci.css:
|
||||
* the dark-mode zone tint, and why it has no grey fallback". */
|
||||
[data-darkmode="true"] .zonebadge[style],
|
||||
[data-darkmode="true"] .ifacebox-head[style] {
|
||||
background: linear-gradient(rgba(var(--zone-color-rgb), .4), rgba(var(--zone-color-rgb), .3)) !important;
|
||||
}
|
||||
|
||||
/* `.ifacebox` above is `white-space: nowrap` — right for a plate of one-word head labels, wrong
|
||||
* for the body's own stat/address lines once they are long enough to need a break: measured
|
||||
* three times under three different scopes before this rule (Network overview's
|
||||
* `#cbi-network-interface`, the upstream plates' `.network-status-table`, and — pseudo-loc,
|
||||
* gallery's plain Port-tile fixture with no `port_*.svg` icon — a 320px/Large stat line "1000
|
||||
* Mbit/s / 1.2 GiB / 8.3 GiB" already 33px past a 118px box in PLAIN ENGLISH, no translation
|
||||
* involved) — the base rule owns it once instead of a fourth scoped copy the next real page
|
||||
* would need again. `overflow-wrap: anywhere`, not `break-word`: only `anywhere` lowers the
|
||||
* box's own min-content, letting the flex item actually shrink to the tile (65-dropdown.css's
|
||||
* own reasoning for the same two properties). */
|
||||
/* generalised once: docs/css.md "base/95-luci.css: the overflow-wrap fix generalised once, not
|
||||
* restated per page". */
|
||||
.ifacebox .ifacebox-body {
|
||||
padding: var(--fs-space-1);
|
||||
white-space: normal;
|
||||
@@ -232,11 +183,9 @@
|
||||
flex-direction: row;
|
||||
border: var(--fs-hairline);
|
||||
padding: var(--fs-space-0-5);
|
||||
/* surface: theme/45-misc.css (flat) — absorbed */
|
||||
color: var(--fs-text);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: default;
|
||||
/* a badge is one line of label — the tight step, like .ifacebox above */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
|
||||
@@ -247,7 +196,6 @@
|
||||
}
|
||||
|
||||
.ifacebadge-active {
|
||||
/* border-color: theme/45-misc.css (accent) — absorbed */
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
@@ -266,17 +214,10 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
text-align: start;
|
||||
/* the wrap fix (an uncompressed IPv6 is ~380px of unbreakable text, measured 45px over the
|
||||
* card at 390px before it shipped) now lives on `.ifacebox .ifacebox-body` above, generalised
|
||||
* to every plate rather than restated per scope — this rule keeps only what is unique to the
|
||||
* upstream layout: full-height flex column, address-block start alignment. */
|
||||
}
|
||||
|
||||
/* The head got the port tiles' `nowrap` (short one-word labels: "wan", "lan2") but sits in the
|
||||
* SAME shrinking flex item as the body above, and RU's "IPv4 Upstream" caption is a sentence,
|
||||
* not a word: "Подключение IPv4 (внешняя сеть)" measured 301px in a 288px column at 320px/
|
||||
* Density Large, 41.3px past #view, with the .cbi-section clipped by 42px and no scroller — EN
|
||||
* at the same width and density: 0. Same two declarations as the body, same reasoning. */
|
||||
/* same fix as .ifacebox-head above, same doc section (RU caption "Подключение IPv4
|
||||
* (внешняя сеть)" measured 41.3px past #view at 320px/Large, 0px in English). */
|
||||
.network-status-table .ifacebox-head {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -304,13 +245,16 @@
|
||||
|
||||
.ifacebadge.large,
|
||||
.network-status-table .ifacebox-body .ifacebadge {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: var(--fs-space-1);
|
||||
min-width: 220px;
|
||||
margin: var(--fs-space-1);
|
||||
}
|
||||
|
||||
.network-status-table .ifacebox-body .ifacebadge {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ifacebadge.large {
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -346,16 +290,14 @@
|
||||
}
|
||||
|
||||
.zonebadge-empty {
|
||||
/* width/style only — colour + ink: theme/45-misc.css */
|
||||
border: 1px dashed var(--fs-border);
|
||||
border: 1px dashed var(--fs-border); /* width/style only — colour + ink: theme/45-misc.css */
|
||||
font-style: italic;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
/* color: theme/60-inputs.css — absorbed. */
|
||||
div.cbi-value var,
|
||||
.td.cbi-value-field var {
|
||||
font-style: italic;
|
||||
font-style: italic; /* colour: theme/60-inputs.css — absorbed */
|
||||
}
|
||||
|
||||
div.cbi-value var[data-tooltip],
|
||||
@@ -379,31 +321,25 @@
|
||||
}
|
||||
|
||||
#modal_overlay > .modal.login > button.important {
|
||||
/* no !important needed: the ID makes this (1,3,1), which no button rule can reach */
|
||||
/* Coverage vocabulary, not a live control: nothing in luci-base gives a modal the `login`
|
||||
* class (ui.showModal takes classes and no call site passes one), and this theme's own
|
||||
* login is a server-rendered form, not a modal. It is inherited from luci-theme-bootstrap,
|
||||
* which carries the same selector, so an app that emits it gets a themed result — a step up
|
||||
* the scale, where it was a bare 120% of whatever the button happened to be. */
|
||||
/* Coverage vocabulary, not a live control: no luci-base call site passes ui.showModal a
|
||||
* `login` class, and this theme's own login is a server-rendered form. Inherited from
|
||||
* luci-theme-bootstrap so a foreign app emitting it still gets a themed result. */
|
||||
font-size: var(--fs-type-lg);
|
||||
margin-top: var(--fs-space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* a list of changed settings, one per line: prose leading, not a hand 170%. */
|
||||
.uci-change-list {
|
||||
line-height: var(--fs-leading);
|
||||
line-height: var(--fs-leading); /* prose leading for a list of changed settings, not a hand 170% */
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* one changed-setting row. It carried `line-height: 15px` — a hand number under a 13px face;
|
||||
* a row of single-line code takes the tight step like every other plate here. */
|
||||
.uci-dialog div > del,
|
||||
.uci-dialog div > ins,
|
||||
.uci-dialog div > var {
|
||||
margin-bottom: var(--fs-space-0-5);
|
||||
border: var(--fs-hairline);
|
||||
line-height: var(--fs-leading-tight);
|
||||
line-height: var(--fs-leading-tight); /* one row of single-line code takes the tight step */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: var(--fs-space-0-5);
|
||||
@@ -489,25 +425,10 @@
|
||||
margin: 15vh auto;
|
||||
}
|
||||
|
||||
/* Rotate in CSS, NEVER with a SMIL <animateTransform> inside the data-URI: that runs in
|
||||
* the SVG's own document, which the page's `prefers-reduced-motion` cannot reach — the
|
||||
* one spinner a motion-sensitive user could not turn off. theme/95-a11y-media.css now
|
||||
* stops it like everything else. (The circle under the arc is rotationally symmetric,
|
||||
* so spinning the whole box looks identical.) */
|
||||
/* ---- the glyph sits in the flow, which is the fix for a whole bug class ----
|
||||
*
|
||||
* Absolutely positioned, the host has to be told to leave room for it — a `padding-left` that
|
||||
* must out-rank whatever else pads that element. That race cannot be won: every padding rule
|
||||
* aimed at a button is a candidate and the loser is silent, the glyph simply drawing on top of
|
||||
* the label. Twice reported from the field, each time by a more specific rule than the last:
|
||||
* issue #15 (`.td .cbi-button`, 0,2,0) and issue #22, whose
|
||||
* `.cbi-section .table[id] .td .cbi-button` is 0,5,0 — there the ladder had padding-left at
|
||||
* 10px against the 32px the glyph needs.
|
||||
*
|
||||
* In the flow the glyph reserves its own space, so no host needs a padding and there is nothing
|
||||
* to out-rank. `flex: 0 0 auto` because the buttons are `inline-flex` and a flex item with a
|
||||
* mask is squeezable; `vertical-align: middle` for the non-flex hosts. The centring is the
|
||||
* host's own align-items or baseline alignment. */
|
||||
/* Rotate in CSS, never a SMIL <animateTransform> inside the data-URI: that runs in the SVG's
|
||||
* own document, out of reach of the page's `prefers-reduced-motion`. In-flow glyph, not
|
||||
* absolutely positioned: docs/css.md "base/95-luci.css: the spinner glyph sits in the flow, on
|
||||
* purpose" (issues #15, #22). */
|
||||
.spinning::before {
|
||||
--spinner-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' stroke='%23000' stroke-width='3' fill='none'><circle cx='10' cy='10' r='8' stroke-opacity='.5'/><path d='M10 2c4 0 8 4 8 8'/></svg>");
|
||||
|
||||
@@ -515,22 +436,20 @@
|
||||
flex: 0 0 auto;
|
||||
vertical-align: middle;
|
||||
margin-inline-end: var(--fs-space-1);
|
||||
width: var(--fs-spin-size);
|
||||
height: var(--fs-spin-size);
|
||||
width: var(--fs-space-5); /* one reader, so the size lives here rather than behind a token */
|
||||
height: var(--fs-space-5);
|
||||
content: " ";
|
||||
background: var(--on-color, #000);
|
||||
mask: var(--spinner-icon) center/cover no-repeat;
|
||||
-webkit-mask: var(--spinner-icon) center/cover no-repeat;
|
||||
/* The two deliberate exceptions to the motion scale (02-tokens.css), both because this
|
||||
* is a LOOP, not a state change: 1s is a spinner period, not one of the four durations;
|
||||
* and `linear` is the one justified curve — `ease` would slow at each turn's seam, so an
|
||||
* infinite rotation visibly stutters once a second instead of spinning. */
|
||||
/* two deliberate exceptions to the motion scale (02-tokens.css): a LOOP, not a state change
|
||||
* — 1s is a spinner period not one of the four durations, and `linear` avoids the stutter
|
||||
* `ease` gives an infinite rotation at each turn's seam. */
|
||||
animation: fs-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* One user in this theme (`.spinning::before` above) — the rail's poll glyph is
|
||||
* deliberately STATIC (theme/20-shell.css; the spin was what broke that click).
|
||||
* Unscoped anyway: keyframe names resolve document-wide, so a luci-app-* may ride it. */
|
||||
/* One user (.spinning::before above) — the rail's poll glyph is deliberately STATIC
|
||||
* (theme/20-shell.css). Unscoped: keyframe names resolve document-wide, a luci-app-* may ride it. */
|
||||
@keyframes fs-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
[data-darkmode="true"] .spinning::before {
|
||||
@@ -552,16 +471,12 @@
|
||||
transition: opacity var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
/* min(210px, 100%) floor: docs/css.md "base/95-luci.css: the file browser's floor, and the row
|
||||
* that is not text". */
|
||||
.cbi-filebrowser {
|
||||
/* `min(210px, 100%)`, not a bare 210: the floor keeps the file list readable, and a floor
|
||||
* wider than the field it sits in is not a floor but an overflow — measured at a 300px
|
||||
* viewport, the panel held 210px inside a 203px field and its whole upload row (the input,
|
||||
* Upload file, Cancel) stood 7px outside the card. Same shape as the dynlist's floor one file
|
||||
* over, and the same fix. Nothing moves at any width where the field is wider than 210. */
|
||||
min-width: min(210px, 100%);
|
||||
max-width: 100%;
|
||||
/* width/style only; radius: theme/60-inputs.css — absorbed */
|
||||
border: var(--fs-hairline);
|
||||
border: var(--fs-hairline); /* width/style only; radius: theme/60-inputs.css — absorbed */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
@@ -569,8 +484,8 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* padding: theme/60-inputs.css — absorbed. The zero side padding here was what let a 36px
|
||||
* themed button touch this element's own clip box and lose its corners to it. */
|
||||
/* padding: theme/60-inputs.css — absorbed. Zero side padding here let a 36px themed button
|
||||
* touch this element's own clip box and lose its corners. */
|
||||
.cbi-filebrowser > * {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
@@ -584,16 +499,9 @@
|
||||
margin-inline-end: .25em;
|
||||
}
|
||||
|
||||
/* `.cbi-filebrowser > *` above gives every row `nowrap` + ellipsis on the assumption that a row
|
||||
* is ONE LINE OF TEXT — true for the path and each listing row, false for `.right`: its content
|
||||
* is the upload strip (its own flex row) plus the Create/Cancel buttons, none of it text that an
|
||||
* ellipsis can stand in for. `nowrap`, inherited by `.upload` and its Browse button, was blocking
|
||||
* the wrap that button already had (`flex-wrap: wrap`, theme/60-inputs.css) from ever firing, and
|
||||
* `overflow: hidden` had no ellipsis to show for a clipped BUTTON — pseudo-loc measured Cancel's
|
||||
* label rendered 76px past this box with no "…" drawn, the severe case the gate's own docs call
|
||||
* "text the reader cannot reach", not the accepted dropdown/tab shape. `normal` restores the
|
||||
* ordinary wrap a button row gets everywhere else in the theme; `visible` undoes a clip a
|
||||
* text-only rule has no business applying to a row of controls. */
|
||||
/* .right is not one line of text (upload strip + Create/Cancel), so it opts out of the
|
||||
* nowrap+ellipsis assumption above: docs/css.md "base/95-luci.css: the file browser's floor,
|
||||
* and the row that is not text". */
|
||||
.cbi-filebrowser > .right {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
@@ -608,9 +516,8 @@
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* row hover surface, and the row's whole flex sizing: theme/60-inputs.css — absorbed. The
|
||||
* proportional split that lived here (name `flex: 10` / actions `flex: 3`) fitted base's ~20px
|
||||
* buttons and not the theme's 36px ones, which is how a file row came to clip its own Delete. */
|
||||
/* row hover surface + flex sizing: theme/60-inputs.css — absorbed (this file's old
|
||||
* name:10/actions:3 split fitted base's ~20px buttons, not the theme's 36px ones). */
|
||||
.cbi-filebrowser > ul > li > div:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -659,15 +566,10 @@
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* The `fade-in` keyframes above stay although nothing here rides them: it is an upstream
|
||||
* animation NAME a luci-app-* may ride (coverage contract, docs/conventions.md). The theme's own
|
||||
* rule on the `.fade-in` CLASS is gone on purpose — luci-mod-status re-marks with it on every
|
||||
* poll tick, so anything hung there replays on every tick across the whole column.
|
||||
*
|
||||
* `fs-fade` is the theme's own and is one-shot: the rail flyout and the search overlay each
|
||||
* play it once, when the surface opens, which is why its 4px rise is right there and wrong on
|
||||
* a class the poll rewrites. Both keyframes live here because every keyframes in this theme
|
||||
* does, and an unresolvable animation-name is simply dropped, silently. */
|
||||
/* `fade-in` stays although nothing here rides it: an upstream animation NAME a luci-app-* may
|
||||
* ride (coverage contract, docs/conventions.md). The theme's own `.fade-in` CLASS rule is gone
|
||||
* on purpose — luci-mod-status re-marks it on every poll tick, replaying anything hung there.
|
||||
* `fs-fade` is one-shot instead, played once by the rail flyout and the search overlay. */
|
||||
@keyframes fs-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
@@ -698,24 +600,15 @@
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* Statistics renders its graphs as PNGs on a white ground, so dark mode inverts them. 180deg is
|
||||
* arithmetic rather than taste: invert() maps every hue to h+180, so rotating 180 back restores
|
||||
* the original hue while keeping the inverted lightness. Read back off collectd's own cpu
|
||||
* series, at 150deg System (h=0) came back at h=301 and User (h=240) at h=180 — 59 and 60
|
||||
* degrees out, i.e. the blue plot was drawn cyan. At 180deg all three primaries are exact.
|
||||
*
|
||||
* It is not exact for every colour and cannot be: CSS hue-rotate is a linear matrix
|
||||
* approximation, so a non-primary survives only approximately (amber lands 13deg out). Do not
|
||||
* tune the angle — moving off 180 trades an exact red/green/blue for a marginally better
|
||||
* amber. */
|
||||
/* PNGs on a white ground, inverted for dark mode: docs/css.md "base/95-luci.css: Statistics
|
||||
* graphs and Realtime SVGs are art drawn for a white ground" — 180deg is exact for the three
|
||||
* primaries, not tuned further. */
|
||||
[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
|
||||
/* Realtime graphs (Status → Load/Traffic) inject resources/svg/*.svg into a div with
|
||||
* an inline style. The <svg> root carries none itself, so the background needs no flag.
|
||||
* Its grid <line>s do — `style="stroke:black"` in the .svg — and a black hairline is
|
||||
* invisible on a dark panel; only an author !important beats an inline declaration. */
|
||||
/* Realtime SVGs carry inline style= for a black ground; only an author !important beats it:
|
||||
* same doc section. */
|
||||
#view div[style] > svg {
|
||||
background-color: var(--fs-panel);
|
||||
}
|
||||
@@ -724,20 +617,8 @@
|
||||
stroke: var(--fs-text) !important;
|
||||
}
|
||||
|
||||
/* …and the axis labels beside them: every <text> in the .svg carries an inline fill of the
|
||||
* file's own light grey plus a black halo, drawn for the black background the file assumes
|
||||
* rather than for the panel this theme paints. On a light palette that grey lands on white at
|
||||
* 1.16:1, legible only as the shadow around it, against 17.9:1 for --fs-text, 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.
|
||||
*
|
||||
* The weight is not decoration: the SVG hardcodes a 9pt label, the smallest type this theme
|
||||
* renders, and at the regular weight also the thinnest.
|
||||
*
|
||||
* A CHILD combinator, and the difference is a page: Channel Analysis derives a colour per BSSID
|
||||
* and paints both the curve and its SSID label with it, and those labels are built inside a
|
||||
* <g>, while the grey axis labels are direct children of the <svg>. A descendant selector
|
||||
* recolours both and leaves the curves as they were; `> text[style]` reaches only the axis. */
|
||||
/* child combinator, not descendant: reaches only the axis labels, not Channel Analysis's
|
||||
* per-BSSID curve labels nested inside a <g> — same doc section. */
|
||||
#view div[style] > svg > text[style] {
|
||||
fill: var(--fs-text) !important;
|
||||
text-shadow: none !important;
|
||||
|
||||
@@ -1,51 +1,20 @@
|
||||
@layer page {
|
||||
/* LOGIN — the server-rendered form of themes/footstrap/sysauth.ut, as a centred card.
|
||||
*
|
||||
* Scoped by `.fs-login`, a class that template puts on the form: the form is ours, so naming it
|
||||
* is free, where the `form:has(> .cbi-map input[name="luci_username"])` it replaced cost 49
|
||||
* characters × 17 rules, ~0.8 KB of the stylesheet. Nothing else ever matched that selector —
|
||||
* ui.js builds no login form of its own and has no `luci_username` anywhere.
|
||||
*
|
||||
* The theme ships its own sysauth.ut so the page carries NO chrome: without one, LuCI falls back
|
||||
* to the generic template, which includes the header without blank_page and draws the whole
|
||||
* sidebar around a page whose only control is a password field.
|
||||
*
|
||||
* Do not turn this into a modal (form in a `<section hidden>`, revealed by a view module): the
|
||||
* view runs before a session exists, its RPCs answer "Access denied", render() never runs, and
|
||||
* the page is blank with no way to log in. Server-rendered, the form works with JS off. */
|
||||
/* LOGIN — themes/footstrap/sysauth.ut's own template, centred card, no chrome. Scoped by
|
||||
* `.fs-login`, a class the template puts on the form: replaced
|
||||
* `form:has(> .cbi-map input[name="luci_username"])`, 49 chars × 17 rules (~0.8 KB) matching
|
||||
* nothing else. Never turn this into a JS-revealed modal: the view runs pre-session, RPCs answer
|
||||
* "Access denied", render() never runs. docs/css.md "Login: a definite width, not max-width". */
|
||||
.fs-login {
|
||||
/* A DEFINITE width, never `max-width`: the card is a direct flex item of <body>
|
||||
* (base/20-typography.css makes body a flex column so the footer can margin-top:auto),
|
||||
* and on the cross axis an `auto` side margin overrides the stretch and sizes the item
|
||||
* to FIT-CONTENT. `max-width: 400px` capped nothing — the card collapsed to its
|
||||
* min-content, ~131px, and the description wrapped one word per line. */
|
||||
/* DEFINITE width, never max-width: a direct flex item of <body> collapses to min-content
|
||||
* (~131px) under max-width alone — docs/css.md above. */
|
||||
width: min(400px, calc(100% - 32px));
|
||||
margin: 7vh auto var(--fs-space-10);
|
||||
background: var(--fs-panel); border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* the token, not a literal 45%-black 60px halo: that ignored the palette and drew
|
||||
* a heavy grey cloud under a white card in light mode. */
|
||||
/* the token, not a literal 45%-black 60px halo: that ignored the palette. */
|
||||
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): a centred identity block, set apart
|
||||
* from the form, above a left-aligned heading and left-aligned fields — the shape an appliance
|
||||
* console uses. Centred and separated is what makes the mixed alignment read as deliberate
|
||||
* rather than as a stray line, the 24px gap below being the card's own padding.
|
||||
*
|
||||
* Still smaller than the heading it stands over (16px against its 20px): it answers "which box
|
||||
* is this", which matters only until you have read it, while `Authorization Required` answers
|
||||
* "what now", the page's actual subject. Set larger, the two swap roles and the heading reads
|
||||
* as a subtitle.
|
||||
*
|
||||
* Not a heading element, on purpose: the outline is about the page's subject, and a heading
|
||||
* above it would announce the login form as two sections.
|
||||
*
|
||||
* 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. */
|
||||
/* router hostname, not the eyebrow tokens: those force uppercase and a hostname is a name
|
||||
* (case is the only thing telling two similar ones apart). docs/css.md above. */
|
||||
.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);
|
||||
@@ -56,19 +25,13 @@
|
||||
.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. `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. */
|
||||
/* the login card IS the panel; a second frame here would nest one inside it */
|
||||
.fs-login .cbi-map > h1,
|
||||
.fs-login .cbi-map-descr {
|
||||
background: none; border: 0; border-radius: 0; box-shadow: none; padding: 0;
|
||||
}
|
||||
/* 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. */
|
||||
/* h1, not h2: no title bar on this page to carry the document's h1, so sysauth.ut emits it here
|
||||
* and the ramp's own h1 size wraps at 400px — reset to h2's size. docs/css.md above. */
|
||||
.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);
|
||||
}
|
||||
@@ -87,8 +50,7 @@
|
||||
width: 100%; margin: 0; box-sizing: border-box;
|
||||
}
|
||||
.fs-login .alert-message { margin: 0 0 var(--fs-space-3-5); border-radius: var(--fs-radius); }
|
||||
/* a column, not a block: some auth plugins add a second action (Reset), and the
|
||||
* full-width buttons below carry margin:0 — they ended up glued together */
|
||||
/* column, not block: an auth plugin's second action (Reset) glues to Login under margin:0 */
|
||||
.fs-login .cbi-page-actions {
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2-5);
|
||||
background: none; border: 0;
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
@layer page {
|
||||
/* Every !important here fights an inline `style=` — nothing else can, an inline declaration
|
||||
* outranking every author rule in every layer. luci-mod-status's 29_ports.js writes the ports
|
||||
* grid, its tiles and their inner rows with inline declarations: `display`,
|
||||
* `grid-template-columns` and `margin-bottom` on the grid, `margin`/`min-width`/`max-width` on
|
||||
* the tile, `display` on the heads, `text-align`/`font-size` on the traffic cell. Each flag is
|
||||
* matched against the property that inline style really sets; one fighting only another
|
||||
* footstrap rule would be redundant, `page` being the last layer. No cascade layer can replace
|
||||
* the flags that remain — keep them. */
|
||||
/* Every !important here fights an inline style= from luci-mod-status's 29_ports.js — page is
|
||||
* the last layer, so a flag fighting only another footstrap rule is not kept. docs/css.md
|
||||
* "Overview: the port-tile reskin". */
|
||||
|
||||
/* The `.cbi-title .label` pill: theme/35-alerts.css (one owner — a copy here had
|
||||
* 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.
|
||||
*
|
||||
* 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. */
|
||||
/* decoration only, glyph aria-hidden — docs/css.md "Overview: the disclosure pill, the grid,
|
||||
* and the duplicate heading" */
|
||||
.cbi-title .label[data-indicator="poll-status"] {
|
||||
font-size: 0;
|
||||
width: 16px;
|
||||
@@ -31,10 +17,7 @@
|
||||
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
|
||||
* ours keyed by class, which no single selector list in one layer can carry. Pinned instead:
|
||||
* tools/mirror.mjs fails the build if the copies drift (styles/pages/80-appearance.css). */
|
||||
/* pinned @mirror chevron/*, byte-identical to the Appearance fold's copy */
|
||||
.cbi-title .label[data-indicator="poll-status"]::after {
|
||||
/* @mirror chevron/closed */
|
||||
content: "";
|
||||
@@ -55,22 +38,8 @@
|
||||
/* @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. */
|
||||
/* two selectors: wrapper differs by release (25.12 .cbi-title>h3, 24.10 bare h3); matches 0 of
|
||||
* 14 h3 on live 24.10.8 — not dead there, that release has no hide/show control */
|
||||
:is(.cbi-title h3, .cbi-section > h3)[role="button"] {
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
@@ -80,44 +49,21 @@
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* Every page-scoped selector below anchors on `:where(#view)[data-page="…"]`, not a bare
|
||||
* `#view[data-page="…"]`: an id contributes (1,0,0), and this file's deepest chain (the
|
||||
* progressbar row's `.td:first-child` selector, further down) already carries the rest past
|
||||
* tools/css-metrics.mjs's ratchet on its own — `#view` on top of it measured [1,9,0], over the
|
||||
* ceiling of [1,7,0]. `:where(#view)` matches the exact same single element (an id is a valid
|
||||
* :where() argument) but contributes ZERO specificity, so nothing about which element matches
|
||||
* changes — including the `> div` case in 40-sshkeys.css, where a wider `:where(#view,
|
||||
* .fs-content)` was tried and rejected: `.fs-content` is #view's ANCESTOR, so its own `> div`
|
||||
* arm would have matched #view itself (a `<div>`, per fs-router.js's stageView()) rather than
|
||||
* #view's children — a different element, not the same one at lower specificity. Measured after:
|
||||
* [1,7,0], the ceiling now held by an unrelated selector elsewhere in the sheet. */
|
||||
/* every page-scoped selector below anchors on :where(#view)[data-page="…"], not bare #view —
|
||||
* docs/css.md ":where(#view), not bare #view". */
|
||||
|
||||
/* 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. */
|
||||
/* hideEmptyCard() marks a stock include with nothing to show; drop it */
|
||||
:where(#view)[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. */
|
||||
/* the chrome already shows the page title, so the stock <h2> is a duplicate */
|
||||
/* the chrome already shows the page title, so the stock <h2> is a duplicate.
|
||||
* Scoped through .fs-content, not #view: the heading is a SIBLING of #view (a
|
||||
* dispatcher template emits it before the div), so it needs the OUTGOING page's
|
||||
* identity for the whole staging window, spared until commitStage the same way
|
||||
* fs-sheets spares a page's stylesheets — fs-router.js. */
|
||||
/* stock <h2> duplicate of the chrome's title; via .fs-content not #view — docs/css.md above */
|
||||
.fs-content[data-page="admin-status-overview"] h2[name="content"] { display: none; }
|
||||
.fs-ovl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--fs-space-4);
|
||||
/* no align-items: the default stretch is what levels the two columns' bottom edges */
|
||||
margin-bottom: var(--fs-card-gap); /* match stock .cbi-section spacing below the grid */
|
||||
}
|
||||
/* min-width:0 so a grid item shrinks to its TRACK, not to its content's min-width
|
||||
* (auto): a long System value (model/firmware) blew the single-column track past a
|
||||
* ~344px phone and clipped. */
|
||||
/* min-width:0 so an item shrinks to its TRACK, not its content's min-width — docs/css.md above */
|
||||
.fs-ovl > .fs-ovl-sys,
|
||||
.fs-ovl > .fs-ovl-mem,
|
||||
.fs-ovl > .fs-ovl-sto { margin: 0; min-width: 0; }
|
||||
@@ -131,245 +77,97 @@
|
||||
.fs-ovl > .fs-ovl-sto { grid-column: 1; grid-row: auto; }
|
||||
}
|
||||
|
||||
/* Stock Port status (29_ports.js) reskin — pure CSS over the stock .ifacebox markup: icon-in-a-box
|
||||
* tiles become left-aligned cards (name + link dot, speed, TX/RX).
|
||||
*
|
||||
* Every rule is scoped to `.ifacebox:has(img[src*="/port_"])` because Network and Wireless reuse
|
||||
* the same .ifacebox classes and only Port tiles carry a port_*.svg icon. Do not unscope. Link
|
||||
* state is the icon src; child order: 1 name-head, 2 body (icon+speed), 3 zone bar, 4 body
|
||||
* (traffic). */
|
||||
/* The tracks pack the row instead of leaving air beside it, and every card is the same width.
|
||||
*
|
||||
* `repeat(auto-fit, minmax(126px, 200px))` reads as "shrink toward 126 when there are many
|
||||
* ports", and a grid does not work that way: `auto-fit` picks the track COUNT from the definite
|
||||
* MAX of the track function, so the count is always `floor(width / 200)` and the floor is
|
||||
* reached only when the column itself is narrower than that. An 11-port switch in a 1190px
|
||||
* column then gets 5 tracks of 200px in three rows with 150px of air beside every one.
|
||||
*
|
||||
* `minmax(92px, 1fr)` asks it the other way round: with an indefinite max the count comes from
|
||||
* the 92px min, so the row takes as many tiles as fit and `1fr` shares the remainder equally. */
|
||||
/* Port status reskin. Scoped to :has(img[src*="/port_"]) — do not unscope, Network/Wireless
|
||||
* reuse the same .ifacebox classes. Child order: 1 name-head, 2 speed, 3 zone bar, 4 traffic. */
|
||||
:where(#view)[data-page="admin-status-overview"] div:has(> .ifacebox img[src*="/port_"]) {
|
||||
/* 8px, not 10: at the floor below, the gap is what decides whether an 11-port switch takes one
|
||||
* row or ten-plus-one. A 1190px column fits 11 cards of 101px with 8px between them and 10
|
||||
* of 110px with 10px — the two px come off the gutter rather than off the card, because the
|
||||
* card's own floor is a measurement (see below) and the gap is not. No inline gap to fight,
|
||||
* so no flag. */
|
||||
/* 8px not 10: an 11-port switch in a 1190px column fits 11 cards only at 8px gap */
|
||||
gap: var(--fs-space-2);
|
||||
/* The floor is the traffic figure, and it is written as one: `▲ 1024.0 PiB` is the widest
|
||||
* string luci.js's `%1024.1mB` can produce, and the figures are `nowrap`, so a card too narrow
|
||||
* for one pushes it past its own padding instead of wrapping it. Measured rather than guessed:
|
||||
* stepping a card 1px at a time until the figure fits
|
||||
* on one line and the card stops overflowing gives 94px at Normal, 80 at Compact and 109 at
|
||||
* Large, so the text scales with the density axis and the gutter is already a token — each
|
||||
* setting gets its own answer (100 / 84 / 111) instead of one number that is wrong on two of
|
||||
* them. Held by a sweep of every width from 320 to 1900 in 10px steps at all three densities
|
||||
* with 24 cards of maximal content: 0 overflowing, 0 figures wrapped, narrowest card 93px. */
|
||||
--fs-port-min: calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--fs-port-min), 1fr)) !important;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(76px * var(--fs-density-type) + var(--fs-space-3) * 2), 1fr)) !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
/* Flex-wrap, not a grid with a threshold, and that is the fix for issue #7's shifted layout.
|
||||
*
|
||||
* As `display: grid; grid-template-columns: 1fr auto`, "do those two still fit side by side?"
|
||||
* was answered by a @container threshold — a proxy for a question about the CONTENT, calibrated
|
||||
* on English: `no link` is ~45px and `нет соединения` ~100px, while the traffic figures are
|
||||
* `nowrap` by design, so on a Russian router a down port that had carried traffic needed ~193px
|
||||
* inside a 178px content box, and a grid does not wrap — the figures overflowed under the next
|
||||
* card. With the counters at zero the same card fitted.
|
||||
*
|
||||
* Flexbox asks the real question for free: the two cells sit on one row while they fit and the
|
||||
* traffic drops to its own row when they do not. */
|
||||
/* flex-wrap, not a grid+@container threshold (issue #7) — docs/css.md above */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) {
|
||||
margin: 0 !important;
|
||||
min-width: 0 !important;
|
||||
/* The cap half of the pair that sizes the tile (the 126px min is on the wrapper's tracks —
|
||||
* see the note there): 200px is where a 2-port device stops growing, so two ports do not
|
||||
* become two half-page cards. It carries a flag because stock writes `max-width:100px`
|
||||
* inline and the ImmortalWrt fork writes its own. */
|
||||
/* caps a 2-port device growing into two half-page cards; fights stock's inline max-width */
|
||||
max-width: 200px !important;
|
||||
/* …and `width` too, which stock never writes but a fork does: ImmortalWrt 25.12 carries
|
||||
* `style="margin:.25em;width:100px"` instead of stock's min/max pair, so releasing only the
|
||||
* two stock properties leaves the fork's fixed width in charge and the tile draws itself
|
||||
* 100px wide inside a 200px track. Naming all three costs nothing on stock. */
|
||||
/* ImmortalWrt 25.12 sets inline margin/width instead of stock's min/max pair */
|
||||
width: auto !important;
|
||||
/* …and that is only half of it: the same fork writes `align-items:center;justify-items:center` on
|
||||
* the grid CONTAINER, so a tile's `justify-self` resolves to `center` — and a centred item
|
||||
* with `width: auto` is sized fit-content rather than stretched to its track, so releasing
|
||||
* the fork's width leaves the tile as narrow as its content and the rows spill to the right.
|
||||
* Stating the self-alignment needs no flag: the fork's declarations are `*-items` on the
|
||||
* PARENT, which only supply a value when the item says `auto`, and a direct `*-self` wins on
|
||||
* the cascade proper. */
|
||||
/* same fork centres the grid CONTAINER; *-self wins over parent *-items, no flag needed */
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
text-align: start;
|
||||
background: var(--fs-panel2);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* the horizontal half is what --fs-port-min budgets for (the wrapper rule above), so it stays
|
||||
* --fs-space-3; the vertical half is one step tighter, because a port card is four short
|
||||
* lines and every px of leading between them shows. */
|
||||
padding: var(--fs-space-1-5) var(--fs-space-3);
|
||||
/* No `container-type` here, and its absence is the fix for a page that jumped every five seconds.
|
||||
* The card carried `container-type: inline-size` for two @container queries that were deleted
|
||||
* when the flex wrap below replaced their pixel threshold, and the containment was left
|
||||
* behind with nothing asking it anything.
|
||||
*
|
||||
* It was not free: the poll rewrites these cards once a tick, and a freshly built
|
||||
* size-contained box is laid out before the containment resolves, so everything below it
|
||||
* moved by the same amount on every tick. Bisected on the reporter's router down to this one
|
||||
* declaration.
|
||||
*
|
||||
* If a future rule needs a container query here, name the container on a wrapper the poll does
|
||||
* NOT replace, never on the card itself. */
|
||||
/* no container-type: broke poll-replaced cards before containment resolved (bisected) */
|
||||
display: flex;
|
||||
/* `flex-direction: row` is NOT redundant: base/95-luci.css paints every .ifacebox
|
||||
* `display: inline-flex; flex-direction: column`, and the grid this rule used to declare
|
||||
* made that direction moot. Switching to flex woke it up — the name, the speed and the
|
||||
* figures stacked into a column, `flex-basis: 100%` became a HEIGHT, and flex-grow grew the
|
||||
* wrong axis. Cost an hour; state the axis. */
|
||||
/* NOT redundant: base/95-luci.css sets .ifacebox inline-flex column */
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
/* row-gap 0: every row inside the card carries its own spacing (the zone bar's margins, the
|
||||
* name's line box), and a gap on top of that is what made a five-line card 19px taller than
|
||||
* its content. */
|
||||
/* row-gap 0: every row carries its own spacing already (zone bar margins, name line box) */
|
||||
column-gap: var(--fs-space-2); row-gap: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
/* drop the stock icon */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) img { display: none; }
|
||||
/* base/95-luci.css draws a border-bottom on EVERY .ifacebox-head; the port card carries
|
||||
* two heads (the name, child 1, and the zone-colour line, child 3), so that is two grey
|
||||
* dividers stacked around the colour line. Drop them — the colour line is the only
|
||||
* divider this card wants. Page layer beats base, no !important needed. */
|
||||
/* two stock border-bottoms stack around the colour line; drop both */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) .ifacebox-head { border-bottom: 0; border-radius: 0; }
|
||||
/* Zone-colour bar (child 3): WAN red, LAN green — the stock Port-status indicator that
|
||||
* distinguishes the Internet port from the LAN ports (issue #13). Its inner .zonebadge segments
|
||||
* already carry firewall.getZoneColorStyle() inline and a `.cbi-tooltip` naming the zone, so
|
||||
* this only PLACES it: a full-width colour line on its own row between the name and the
|
||||
* speed/traffic bodies, where it reads as the card's divider. `order` puts it there — without it
|
||||
* the bodies fall in source order and the bar splits speed from traffic. No `overflow` or
|
||||
* `border-radius`: both clip the absolutely-positioned tooltip. */
|
||||
/* zone-colour bar (WAN red/LAN green, issue #13); order places it between name and traffic.
|
||||
* No overflow/border-radius: both clip the absolutely-positioned .cbi-tooltip */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:nth-child(3) {
|
||||
order: 1;
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
margin: var(--fs-space-0-5) 0 var(--fs-space-1);
|
||||
}
|
||||
/* The name row, with no status dot: the speed line and the zone bar already report the carrier,
|
||||
* and a `-webkit-box` lays its children out as lines, so a dot could not stay in the flow — it
|
||||
* would join the name's text and be clipped with the second line. */
|
||||
/* no status dot: speed + zone bar report carrier, -webkit-box lays children as lines */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-head:first-child {
|
||||
/* Its own row, and `width` rather than `flex-basis: 100%`: the two agree now that the card's
|
||||
* size containment is gone (see above), and this form says what it means without depending on
|
||||
* the card's sizing staying definite. */
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
/* Two lines of name, never three, and never a name that decides how wide every card is.
|
||||
*
|
||||
* As a flex row with the name on one `nowrap` line, the LONGEST interface name becomes the
|
||||
* minimum width of every tile on the page. Wrapping alone is not the answer either: a name
|
||||
* with no space and no hyphen to break at gets three ragged lines from `overflow-wrap:
|
||||
* anywhere` and the row grows to fit them.
|
||||
*
|
||||
* So: a `-webkit-box` clamped to two lines, the one shape that both wraps AND puts an
|
||||
* ellipsis where it stops (`text-overflow` alone only ever truncates a single line).
|
||||
* `line-clamp` is written beside the prefixed pair for the engines that have taken the
|
||||
* unprefixed property; the prefixed spelling is the one that works everywhere today and is in
|
||||
* the spec's own compat section.
|
||||
*
|
||||
* No second line is reserved: `min-height: 2lh` was tried and cost 19px of empty air in every
|
||||
* card whose name does not wrap (a 5-port device stood 126px against 107px), while the grid
|
||||
* levels the row either way. The explicit `line-height` stays — it is what makes a wrapped
|
||||
* name's two lines the theme's rhythm rather than the font's own default. */
|
||||
/* ONE LINE, cut with an ellipsis. base/95-luci.css already says `white-space: nowrap`; what
|
||||
* this adds is the pair that makes the overflow READABLE instead of clipped — and `min-width`,
|
||||
* because a nowrap box's automatic minimum size is its whole text, which would put the longest
|
||||
* interface name back in charge of how wide every card is. */
|
||||
/* clamped to 2 lines never 3: unclamped nowrap name sets every tile's min width */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: var(--fs-leading);
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-text);
|
||||
}
|
||||
/* no link (*_down.svg): dim name, to match the dim speed and the faded zone bar */
|
||||
/* no link (*_down.svg): dim to match the dim speed and faded zone bar */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-head:first-child { color: var(--fs-dim); }
|
||||
/* speed: green when linked, dim when not */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) {
|
||||
order: 2; /* below the zone-colour line (child 3, order 1) */
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-good); text-align: start;
|
||||
/* `flex-grow: 1` means "I take the slack", which pushes the traffic to the right edge while
|
||||
* the two share a row. `margin-left: auto` on the traffic is wrong: Chrome counts it when
|
||||
* breaking lines, so the figures take a row of their own even on a card they fit.
|
||||
*
|
||||
* The text WRAPS, and the automatic minimum size is what keeps the wrap tidy. This field used
|
||||
* to be `nowrap`, which holds for the speeds themselves (`1GbE`, `10 GbE`) and breaks on the
|
||||
* no-link label — one short word in English, three in most other languages. `нет соединения`
|
||||
* measured 127px in a card that gives it 127 and was CLIPPED, not ellipsised: the reader saw
|
||||
* `нет соедине`. A label that cannot be read is worse than a card one line taller.
|
||||
*
|
||||
* NO `min-width: 0` with it. That would let the flex line squeeze this box to 77px — the
|
||||
* width left beside the port image, which shares this box — and the label then broke mid-word
|
||||
* (`соедине` / `ния`). Left at `auto` the box keeps its min-content, which for wrapping text
|
||||
* is the longest WORD, so the label breaks between words and the short speeds, having nothing
|
||||
* to wrap at, do not move at all. `overflow-wrap` is the last resort for a language whose
|
||||
* single word is wider than the card. */
|
||||
/* margin-left:auto forces its own row in Chrome's line-break count; flex-grow instead */
|
||||
flex: 1 1 auto; white-space: normal; overflow-wrap: break-word;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(2) br { display: none; }
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]):has(img[src*="_down.svg"]) > .ifacebox-body:nth-child(2) { color: var(--fs-dim); }
|
||||
/* traffic: tx over rx, as stock's <br> leaves them — CSS cannot reorder br-separated
|
||||
* text runs. The size flag fights the inline `font-size:80%`. */
|
||||
/* traffic: tx over rx, as stock's <br> leaves them — CSS cannot reorder br-separated text.
|
||||
* Size flag fights the inline font-size:80%. */
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) {
|
||||
order: 2; /* shares the speed's row, below the zone line */
|
||||
text-align: end;
|
||||
/* Grows but never shrinks. Growing is what anchors the figures to the card's right edge on either
|
||||
* row, since `text-align: right` then has a box that reaches that edge; `margin-left: auto`
|
||||
* would do the same on a shared row but Chrome counts it when breaking lines.
|
||||
* `flex-shrink: 0` keeps the nowrap figures from being squeezed into an overflow.
|
||||
*
|
||||
* `flex-basis: 100%` is what makes the figures take a row of their OWN always, not only on a
|
||||
* card too narrow to share one: decided per card by the content, a port with zero counters
|
||||
* keeps speed and traffic side by side while the port beside it pushes them apart — two
|
||||
* cards of the same width reading as two different layouts. */
|
||||
/* grows to anchor text-align:end, never shrinks (nowrap figures) */
|
||||
flex: 1 0 100%;
|
||||
}
|
||||
:where(#view)[data-page="admin-status-overview"] .ifacebox:has(img[src*="/port_"]) > .ifacebox-body:nth-child(4) > .cbi-tooltip-container {
|
||||
font-family: var(--fs-font-mono);
|
||||
/* 10px at Normal, a step below the theme's --fs-type-xs (11px), and it BUYS WIDTH: this figure
|
||||
* is the widest thing a port card must fit whole, so its size sets the card's floor — one pixel
|
||||
* here is 12px off every card, measured: the floor goes 106 → 94 at Normal, 92 → 80 at Compact
|
||||
* and 119 → 109 at Large. It stays on the density axis and off the type LADDER, which has no
|
||||
* rung here, because a figure read as a pair of numbers beside a label tolerates a step body
|
||||
* text would not. */
|
||||
/* The token, not a literal: --fs-type-2xs is one step below the ladder's smallest and exists
|
||||
* for these figures (02-tokens.css says why, and rounds it where round() exists). The flag
|
||||
* fights 29_ports.js's inline `font-size:80%`. */
|
||||
/* widest thing a card must fit whole, sets the card's floor; fights inline font-size:80% */
|
||||
font-size: var(--fs-type-2xs) !important;
|
||||
color: var(--fs-dim);
|
||||
/* the tight leading, not the body one: these are two lines of figures read as a block, and
|
||||
* --fs-leading (1.5) spent 5px of card height on the space between them. */
|
||||
line-height: var(--fs-leading-tight);
|
||||
white-space: nowrap;
|
||||
/* Fights the element's own inline `text-align:left` (29_ports.js) so the up and down lines align
|
||||
* on their right edge and the units form a column. Scoped to a @container threshold it was
|
||||
* the pixel proxy the flex wrap above replaces: the cell now reaches the card's right edge
|
||||
* on whichever row it lands, so the alignment is one answer with no width to switch at. */
|
||||
/* fights the element's own inline text-align:left */
|
||||
text-align: end !important;
|
||||
}
|
||||
|
||||
/* Meter rows — any stock .table row carrying a .cbi-progressbar (Memory, Storage, CPU
|
||||
* load, Network connections, …): label + value on one line, bar below. Keyed off the
|
||||
* progressbar (:has), not per-section, so it covers every such row — including a
|
||||
* third-party one. The bar itself is theme/25-progressbar.css. */
|
||||
/* meter rows — any stock .table row with a .cbi-progressbar. docs/css.md above. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar) { display: block; }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--fs-space-1) var(--fs-space-2-5);
|
||||
/* Halved from 8/10. A progressbar row is a LABEL over a 10px bar — the row is already
|
||||
* two lines tall, so the outer padding was doing the work of a gap twice over and Memory
|
||||
* and Storage read as mostly empty space. The bottom stays the larger of the two: the bar
|
||||
* sits on that edge, and equal padding reads as the bar hanging low in its row. */
|
||||
/* halved from 8/10: the outer padding duplicated the gap. Bottom stays larger for the bar. */
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
border: 0;
|
||||
position: relative;
|
||||
@@ -377,42 +175,24 @@
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td {
|
||||
display: block;
|
||||
/* beats base's `.td[width="33%"]` with no flag: the includes set a width
|
||||
* ATTRIBUTE, not an inline style, so a later layer wins. */
|
||||
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* label. Undoes the key/value `white-space: nowrap` on a first column: a mount point
|
||||
* ("192.168.3.26:/volume1/… (/mnt/hdd_isync)") is one unbreakable line, so the row's
|
||||
* min-content width blew past the card and Memory/Storage stopped shrinking on a phone.
|
||||
* padding-inline-end = the gutter for the value, which is pinned over the bar's far edge
|
||||
* (::after, out of flow — nothing else reserves space for it). */
|
||||
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
|
||||
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
grid-column: 1; grid-row: 1;
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
/* overflow-wrap dropped — base/40-tables.css breaks every cell; the copy that stood here
|
||||
* moved nothing against 267 matching cells. */
|
||||
padding-inline-end: min(230px, 50%);
|
||||
/* colour and weight are the shared label rule at the end of this file; the size is here
|
||||
* because it is this row's geometry, not its typography */
|
||||
font-size: var(--fs-type);
|
||||
}
|
||||
|
||||
/* On a narrow card the value takes its own line — a reserve cannot be made to fit there.
|
||||
*
|
||||
* The value is out of flow (the bar's ::after, pinned over its far edge), so the only thing
|
||||
* keeping it off the label is that padding, and the two are sized by different things: the
|
||||
* padding is a fraction of the CARD, the value is however wide LuCI's formatter made it.
|
||||
* `16.66 GiB / 16.66 GiB (100%)` measures 218px against 190px of reserve on a 380px phone card,
|
||||
* so the figure printed straight through the end of the label.
|
||||
*
|
||||
* Below the threshold the label keeps the whole line and the bar drops by one --fs-type-xs line,
|
||||
* which is the band the value then sits in. `fs-view` rather than a container of its own: adding
|
||||
* `container-type` to the card would size-contain a box the port tiles deliberately keep
|
||||
* uncontained (see the note by `padding` above). */
|
||||
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
|
||||
* above has the 218px/190px measurement. */
|
||||
@container fs-view (max-width: 560px) {
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
|
||||
padding-inline-end: 0;
|
||||
@@ -421,90 +201,53 @@
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
}
|
||||
}
|
||||
/* bar cell — spans full width on row 2 */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) {
|
||||
grid-column: 1 / -1; grid-row: 2;
|
||||
}
|
||||
|
||||
/* Drop the per-row dividers theme/30-tables.css adds (.cbi-section .table .td) —
|
||||
* cleaner card. No !important: that rule is `theme`, this file is `page`, so the later
|
||||
* layer already wins.
|
||||
*
|
||||
* Keyed on the table's SHAPE rather than on the three cards the grid happens to name: a
|
||||
* key/value table is one with no header row, which is the same test the meter rows above
|
||||
* already use. Naming `.fs-ovl-mem/-sto` left System ruled at 1px per row — nine dividers down
|
||||
* a card whose two columns are a name and its value, where the alignment already says which is
|
||||
* which — and gave a third-party include of the same shape nothing at all. A DATA table
|
||||
* (`.tr.table-titles` present: DHCP leases, the wifi station list) keeps its dividers: there
|
||||
* the row is one record among many and the rule is what separates the records. */
|
||||
/* per-row dividers dropped, keyed on table SHAPE (no header row) not on the three named cards —
|
||||
* docs/css.md above. A DATA table (.tr.table-titles present) keeps its dividers. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr { border-bottom: 0; }
|
||||
/* Its own rule, not a fourth line in the list above: `.cbi-value` carries no `:has()`, and a
|
||||
* selector list is not forgiving — below the browser floor the whole list would be discarded,
|
||||
* dividers and all (npm run css-floor, .claude/rules/css.md). */
|
||||
/* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not
|
||||
* forgiving below the browser floor (npm run css-floor) */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
|
||||
|
||||
/* The name in a key/value row is the QUIET half — the figure beside it is what the card is
|
||||
* read for. It came out at --fs-weight (600) in --fs-text, i.e. heavier and brighter than the
|
||||
* value it introduces, which is the ranking inverted: four bold lines down a Memory card
|
||||
* competing with the four numbers they label. --fs-dim at normal weight is what the System
|
||||
* table's first column already resolved to, so the meter cards now read as the same family.
|
||||
* Data tables are excluded by the same header-row test as the dividers above: there the first
|
||||
* column is a record's name, not a caption. */
|
||||
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
|
||||
* first column already resolved to. Data tables excluded by the same header-row test above. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* ---- the active interface box ----
|
||||
*
|
||||
* theme/45-misc.css fills `.ifacebox-head.active` with --fs-accent and inks it --fs-on-accent.
|
||||
* On a form that is one small badge; on the Overview it is a full-bleed bar of saturated colour
|
||||
* across the card — three of them at once here (IPv4 Upstream, radio0, radio1), each louder
|
||||
* than the data underneath and than the page's own controls. The state it encodes is "this
|
||||
* interface is up", which is a STATUS, so it is said in the status colour and in the text
|
||||
* rather than in a fill: --fs-good on the same --fs-panel2 every other head carries.
|
||||
*
|
||||
* Scoped to this page deliberately: the same head on Network -> Interfaces sits among many
|
||||
* boxes where the fill is doing real work telling them apart. */
|
||||
/* .active fill (theme/45-misc.css) makes three saturated bars at once here; "Up" said in
|
||||
* colour+text on --fs-panel2 instead. Network -> Interfaces keeps the fill (tells boxes apart
|
||||
* there), so this is scoped to Overview only. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox .ifacebox-head.active {
|
||||
background: var(--fs-panel2);
|
||||
color: var(--fs-good);
|
||||
font-weight: var(--fs-weight);
|
||||
}
|
||||
|
||||
/* `L.itemlist` wraps every caption in <strong>, which theme/30-tables.css leaves at 700 — so
|
||||
* inside an interface box the captions (Protocol, Address, Gateway, DNS) are the boldest text
|
||||
* on the card and the addresses they introduce are not. Same inversion as the key/value rule
|
||||
* above, same answer; the value keeps --fs-text and needs no rule of its own. */
|
||||
/* L.itemlist wraps every caption in <strong> at 700 weight — the boldest text on the card,
|
||||
* inverted like the key/value rule above; the value keeps --fs-text with no rule of its own */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .ifacebox-body strong {
|
||||
font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* The meter's own figure, promoted to match the label it now outranks.
|
||||
*
|
||||
* theme/25-progressbar.css prints `attr(title)` above the bar at --fs-type-xs in --fs-dim,
|
||||
* which was the right pairing while the label beside it was 600 in --fs-text. With the label
|
||||
* moved down (above), a dim 11px figure left the row with nothing at full strength: on Memory
|
||||
* that is four bars whose only readable text is the caption. The number is what the card is
|
||||
* for, so it takes the body size and the body ink; the bar and the placement are untouched.
|
||||
*
|
||||
* Overview only. The same meter on Software and in the package manager sits in a form row
|
||||
* where the value is beside its own field label, not carrying the row on its own. */
|
||||
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
|
||||
* bars something at full strength. Overview only — on Software the value sits beside its own
|
||||
* field label. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after {
|
||||
font-size: var(--fs-type);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
|
||||
/* Row height in the Overview's DATA tables (DHCP leases, the wifi station list).
|
||||
*
|
||||
* theme/30-tables.css pads a data cell for a page that IS a table, where the row is the unit of
|
||||
* work and the padding is its hit target. On the Overview the same table is a card's contents,
|
||||
* read at a glance and never clicked except on its buttons, and eight leases at that height are
|
||||
* most of a screen: the DHCP card measures 740px against a System card of 440. One step down
|
||||
* the space ladder, so it follows the density axis like everything else, and only here. */
|
||||
/* row height in DATA tables (DHCP leases, station list): theme/30-tables.css pads for a page
|
||||
* that IS a table, here it's a card read at a glance — 8 leases measured 740px against a 440px
|
||||
* System card. One step down the space ladder, only here. */
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td,
|
||||
:where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th {
|
||||
padding-block: var(--fs-space-1-5);
|
||||
|
||||
@@ -1,105 +1,33 @@
|
||||
@layer page {
|
||||
/* Software disk-space bar -> the overview Memory/Storage meter: label + value on one
|
||||
* line, bar below (the value is the .cbi-progressbar title, lifted out by ::after).
|
||||
* Scoped by #view[data-page], NOT by :has(#disk-space-label) — 24.10 ships no such id.
|
||||
* #view, not body: the attribute lives on the view itself so the outgoing page keeps its
|
||||
* own rules for the whole staging window (fs-router.js, commitStage).
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity without changing which
|
||||
* element matches — 20-overview.css, top of `@layer page`, has the full reasoning and the
|
||||
* measured numbers (tools/css-metrics.mjs's ceiling). */
|
||||
/* Package list and disk-space bar. :where(#view): docs/css.md ":where(#view), not bare
|
||||
* #view". Full rationale for every rule below: docs/css.md "Software: the disk-space bar and
|
||||
* the carded package row". */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:first-child { position: relative; display: block; flex: 1 1 100%; width: 100%; margin: 0 0 var(--fs-space-1-5); }
|
||||
|
||||
/* The package list's row SHAPE. #packages is a DATA table (`<table class="table" id="packages">`,
|
||||
* no .cbi-section-table class; fs-select.js tags it .fs-dt off its .tr.cbi-section-table-titles
|
||||
* header), so it takes the shared card from
|
||||
* theme/30-tables.css when measured too narrow; this only changes how a row READS, a package
|
||||
* being a name plus a few short facts, so label-left / value-right on one line beats the
|
||||
* half-width pairs a config row wants.
|
||||
*
|
||||
* No @container guard, and do not re-add one: overflow is the trigger, and fs-select.js
|
||||
* measures it. */
|
||||
/* ---- the package NAME is an identifier: do not let the column collapse under it ----
|
||||
*
|
||||
* base/40-tables.css gives every data cell `overflow-wrap: anywhere` (theme/30-tables.css
|
||||
* narrows it to `break-word` for as long as a data table is a table), and `anywhere` — unlike
|
||||
* `break-word` — also lowers the column's min-content to a single character. Version and Size
|
||||
* are `nowrap` and cannot shrink, so auto table layout takes the whole shortfall out of column
|
||||
* 1: measured with the filter on "app", the name column came out 81-101px — narrower than
|
||||
* Size (88px) — and 6 of the first 8 names rendered broken mid-word, a hyphen making it worse
|
||||
* by offering a legal break point.
|
||||
*
|
||||
* nowrap here does not mean "never wrap": it makes the overflow HONEST, which is what
|
||||
* fs-select.js's measurement needs — where the row genuinely does not fit, the table cards. It
|
||||
* is the same trap idTower() works around in fs-select.js: `anywhere` hides the overflow by
|
||||
* shredding a cell, so nothing is left for fit.overflows() to read. Verified 560-1440px: 0
|
||||
* broken names, no table overflow, and the card threshold unchanged. */
|
||||
#packages:not(.fs-stacked) .tr > .td:first-child { white-space: nowrap; }
|
||||
|
||||
/* `:not(...)` on the header rows, and it is load-bearing: this selector carries an ID, so it
|
||||
* is (1,1,0) in the PAGE layer — it beat theme/30-tables.css's
|
||||
* `.table.fs-stacked .tr.cbi-section-table-titles { display: none }` and put the column
|
||||
* header back on screen in card mode, where every cell already prints its own label. */
|
||||
/* the id beats theme/30-tables.css's header-hiding rule in card mode — load-bearing. */
|
||||
#packages.fs-stacked .tr:not(.cbi-section-table-titles):not(.cbi-section-table-descr) {
|
||||
display: block; padding: var(--fs-space-3) var(--fs-space-4);
|
||||
}
|
||||
/* ---- the value is not one flex item, and space-between tears it apart ----
|
||||
*
|
||||
* A card row is label-left / value-right, which `justify-content: space-between` spreads over
|
||||
* the ::before and the cell's content. But flex makes every ELEMENT its own item, and the
|
||||
* filter on this page wraps each match in `<ins>`, so a matched name is two items and
|
||||
* space-between pushes them to opposite edges — the package name read as `luc` … 85px …
|
||||
* `i-app-acl`.
|
||||
*
|
||||
* So do not distribute the free space BETWEEN the items — hand it all to the label, and
|
||||
* everything else packs flush right in DOM order, however many elements the value is. */
|
||||
#packages.fs-stacked .td {
|
||||
display: flex; align-items: baseline; justify-content: flex-end;
|
||||
gap: 0; padding: var(--fs-space-1) 0; border: 0; text-align: end;
|
||||
/* `min-width: 0` and `width: auto` are not restated: theme/30-tables.css sets both on
|
||||
* `.table.fs-stacked .td`, which this table is one of, and switching them off here moved
|
||||
* nothing across 500 matching cells. `text-align: end` above IS this file's own — it
|
||||
* overrides that rule's `start`. */
|
||||
flex: none;
|
||||
}
|
||||
/* An auto inline-end margin is what replaces space-between: it eats the whole free line, so the
|
||||
* value packs flush against the far edge as one unbroken run. The inline-end padding keeps a gutter when the row is
|
||||
* full and the auto margin collapses to nothing (the `gap` used to do that, but a gap applies
|
||||
* between EVERY pair of items — including the two halves of a highlighted name). */
|
||||
#packages.fs-stacked .td[data-title]::before {
|
||||
content: attr(data-title); flex: 0 0 auto; text-align: start; margin-bottom: 0;
|
||||
margin-inline-end: auto; padding-inline-end: var(--fs-space-3-5);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking);
|
||||
color: var(--fs-eyebrow-color); white-space: nowrap;
|
||||
}
|
||||
/* Column 4 = Description. NOT `[data-title="Description"]`, which is a translated string and
|
||||
* `tools/css-i18n.mjs` fails the build on one. It used not to match even in English: LuCI builds
|
||||
* these cells from the heading's `innerText` (ui.js, `data-title`), which is RENDERED text, and
|
||||
* `.th` was uppercased here — so the attribute said "DESCRIPTION" and the literal matched ZERO
|
||||
* elements on every router (measured). The uppercase is gone (theme/30-tables.css, issue #39) and
|
||||
* the attribute now carries the app's own case; the column index is still the right anchor.
|
||||
* Columns: 1 Package name | 2 Version | 3 Size | 4 Description | 5 actions. */
|
||||
/* `overflow-wrap` dropped: base/40-tables.css breaks every cell now and this copy moved nothing
|
||||
* against 100 matching cells. `word-break` has now gone the same way for a different reason: its
|
||||
* neutraliser in theme/30-tables.css stopped excusing carded tables, so this sat under a flag and
|
||||
* could not be read. Nothing renders differently — `break-word` is `word-break: normal` plus
|
||||
* `overflow-wrap: anywhere` and the theme already gives every stacked cell the second half;
|
||||
* measured at 390px, this cell is 322 x 103 px on either side of the change. */
|
||||
/* column 4 = Description, matched by index — a `[data-title]` value is a translated string. */
|
||||
#packages.fs-stacked .td:nth-child(4) { display: block; text-align: start; }
|
||||
#packages.fs-stacked .td:nth-child(4)::before { display: block; margin-bottom: var(--fs-space-1); }
|
||||
#packages.fs-stacked .td.cbi-section-actions { justify-content: flex-end; margin-top: var(--fs-space-1-5); }
|
||||
#packages.fs-stacked .td.cbi-section-actions::before,
|
||||
#packages.fs-stacked .td:not([data-title])::before { content: none; }
|
||||
/* The "Disk space:" caption, keyed on WHAT IT SITS BESIDE rather than on `#disk-space-label`:
|
||||
* that id is 25.12+ only (25.12 emits `E('label',{'id':'disk-space-label'}, …)`, 24.10's
|
||||
* package-manager.js a bare `E('label',{}, …)`), so an id rule is dead on 24.10 and the caption
|
||||
* keeps base's 250px-wide label. The disk section is the only `label` immediately followed by a
|
||||
* `.cbi-progressbar`, which is the discriminator theme/90-responsive.css already uses for the
|
||||
* same element.
|
||||
*
|
||||
* Not `:has(:where(+ .cbi-progressbar))`: a leading combinator makes it a RELATIVE selector, and
|
||||
* only `:has()` accepts one — `:where()` takes a plain complex-selector list, so wrapping it
|
||||
* there makes the whole rule invalid and the browser drops it, which stylelint and css-metrics
|
||||
* both parse happily. */
|
||||
|
||||
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
|
||||
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
@layer page {
|
||||
/* SSH-Keys renders its whole view as a single bare <div> in #view with no .cbi-section wrapper, so
|
||||
* its text sits frameless over the wallpaper while every other page gets an opaque card. There
|
||||
* is no section markup to hook, so that lone wrapper is given the same card (the values are
|
||||
* theme/30-tables.css's `.cbi-section`).
|
||||
*
|
||||
* Scoped to this page on purpose: a general "card any bare #view > div" was tried and reverted —
|
||||
* it also wrapped whole tabbed views (Startup). Other bare-view pages get their own entry
|
||||
* here.
|
||||
*
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity while still matching the exact
|
||||
* same element, so `> div` still means "child of #view" — 20-overview.css, top of
|
||||
* `@layer page`, has the full reasoning, including why a wider `:where(#view, .fs-content)`
|
||||
* was rejected precisely because of this `>` combinator. */
|
||||
/* :where(#view): docs/css.md ":where(#view), not bare #view". Rest: docs/css.md "SSH-keys: the
|
||||
* card wrapper and the wide dynlist". */
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] > div {
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
@@ -19,9 +8,6 @@
|
||||
padding: var(--fs-space-4);
|
||||
}
|
||||
|
||||
/* An authorized_keys entry is a ~400-char base64 blob: the one dynlist that must not
|
||||
* take theme/60-inputs.css's 440px cap. Must live in `page` — in `base` it was dead,
|
||||
* since a layer beats specificity and theme's plain `.cbi-dynlist` capped it anyway. */
|
||||
:where(#view)[data-page="admin-system-admin-sshkeys"] .cbi-dynlist {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,10 @@
|
||||
@layer page {
|
||||
/* DHCP leases. The .leases/.leases6 tables (JS-tagged .fs-dt) carry 7-8 mono columns, and the
|
||||
* theme's general rule lets any data cell break to fit, which splits an IPv4 or a MAC over two
|
||||
* lines. So: every cell on ONE line, and only the long opaque columns (DUID, the IPv6 list, the
|
||||
* hostname) may wrap.
|
||||
*
|
||||
* Do not re-add a card-stack copy here at a container threshold of its own: the nowrap IS this
|
||||
* table's ~736px floor, and a floor is what makes a table OVERFLOW when the room runs out —
|
||||
* fs-select.js measures that and sets .fs-stacked, so the threshold is discovered and the card
|
||||
* lives once, in theme/30-tables.css. `:not(.fs-stacked)` is the whole guard: once carded, the
|
||||
* cells must wrap again.
|
||||
*
|
||||
* `.lases` is not a typo here — it is upstream's spelling for the IPv4 table on 24.10
|
||||
* (`class="table lases"`), corrected to `leases` on 25.12+. Dropping it made this whole file
|
||||
* dead on 24.10: measured there with the theme active, every one of the five cells computed
|
||||
* `white-space: normal` — issue #7's exact symptom. Both spellings are matched here. */
|
||||
/* DHCP leases nowrap by default; `.lases` is upstream's 24.10 spelling (issue #7), do not drop.
|
||||
* docs/css.md "Leases: nowrap counted from the end". */
|
||||
#view table.fs-dt:is(.leases, .lases, .leases6):not(.fs-stacked) .td { white-space: nowrap; }
|
||||
/* ---- never match a `data-title` VALUE: it is a translated, render-dependent string ----
|
||||
*
|
||||
* LuCI fills `data-title` from the column HEADING, so on a localised router the value is the
|
||||
* translation and an English literal in a selector never matches — the rule is dead in ~40
|
||||
* languages while looking alive here, which is how issue #7 survived.
|
||||
*
|
||||
* Worse, for tables whose rows LuCI builds from the heading's `innerText` the value is what the
|
||||
* heading RENDERS, so any transform the theme puts on `.th` rewrites the string the theme's own
|
||||
* CSS matches on. That is not hypothetical: `.th` carried `text-transform: uppercase` until
|
||||
* issue #39, and every such rule matched nothing on any router in any language.
|
||||
*
|
||||
* Anchor on the COLUMN instead: `tools/css-i18n.mjs` fails the build on any `[data-title="…"]`
|
||||
* value match, so this cannot come back. */
|
||||
|
||||
/* The long opaque columns, which may wrap. overflow-wrap:anywhere (not word-break) takes the
|
||||
* natural breaks first — the hyphens in a hostname — and only splits mid-token when nothing else
|
||||
* fits.
|
||||
*
|
||||
* ---- count from the END, never from the start ----
|
||||
*
|
||||
* The leading column is optional on 25.12+: `40_dhcp.js` opens both tables with an Interface
|
||||
* column only where the router serves DHCP through odhcpd, so every `:nth-child()` shifts by one
|
||||
* on exactly those routers and is invisible on the others. The TRAILING columns are identical
|
||||
* either way, so `:nth-last-child()` cannot shift.
|
||||
*
|
||||
* 25.12+ .leases [Interface] Hostname IPv4 MAC DUID IAID Remaining Static
|
||||
* -7 -6 -5 -4 -3 -2 -1
|
||||
* .leases6 [Interface] Hostname IPv6 DUID IAID Remaining Static
|
||||
* -6 -5 -4 -3 -2 -1
|
||||
* 24.10 .lases Hostname IPv4 MAC Remaining Static (no DUID/IAID column at all)
|
||||
* -5 -4 -3 -2 -1
|
||||
* .leases6 Host IPv6 DUID Remaining Static
|
||||
* -5 -4 -3 -2 -1
|
||||
*
|
||||
* so the hostname, the IPv6 list and DUID may wrap, while IPv4/MAC/IAID and the two short
|
||||
* trailing columns keep the nowrap above. BOTH tables are split on column COUNT, never on the
|
||||
* release and never on the class name: a one-character upstream fix to `lases` would otherwise
|
||||
* hand the 24.10 table the 25.12 column plan and silently un-fix issue #7. base/95-luci.css
|
||||
* states that as the rule for release-conditioned styling.
|
||||
*
|
||||
* The count test is wrapped in `:where()` so it costs NOTHING: `:has()` otherwise takes its
|
||||
* argument's specificity, and `.tr.table-titles > :nth-child(6)` alone pushed the selector to
|
||||
* [1,8,1], past the sheet's [1,7,0] ceiling. */
|
||||
/* opaque columns re-permitted to wrap; counted from the END, split on column COUNT — docs/css.md
|
||||
* above has both matrices and why :where() costs nothing. */
|
||||
#view table.fs-dt:is(.leases, .lases):has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
|
||||
.td:is(:nth-last-child(7), :nth-last-child(4)),
|
||||
#view table.fs-dt.leases6:has(:where(.tr.table-titles > :nth-child(6))):not(.fs-stacked)
|
||||
@@ -66,10 +13,6 @@
|
||||
.td:nth-last-child(5),
|
||||
#view table.fs-dt.leases6:not(:has(:where(.tr.table-titles > :nth-child(6)))):not(.fs-stacked)
|
||||
.td:is(:nth-last-child(5), :nth-last-child(4), :nth-last-child(3)) {
|
||||
/* `white-space` is the whole rule now: it is what re-permits wrapping for these columns
|
||||
* against the nowrap above — and the nowrap is also what keeps IPv4/MAC unbroken, so the
|
||||
* column plan does not rest on overflow-wrap at all. The break itself is base's default for
|
||||
* every cell (base/40-tables.css); the copy that stood here moved nothing against 164 cells. */
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
@layer page {
|
||||
/* Associated stations (Overview and Network -> Wireless render the same `.assoclist` table).
|
||||
*
|
||||
* No `white-space: nowrap` on the MAC column any more, though one stood here while
|
||||
* `overflow-wrap: anywhere` split `48:E1:5C:9F:26:20` at whatever character landed on the edge
|
||||
* of a ~103px column, every time (issue #5). A MAC has no break opportunity in it — a colon is
|
||||
* not one — so the honest floor theme/30-tables.css gives a data table keeps the whole address
|
||||
* in one piece, for every table that shows one rather than for this one.
|
||||
*
|
||||
* Do not nowrap the Signal and Rate columns either: that makes the modulation string
|
||||
* ("229 Mbit/s, 20 MHz, HE-MCS 9, HE-NSS 2") an unbreakable ~300px block that eats the row and
|
||||
* crushes the Network column. A wide cell that CAN break is the lesser evil, and that is what
|
||||
* rung 3 of fs-select.js's remedy ladder chooses when it has to choose: the widest breakable
|
||||
* column gives way, never the identity column. */
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
@layer page {
|
||||
/* The filter bar, which LuCI writes as a run of inline text. Both log views build one —
|
||||
* `tools/views.js` (LogreadBox: the System Log and every third-party page built on it) and
|
||||
* `view/status/dmesg.js` (the Kernel Log) — emitting bare `<div style="margin-bottom:10px">`
|
||||
* rows of label/control pairs with nothing around a pair. Inline flow then breaks wherever the
|
||||
* line runs out, and on a phone that is between a label and the control it names — or inside
|
||||
* the label itself.
|
||||
*
|
||||
* One rule of layout, two tiers, no per-field exceptions. The rows are flex, so a wrap can only
|
||||
* fall BETWEEN items and a label is one unbreakable item; a field may shrink to share the line.
|
||||
*
|
||||
* Scoped by SHAPE: `data-page` differs between the two logs and a third-party box has neither,
|
||||
* while both wrap their rows in `#content_syslog` and a filter row is exactly one that opens
|
||||
* with a `<label>` — the scroll-to-end button's row and the log itself do not.
|
||||
*
|
||||
* Only the ROW gap is ours: the inline `margin: 0 5px` LuCI writes on the labels stays as the
|
||||
* horizontal spacing, so nothing here has to fight an inline style. */
|
||||
/* Filter bar, scoped by SHAPE not `data-page` (a 3rd-party LogreadBox has neither). Row gap
|
||||
* only — LuCI's inline margin stays as horizontal spacing. docs/css.md "Syslog filter bar". */
|
||||
#content_syslog > div:has(> label) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -23,45 +9,23 @@
|
||||
}
|
||||
#content_syslog > div:has(> label) > label { white-space: nowrap; }
|
||||
#content_syslog > div:has(> label) > :is(select, input) {
|
||||
/* shrink, never grow. `max-width` is not the same lever as `min-width` and both are needed:
|
||||
* one lets a flex item go below its content, the other clamps a `<select>` that carries the
|
||||
* theme's own 210px width — measured at 360px, where both selects hung 3px past the view. */
|
||||
/* max-width clamps the theme's 210px select (3px overflow measured at 360px). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* THE TWO TIERS ARE DISJOINT CONTAINER QUERIES, not a base rule and an override: each states one
|
||||
* layout in full, so neither can win or lose against the other by source order — the trap the
|
||||
* first version of this file fell into, where a later generalisation silently out-specified two
|
||||
* rules that were meant to override it. */
|
||||
/* disjoint tiers, not base+override — neither out-specifies the other, docs/css.md above */
|
||||
@container fs-view (width > 560px) {
|
||||
/* room for a filter and a half at least: the free-text filter takes what the named fields
|
||||
* leave. `input:not([type])` IS that filter, in both views: every other input under
|
||||
* `#content_syslog` states a type (`checkbox` for the four inverts and the sort flag, `number`
|
||||
* for Max rows and for the Kernel Log's boot-time range), and both views leave the text one
|
||||
* untyped — so nothing here keys on an id or has to know how many fields a row holds. */
|
||||
#content_syslog > div:has(> label) > input:not([type]) { flex: 1 1 12rem; }
|
||||
}
|
||||
@container fs-view (width <= 560px) {
|
||||
/* One field per line, its labels on the line above it. Wrapping alone fills each line greedily,
|
||||
* so a leading label rides the end of the previous line while the field it belongs to opens the
|
||||
* next — and no flex rule can bind a label to the field that follows it. A full-width basis
|
||||
* can: the field always starts a line, so whatever labels precede it sit together above it.
|
||||
* It costs one line per filter on a phone and holds for every row of both views without any
|
||||
* of them being named. A checkbox is not a field and keeps its 16px. */
|
||||
#content_syslog > div:has(> label) > :is(select, input:not([type="checkbox"])) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* System Log / Kernel Log: the whole log is a readonly `<textarea id="syslog">` in a bare div, not
|
||||
* in a .cbi-value-field. Stock bootstrap pairs its generic `input, textarea { width: 210px }`
|
||||
* with `#syslog { width: 100% }` and wins on specificity — but this theme's generic field box
|
||||
* lives in the `theme` layer, and a layer beats specificity, so the same pair in `base` loses and
|
||||
* the log rendered as a 210px column (measured on 25.12). The rule is absorbed here, above the
|
||||
* generic's layer. Mono at the data tables' size, `pre` because log lines are lines, and a
|
||||
* vertical-only resize grip — a sideways grip would just re-create the squeeze. */
|
||||
/* width beats base's 210px input/textarea by layer order alone (25.12), docs/css.md above */
|
||||
#syslog {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
@layer page {
|
||||
/* The Appearance controls, appended to System -> System by fs-appearance.js.
|
||||
*
|
||||
* This was a popover on <body>, capped to the viewport because eighteen axes plus an upload
|
||||
* panel do not fit a floating card on a phone in landscape. As part of a page none of that
|
||||
* applies: the sections are ordinary .cbi-section cards, the width is the content column's and
|
||||
* the scroll is the document's — so the placement, the max-height clamp, the fade-in and the
|
||||
* [hidden] pair a floating card needed are all gone.
|
||||
*
|
||||
* What is left in the theme's own namespace — the `.fs-ap-*` folds, sections, previews and
|
||||
* version line below — lives in the page layer rather than the theme layer, because this is
|
||||
* the only page that draws it: an axis control is not chrome. */
|
||||
/* Appearance controls, appended to System -> System by fs-appearance.js. Was a viewport-capped
|
||||
* popover; now ordinary .cbi-section cards in the page layer (an axis control is not chrome).
|
||||
* docs/css.md "Appearance: from popover to page, and what stayed CSS". */
|
||||
|
||||
.fs-ap { display: flex; flex-direction: column; gap: var(--fs-space-5); }
|
||||
/* The page note (fs-appearance.js). Only the outer margins: `.alert-message p` carries
|
||||
* --fs-space-1 top and bottom, which at the box's own edges adds to the panel's padding and
|
||||
* makes a two-sentence note look loose against every other alert on the theme. */
|
||||
.fs-ap-note p:first-child { margin-top: 0; }
|
||||
.fs-ap-note p:last-child { margin-bottom: 0; }
|
||||
/* Grouping headings INSIDE the tab pane, not cards: the pane is already inside the stock
|
||||
* .cbi-section card, and a second border around each group is a box in a box. A hairline above
|
||||
* each group does the same job — except for the first, which sits under the tab strip. */
|
||||
/* the heading row. On a plain group it holds the title; on a FOLDED one the whole row is the
|
||||
* disclosure button (fs-appearance.js) — full width, so the target is the heading rather than a
|
||||
* chevron nobody can hit on a phone. */
|
||||
/* the heading row spans the section on its own now that the rows stack */
|
||||
/* heading row: on a folded group the WHOLE row is the disclosure button, full width so the
|
||||
* target is the heading, not a chevron nobody can hit on a phone */
|
||||
button.fs-ap-fold {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--fs-space-3);
|
||||
inline-size: 100%; padding: 0; margin: 0;
|
||||
@@ -31,14 +15,8 @@
|
||||
}
|
||||
button.fs-ap-fold:hover h4 { color: var(--fs-accent); }
|
||||
button.fs-ap-fold:focus-visible { outline: none; box-shadow: var(--fs-focus-ring-solo); border-radius: var(--fs-radius-sm); }
|
||||
/* The chevron: the same box with a two-border ::after that the overview's card toggles use
|
||||
* (styles/pages/20-overview.css) — one
|
||||
* chevron for "this panel opens", drawn here and there rather than an SVG in one place and
|
||||
* borders in the other, with the glyph pinned as @mirror chevron/* so the copies cannot drift.
|
||||
*
|
||||
* It turns on the SAME attribute that tells a screen reader the state, so the two cannot
|
||||
* disagree. Rotated with `transform` rather than the individual `rotate` property, because that
|
||||
* is what the overview's copy does and the pair must look identical mid-animation. */
|
||||
/* pinned @mirror chevron/*: same glyph as the overview card toggle (two hosts, one keyed by
|
||||
* attribute, one by class — no single selector list reaches both). docs/css.md above. */
|
||||
.fs-ap-chev {
|
||||
flex: 0 0 auto; inline-size: 26px; block-size: 26px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -64,64 +42,27 @@
|
||||
/* @endmirror */
|
||||
}
|
||||
button.fs-ap-fold:hover .fs-ap-chev { color: var(--fs-accent); }
|
||||
/* Stock `.cbi-value` rows stack one per line and carry their own hairline, so a folded group needs
|
||||
* no layout of its own — it lays its rows out exactly like an unfolded section by doing nothing. */
|
||||
/* `hidden` is what keeps a closed group out of the tab order and the a11y tree, and
|
||||
* base/10-reset.css is what makes the attribute win over a class's `display` — for every
|
||||
* element, not just this one, so there is nothing to restate here. */
|
||||
/* the second heading inside the Colours fold — Surfaces shares the fold, not the heading. It
|
||||
* wears the SAME .fs-ap-head h4 as every other section title now; a third heading style on one
|
||||
* page (h4, folded h4, uppercase eyebrow h5) was a third of the raggedness. Only the space
|
||||
* above it is its own. */
|
||||
.fs-ap-sub { margin-top: var(--fs-space-4); }
|
||||
.fs-ap-head h4 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
}
|
||||
.fs-ap-section + .fs-ap-section { padding-top: var(--fs-space-4); border-top: var(--fs-hairline); }
|
||||
/* The rows are stock `.cbi-value` and this page states no layout for them at all: caption column,
|
||||
* field column and the hairline between rows come from base/30-forms.css and theme/60-inputs.css,
|
||||
* the rules every other LuCI form is drawn by. What stood here was a responsive two-column grid
|
||||
* of stacked cards with an uppercase eyebrow above each control: it fitted a floating popover,
|
||||
* and beside General Settings / Language and Style it made the theme's own tab the one page in
|
||||
* LuCI that did not look like LuCI. Deleting that grid is the change — do not restate a layout
|
||||
* for these rows. */
|
||||
/* The accordion switch is meaningless in the top layout — its sections are hover dropdowns,
|
||||
* already exclusive. Hidden HERE, not by a branch in the JS: the page is built once, so a branch
|
||||
* froze the control to whatever layout it loaded in and the live toggle could never change it. */
|
||||
/* rows are stock .cbi-value with no layout of their own — base/30-forms.css and
|
||||
* theme/60-inputs.css already draw every other LuCI form this way. docs/css.md above. */
|
||||
:root[data-layout="top"] .fs-ap-submenus { display: none; }
|
||||
|
||||
/* Save-as-default / Reset row: two equal buttons, no status text — the Save button's own
|
||||
* label/disabled state is the status. They take their look from .btn / .cbi-button-action
|
||||
* (base + theme/55-buttons.css); a disabled .cbi-button-action already greys via base. */
|
||||
/* Buttons take the width of their own label, which is what every button on every other LuCI page
|
||||
* does — they were `flex: 1 1 9em` and stretched to fill the field, so three of them made one
|
||||
* long bar and "Choose SVG"/"Remove" each ran half the card. `wrap` keeps a narrow card honest:
|
||||
* an item that cannot fit moves to the next line instead of hanging past the edge, which on a
|
||||
* flex row is silent. */
|
||||
.fs-ap-actrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
/* Save and the two resets differ by one word and sit in one row, which is the shape a
|
||||
* destructive click lands on by accident. The gap says they are two groups; both resets also
|
||||
* ask for a second click. Zero-width, so it costs no height when the row wraps on a phone. */
|
||||
.fs-ap-actgap { flex: 0 0 var(--fs-space-4); }
|
||||
.fs-ap-actrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
|
||||
.fs-ap-actrow .btn:disabled { cursor: default; }
|
||||
/* the primed Reset (second click confirms) reads as the destructive action it is */
|
||||
/* primed Reset (2nd click confirms) reads as the destructive action it is */
|
||||
.fs-ap-armed { color: var(--fs-on-danger); background: var(--fs-danger); border-color: transparent; }
|
||||
/* Save's only visible failure line (a rejected save rpc — usually a stale session) — shown in
|
||||
* place of the absent status text, hidden until a save actually fails. */
|
||||
.fs-ap-err { margin-top: var(--fs-space-2); font-size: var(--fs-type-xs); line-height: 1.35; color: var(--fs-danger); }
|
||||
|
||||
/* ---- the colour axes ----
|
||||
* One row is a swatch, a hex field and the button back to the palette's own colour. */
|
||||
/* the same ceiling every other stretching control in a form row takes (02-tokens.css), so the
|
||||
* swatch + hex + Palette button end where the selects and the sliders end */
|
||||
.fs-colorctl { display: flex; flex-direction: column; gap: var(--fs-space-1-5); max-width: var(--fs-field-max); }
|
||||
.fs-color-row { display: flex; align-items: center; gap: var(--fs-space-2); }
|
||||
/* The native colour swatch. type="color" is drawn by the browser and cannot be restyled beyond
|
||||
* its box, so the box is what is styled: the inner well is stripped to the frame's edge on both
|
||||
* engines (WebKit nests two elements, Firefox one) or it paints a white margin inside our
|
||||
* border. Fixed 34px square so it lines up with the field beside it at every density. */
|
||||
/* native swatch strips the browser's own inset well to the frame's edge (both engines) */
|
||||
input[type="color"].fs-color-swatch {
|
||||
flex: 0 0 auto; inline-size: 34px; block-size: 34px; padding: 0; cursor: pointer;
|
||||
background: none; border: var(--fs-hairline); border-radius: var(--fs-radius-sm);
|
||||
@@ -129,8 +70,6 @@
|
||||
input[type="color"].fs-color-swatch::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
input[type="color"].fs-color-swatch::-webkit-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
|
||||
input[type="color"].fs-color-swatch::-moz-color-swatch { border: 0; border-radius: calc(var(--fs-radius-sm) - 1px); }
|
||||
/* the hex field is a VALUE, so it is mono and tabular like every other value in the theme —
|
||||
* and #0969da is exactly the kind of string a proportional face makes hard to read back */
|
||||
input[type="text"].fs-color-hex {
|
||||
flex: 1 1 auto; min-inline-size: 0;
|
||||
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-variant-numeric: tabular-nums;
|
||||
@@ -139,86 +78,40 @@
|
||||
.fs-color-clear { flex: 0 0 auto; font-size: var(--fs-type-xs); padding: var(--fs-space-1-5) var(--fs-space-2); cursor: pointer; }
|
||||
.fs-color-clear:disabled { cursor: default; }
|
||||
|
||||
/* What the chosen colour COSTS, measured live against the surface it is read on (fs-widgets'
|
||||
* contrastRatio). It is a readout, not a validation: the theme derives readable ink over a fill
|
||||
* but never silently corrects a colour the admin asked for, so this says the number and grades
|
||||
* it. Three grades, three colours — and the failing one is --fs-danger, which the admin may have
|
||||
* just recoloured, which is exactly the honest outcome. */
|
||||
/* It is LuCI's own help-text line now — `.cbi-value-description`, styled in base/95-luci.css,
|
||||
* which is where a sentence under a field belongs on every other page. What stays here is the
|
||||
* only thing stock has no opinion about: the GRADE's colour, and the tabular figures that keep
|
||||
* the ratio from jittering as it updates. */
|
||||
/* live contrast readout, not a validation — docs/css.md above */
|
||||
.fs-color-contrast { font-variant-numeric: tabular-nums; }
|
||||
.fs-contrast-aa { color: var(--fs-good); }
|
||||
.fs-contrast-aa-large { color: var(--fs-warn); }
|
||||
.fs-contrast-low { color: var(--fs-danger); }
|
||||
|
||||
/* THE PRESET CHIPS' rules were here — a wrapping row of pills, each painted in the accent it
|
||||
* would set, with ink derived from that colour's own lightness. The control is gone from
|
||||
* fs-appearance.js: it was the one thing on the page that looked like nothing else in LuCI, it
|
||||
* started at the card's edge instead of at the field column, and everything it did the Accent
|
||||
* row does with any colour rather than eight. */
|
||||
|
||||
/* The uploaded image's preview and the two buttons under it, both in an ordinary
|
||||
* `.cbi-value-field`. The preview is a thumbnail, not a banner: capped to the same field ceiling
|
||||
* as every other stretching control and to a fixed height, so the row keeps one shape whatever
|
||||
* the image's aspect is — full width makes an SVG tile look like a letterboxed photograph. */
|
||||
.fs-ap-bgprev {
|
||||
display: block; width: 100%; max-width: var(--fs-field-max); height: 84px; object-fit: cover;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
/* the preview follows the buttons in the field, so the gap belongs ABOVE it */
|
||||
margin-top: var(--fs-space-2);
|
||||
}
|
||||
.fs-ap-bgrow { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.fs-ap-bgrow .btn { font-size: var(--fs-type); padding: var(--fs-space-2) var(--fs-space-2-5); cursor: pointer; }
|
||||
.fs-ap-bgrow .btn:disabled { cursor: default; }
|
||||
/* The JS hides a row that does not apply to the chosen wallpaper, the empty preview and a
|
||||
* not-yet-uploaded Remove with `el.hidden`; base/10-reset.css makes that attribute beat the
|
||||
* `display` a class sets, so none of it needs restating here any more. */
|
||||
/* Under the File wallpaper only the photo Dim is relevant — the router-identity Tint colours the
|
||||
* canvas the photo covers, so hide the Tint hue AND its strength there (fs-ap-tint marks both). */
|
||||
/* File wallpaper: only Dim applies, Tint colours the canvas a photo already covers */
|
||||
:root[data-wallpaper="file"] .fs-ap-tint { display: none; }
|
||||
/* and the strength is a multiplier on a chroma WE choose, so it only means anything in hue mode:
|
||||
* with the tint off there is nothing to scale, and on a hex canvas the colour is the one the
|
||||
* admin entered. (0,3,0) out-ranks the stock row's own display. Not named after Density: that is
|
||||
* the UI-density select four rows above, and one name for two rows is how the label itself went
|
||||
* wrong. */
|
||||
/* strength is a multiplier on a chroma we choose, meaningless off hue mode */
|
||||
:root:not([data-tint="hue"]) .fs-ap-tintstr { display: none; }
|
||||
|
||||
/* the version line at the foot of the page */
|
||||
.fs-ap-footer {
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2);
|
||||
margin-top: var(--fs-space-0-5); padding-top: var(--fs-space-2-5); border-top: var(--fs-hairline);
|
||||
}
|
||||
.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 { 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. */
|
||||
/* invisible overlay grows an 84x17 link past the WCAG 2.2 SC 2.5.8 24px floor, to 88x25,
|
||||
* without moving the text (out of flow, same fix as .cbi-checkbox in theme/60-inputs.css) */
|
||||
.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.
|
||||
* Wraps, because the package name is 24 characters and the column is narrow at 390px. */
|
||||
.fs-ap-i18n {
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--fs-type-xs);
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
/* the chip is base/20-typography.css's `code`; only the size follows the line it sits in, which
|
||||
* is a step smaller than body text */
|
||||
.fs-ap-i18n code { font-size: inherit; }
|
||||
|
||||
/* No rules here for a segmented control or a range slider of our own: the page draws its enums
|
||||
* with `ui.Select` and its numbers with `ui.RangeSlider`, which this theme already styles for
|
||||
* every other LuCI form. Two copies of a slider, one of them ours, was the thing to delete. */
|
||||
|
||||
/* No rules here for a hue slider either: the control is gone from fs-widgets' colorControl, two
|
||||
* controls for one value reading as two settings, and the half that could not reach a grey was
|
||||
* the slider. The axis still stores a hue and 03-palettes.css still rotates by one, so a saved
|
||||
* value goes on painting — but CSS for a control nobody renders is dead CSS in our own
|
||||
* namespace, which `npm run css-orphans` exists to keep out. */
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
@layer page {
|
||||
/* Processes: the PID column, which must not split. No `white-space: nowrap` here any more — the
|
||||
* floor is general: theme/30-tables.css gives a data table `overflow-wrap: break-word` for as
|
||||
* long as it is a table, so every column keeps the width of its own widest unbreakable token,
|
||||
* and a PID is one token. (With `anywhere` on every cell instead, `384` rendered as "38/4" and
|
||||
* `4810` as "481/0" — 23 of 26 rows, measured at 1228px against a Russian catalogue: issue #32,
|
||||
* and issue #5 before it.) The one per-page rule that survives (pages/30-software.css) does so
|
||||
* because a hyphen IS a legal break opportunity and `break-word` will use it.
|
||||
*
|
||||
* The measurement that replaced the nowrap, on the router at 720px: the Command column reports
|
||||
* 353px of need against 126px of width, the table asks for 963px in 688px of room, and the
|
||||
* ladder marks exactly one column breakable — leaving the PID whole without a rule of its
|
||||
* own. */
|
||||
}
|
||||
@@ -1,34 +1,7 @@
|
||||
@layer theme {
|
||||
/* ---- the chrome's floor: pin what a foreign rule can reach by INHERITANCE ----
|
||||
*
|
||||
* fs-sheets.js fences the chrome out of a third-party selector, so nothing foreign MATCHES a
|
||||
* chrome element. Inheritance is the way in a fence cannot close: a rule on `html` or `body` is
|
||||
* an ancestor, and excluding it is meaningless. Measured against a hostile flagged rule on
|
||||
* `html`: font-style reached 166 of the menu's 169 elements, word-spacing and text-align 157,
|
||||
* letter-spacing and text-transform 156, cursor 46 — while font-family, color, font-size,
|
||||
* line-height and font-weight reached none,
|
||||
* because the chrome already states those. The fix is to state the rest too.
|
||||
*
|
||||
* Inheritance is not a cascade competitor: it supplies a value only when no declaration matches
|
||||
* the element, so any declaration of ours — unflagged, at zero specificity — beats an inherited
|
||||
* `!important`. Hence no `!important` here.
|
||||
*
|
||||
* It keys off `[data-fs-chrome]`, the mark a chrome root carries in the markup (header.ut), not
|
||||
* off `.fs-sidebar`: the chrome is not one element, and a pin naming the menu leaves the skip
|
||||
* link and the search overlay inheriting straight from `html`. The fence (fs-sheets.js) reads
|
||||
* the same mark, so the two halves of zone 1's defence cannot cover different sets of elements,
|
||||
* and `npm run chrome-fence` derives the mark from the markup and holds both to it.
|
||||
*
|
||||
* It goes on the ROOT alone, and says so in the SELECTOR (`:not([data-fs-chrome] *)`) rather
|
||||
* than trusting whoever adds the next marker to have read this: the markers live in a template
|
||||
* with conditional blocks, so "is this one inside that one" is not a question a text scanner can
|
||||
* answer, and CSS can. Pinning descendants too is worse than useless — a direct declaration
|
||||
* beats an inherited one even when the inherited one is ours, so `.fs-label` loses the `nowrap`
|
||||
* it inherits and `text-align` is forced from `start` to `left` on 302 elements, which alone
|
||||
* breaks every RTL language LuCI ships. Hence `text-align: start`, never `left`.
|
||||
*
|
||||
* `:where()` keeps it at 0,0,0 so every existing chrome rule outranks it: the pin is a floor,
|
||||
* never a ceiling. Only inherited properties belong here. */
|
||||
/* Pins inherited-only props on [data-fs-chrome], closing what the fence cannot: inheritance
|
||||
* from html/body. ROOT-only selector and text-align: start (never left) are load-bearing —
|
||||
* third-party-apps.md, "Rule 7. The chrome is fenced off, in three places that must agree". */
|
||||
:where([data-fs-chrome]:not([data-fs-chrome] *)) {
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
@@ -40,11 +13,8 @@
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* Shared chrome — brand, logo, wordmark, logout, indicators. Same in both layouts;
|
||||
* only their PLACEMENT differs. Written twice once (here and under `.fs-topnav`) and
|
||||
* the copies had drifted: 32 vs 30px logo, 11 vs 10px brand gap, 10 vs 8px indicator
|
||||
* gap. The top-bar values won. A layout file may set placement on these (flex, order,
|
||||
* the rail collapse) — never their look. */
|
||||
/* Brand/logo/wordmark/logout/indicators: one look for every layout — a layout file sets only
|
||||
* PLACEMENT (flex, order, rail collapse), never look. */
|
||||
|
||||
.fs-brand {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2-5);
|
||||
@@ -53,29 +23,17 @@
|
||||
|
||||
.fs-logo {
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); border-radius: var(--fs-radius);
|
||||
/* No accent fill — a bordered box, same look as the square buttons in the right
|
||||
* cluster (.fs-logout, .fs-themerow: 1px var(--fs-border)). The glyph is the OpenWrt
|
||||
* favicon mark (brand.ut), baked-colour, so color/currentColor is unused here. */
|
||||
border: var(--fs-hairline);
|
||||
border: var(--fs-hairline); /* glyph is baked-colour (brand.ut), no currentColor use */
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fs-logo svg { width: 20px; height: 20px; }
|
||||
/* The OpenWrt ring flips with the theme mode like the SVG favicon does under
|
||||
* prefers-color-scheme — dark ring on the light UI, light ring on dark. The cyan arcs
|
||||
* stay baked (read on both). */
|
||||
.fs-logo-ring { fill: #002b49; }
|
||||
.fs-logo-ring { fill: #002b49; } /* flips with mode like the favicon SVG; cyan arcs stay baked */
|
||||
:root[data-darkmode="true"] .fs-logo-ring { fill: #e6f4fb; }
|
||||
|
||||
/* 700, not 800: the theme ships no faces, so 800 is whatever the reader's Manrope or system
|
||||
* stack has — 700 for most of them, synthesised for the rest. It already resolved to 700 back
|
||||
* when the 18 KB face was bundled and dropped for drawing six elements. Say so. */
|
||||
.fs-wordmark { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); letter-spacing: -.01em; }
|
||||
|
||||
/* Skip link — the first tab stop: a keyboard/SR user jumps the whole menu instead of
|
||||
* tabbing through every section on every page. Off-screen until focused, never
|
||||
* display:none / visibility:hidden — that removes it from the tab order, which is the
|
||||
* one thing it exists for. Sits above everything, incl. an open popover. */
|
||||
/* First tab stop — never display:none/visibility:hidden, that drops it from the tab order. */
|
||||
.fs-skip {
|
||||
position: fixed; top: 8px; inset-inline-start: 8px; z-index: var(--fs-z-dropdown);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4); border-radius: var(--fs-radius);
|
||||
@@ -83,10 +41,8 @@
|
||||
font-weight: var(--fs-weight-bold); font-size: var(--fs-type); text-decoration: none;
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* Clipped, not translated off-screen: --fs-shadow-pop is offset 12px / blur 34px, so a
|
||||
* box parked at -68px still bled ~16px of DROP SHADOW into the viewport and smudged the
|
||||
* top-left corner of every page. clip-path clips the shadow too. Never display:none /
|
||||
* visibility:hidden — see above. */
|
||||
/* clip-path, not a -68px translate: the translate still bled ~16px of drop shadow into the
|
||||
* viewport (--fs-shadow-pop is offset 12px/blur 34px); clip-path clips the shadow too. */
|
||||
.fs-skip:not(:focus) {
|
||||
clip-path: inset(50%);
|
||||
width: 1px; height: 1px;
|
||||
@@ -95,11 +51,7 @@
|
||||
.fs-skip:focus {
|
||||
outline: 2px solid var(--fs-panel); outline-offset: 2px;
|
||||
}
|
||||
/* Visually hidden but PRESENT in the a11y tree — for the document <h1> and the live
|
||||
* region. hidden/display:none/visibility:hidden take the element out of the a11y tree
|
||||
* too: the h1 would fix no heading outline and the live region would never announce.
|
||||
* Clipping leaves a node readable to assistive tech while occupying no space.
|
||||
* nowrap: a long title clipped into a 1px column is read out line by line by some SRs. */
|
||||
/* Clipped, not display:none/visibility:hidden — both take the <h1> out of the a11y tree too. */
|
||||
.fs-sr {
|
||||
position: absolute;
|
||||
clip-path: inset(50%);
|
||||
@@ -108,9 +60,7 @@
|
||||
overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the skip link's target <main> carries tabindex="-1" so the browser actually MOVES
|
||||
* focus there (Safari/Chrome refuse on a non-focusable target: they scroll but leave
|
||||
* focus in the menu) — but it must not then ring the whole page. */
|
||||
/* tabindex="-1" on <main> makes Safari/Chrome move focus here rather than just scroll to it. */
|
||||
#maincontent:focus { outline: none; }
|
||||
|
||||
.fs-logout { display: flex; align-items: center; color: var(--fs-dim); }
|
||||
@@ -118,42 +68,24 @@
|
||||
|
||||
.fs-indicators { display: flex; align-items: center; gap: var(--fs-space-2); }
|
||||
|
||||
/* The 34px square icon button (.fs-themerow / .fs-logout collapsed) is NOT declared
|
||||
* here: it only exists where the chrome is a bar or a rail, and those blocks
|
||||
* (20-shell.css, 50-toplayout.css) already give it its border, surface and size — an
|
||||
* unconditional copy here was a second definition they had to re-state anyway. */
|
||||
/* 34px square icon button: not declared here, only where the chrome is a bar or a rail
|
||||
* (20-shell.css, 50-toplayout.css). */
|
||||
|
||||
/* Density steps for the main menu once it is a horizontal row —
|
||||
* fs-chrome.js (fitTabStrips) adds .fs-dense1/2 when the row would wrap (and strips them
|
||||
* in the vertical sidebar, so no media guard is needed). Padding is trimmed hard
|
||||
* first, the font only at the d2 floor. */
|
||||
/* fitTabStrips (fs-chrome.js) adds .fs-dense1/2 when the horizontal menu row would wrap, and
|
||||
* strips them in the vertical sidebar — no media guard needed. */
|
||||
.fs-sidebar ul.nav.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
.fs-sidebar ul.nav.fs-dense2 > li > a { padding: var(--fs-space-1) var(--fs-space-1-5); font-size: var(--fs-type); }
|
||||
|
||||
/* Menu primitives, once for both layouts (both render the menu as `ul.nav > li > a`):
|
||||
* the 19px line-height, `display: block` on the items and the `position: relative`
|
||||
* every submenu popup anchors to — all three used to be inherited from a navbar rule
|
||||
* set the theme no longer carries. */
|
||||
/* One markup (`ul.nav > li > a`) for both layouts, so these three run once. */
|
||||
ul.nav { position: relative; margin: 0; list-style: none; }
|
||||
ul.nav > li { display: block; }
|
||||
ul.nav a { line-height: 19px; text-decoration: none; }
|
||||
|
||||
/* `.dropdown-menu` is emitted by nobody here — not by this theme, whose menu is a plain nested
|
||||
* `ul`, and not by luci-base. It stays styled under the coverage contract: it is theme
|
||||
* vocabulary (stock luci-theme-bootstrap carries 14 rules for it), so a third-party luci-app-*
|
||||
* is entitled to emit it and expect a themed result. Each item anchors its own nested popup,
|
||||
* hence position: relative.
|
||||
*
|
||||
* This comment used to claim ui.js emits it for the split Save & Apply button. It does not —
|
||||
* that button is a `.cbi-dropdown`, a different widget. */
|
||||
/* No emitter here, stays styled under the coverage contract — conventions.md, "Coverage is a
|
||||
* contract". */
|
||||
.dropdown-menu li { display: block; position: relative; }
|
||||
/* wins over `ul.nav a` above: the popup sets its own denser rhythm */
|
||||
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; }
|
||||
ul.nav .dropdown-menu a, .dropdown-menu a { line-height: 18px; } /* denser than ul.nav a above */
|
||||
|
||||
/* Menu hover / active — one look, every layout. One markup (`ul.nav > li > a` + a
|
||||
* nested `ul`) serves the main list, the submenu (accordion / bar dropdown / rail
|
||||
* flyout), the mode menu and the Appearance row, so there are no per-layout copies to
|
||||
* keep in step. */
|
||||
.fs-sidebar ul.nav > li > a:hover,
|
||||
.fs-sidebar ul.nav ul a:hover,
|
||||
.fs-themerow:hover {
|
||||
@@ -165,27 +97,12 @@
|
||||
.fs-modemenu li.active a {
|
||||
background: var(--fs-accent-soft); color: var(--fs-accent);
|
||||
}
|
||||
/* ---- the fill marks the PAGE, and a category is not a page ----
|
||||
*
|
||||
* `li.active` is set on the SECTION containing the current page as well as on the page's own
|
||||
* row, so `li.active > a` hands the same accent plate to both — two identical plates, one above
|
||||
* the other, on System -> System (issue #37): measured before this rule, the open category and
|
||||
* the current leaf carried the same fill, the same ink and the same 13px/600, differing in 2px
|
||||
* of padding.
|
||||
*
|
||||
* The markup already says which is which: menu-footstrap.js puts `aria-current="page"` on the
|
||||
* LEAF alone, a section header being a disclosure control rather than a link to where you are.
|
||||
* So the plate stays with the page and the ancestor keeps the accent INK, while an ancestor
|
||||
* that is also the page is matched by `a.active` above and keeps the plate.
|
||||
*
|
||||
* Unscoped, like the rule above it: the accordion, the bar's dropdown and the rail's flyout are
|
||||
* one markup. */
|
||||
/* Plate on the PAGE only; the open ANCESTOR keeps the ink alone — issue #37 (two identical
|
||||
* plates stacked on System -> System otherwise). chrome.md, "menu-footstrap.js". */
|
||||
.fs-sidebar ul.nav > li.active > a:not(.active) { color: var(--fs-accent); }
|
||||
|
||||
/* Keyboard focus for the chrome controls, which otherwise have only the UA default.
|
||||
* --fs-focus-ring-solo, not --fs-focus-ring: none of these flips a border on focus, so the ring
|
||||
* is the entire indicator, and the tinted ring measures 1.15-1.29:1 against the sidebar — i.e.
|
||||
* tabbing the whole menu shows nothing at 3:1 (the two-ring contract in 02-tokens.css). */
|
||||
/* --fs-focus-ring-solo: none of these flips a border on focus, so the ring alone must clear
|
||||
* 3:1 — the tinted ring reads only 1.15-1.29:1 (two-ring contract, 02-tokens.css). */
|
||||
.fs-railtoggle:focus-visible,
|
||||
.fs-themerow:focus-visible,
|
||||
.fs-logout:focus-visible,
|
||||
@@ -194,11 +111,8 @@
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* The page-wide accent signals no widget rule covers, all following the palette:
|
||||
* ::selection the UA default (opaque blue) ignores palette and dark mode
|
||||
* caret-color inherits from :root into every field, in one declaration
|
||||
* accent-color the theme's own checkboxes are appearance:none drawings and never see it,
|
||||
* but a third-party app's native checkbox/radio/progress does */
|
||||
/* accent-color reaches a foreign app's native checkbox/radio/progress — the theme's own are
|
||||
* appearance:none and never see it. */
|
||||
::selection { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
:root { caret-color: var(--fs-accent); accent-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -1,24 +1,6 @@
|
||||
@layer theme {
|
||||
/* Wallpaper axis, value `pattern`: an SVG the admin uploads, tiled behind the content and
|
||||
* recoloured by the theme, so the same file reads correctly in light and dark and under any
|
||||
* palette without being edited.
|
||||
*
|
||||
* A dedicated element rather than a `background-image`, because recolouring a rasterised image
|
||||
* takes `mask-image` — the SVG supplies the alpha, the element the colour — and a mask applies
|
||||
* to the element's whole rendering, children included, so it can never go on .fs-shell or
|
||||
* <body>. Hence .fs-pattern, emitted as the first child of <body> by header.ut and sysauth.ut
|
||||
* (the login page has no .fs-shell), whose only job is to be masked.
|
||||
*
|
||||
* z-index: -1 with a transparent body, because inside the root stacking context the paint order
|
||||
* is html's background, then negative-z children, then every block background: the layer sits
|
||||
* above the canvas and below the content, but body's own opaque --fs-bg would bury it. The
|
||||
* canvas therefore moves up to :root for this axis and body goes transparent; .fs-main and the
|
||||
* footer paint their own --fs-bg and follow for the same reason. The sidebar is deliberately
|
||||
* not excluded —
|
||||
* the bar is translucent and the pattern showing behind it is what makes the page read as one
|
||||
* sheet.
|
||||
*
|
||||
* Cards sit on opaque --fs-panel, so the pattern shows through the gaps only. */
|
||||
/* .fs-pattern: the masked wallpaper element. Why a dedicated element, why z-index: -1 needs a
|
||||
* transparent body — design-system.md, "Wallpaper rendering: why the canvas moves to :root". */
|
||||
.fs-pattern { display: none; }
|
||||
|
||||
:root[data-wallpaper="pattern"] { background-color: var(--fs-bg); }
|
||||
@@ -26,10 +8,8 @@
|
||||
:root[data-wallpaper="pattern"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="pattern"] footer.fs-footer { background: transparent; }
|
||||
|
||||
/* `fixed`, so the pattern is one calm backdrop the page scrolls under rather than a strip that
|
||||
* slides with every table — the same argument the File photo's background-attachment makes.
|
||||
* --fs-pattern-url is `none` until something is uploaded (head.ut / fs-prefs.js), so the axis
|
||||
* selected with no file is a bare canvas rather than a broken tile. */
|
||||
/* --fs-pattern-url is `none` until an upload exists (head.ut/fs-prefs.js) — the axis picked
|
||||
* with no file is a bare canvas, not a broken tile. */
|
||||
:root[data-wallpaper="pattern"] .fs-pattern {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -38,10 +18,8 @@
|
||||
pointer-events: none;
|
||||
opacity: var(--fs-pattern-strength);
|
||||
}
|
||||
/* THEME INK (the default). The file's own colours are discarded and only its alpha is used, so
|
||||
* an opaque background inside the SVG would mask as a solid sheet — which is exactly why the
|
||||
* admin can switch to the file's own colours below. Line art on transparency is the shape this
|
||||
* mode is for. */
|
||||
/* Theme ink (default): file's own colours discarded, only alpha used through the mask —
|
||||
* design-system.md, "Pattern colours". */
|
||||
:root[data-wallpaper="pattern"]:not([data-pattern-ink="original"]) .fs-pattern {
|
||||
background-color: var(--fs-text);
|
||||
-webkit-mask-image: var(--fs-pattern-url);
|
||||
@@ -51,15 +29,13 @@
|
||||
-webkit-mask-size: var(--fs-pattern-size) auto;
|
||||
mask-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
/* THE FILE'S OWN COLOURS: an ordinary tiled background, no mask. For artwork that carries its
|
||||
* own palette and would be flattened to one colour by the mask. */
|
||||
/* original: the file's own palette, plain tiled background, no mask. */
|
||||
:root[data-wallpaper="pattern"][data-pattern-ink="original"] .fs-pattern {
|
||||
background-image: var(--fs-pattern-url);
|
||||
background-repeat: repeat;
|
||||
background-size: var(--fs-pattern-size) auto;
|
||||
}
|
||||
|
||||
/* No wash layer behind #view: the pattern shows through the gaps between the cards at
|
||||
* full strength. Every block that carries text paints an opaque --fs-panel of its own,
|
||||
* so nothing has to be dimmed globally. */
|
||||
/* No wash layer behind #view: cards paint their own opaque --fs-panel, so nothing needs
|
||||
* dimming globally. */
|
||||
}
|
||||
@@ -1,30 +1,14 @@
|
||||
@layer theme {
|
||||
/* The uploaded photo, the Wallpaper axis's `file` value. The bytes are router-side and shared,
|
||||
* and the served file is referenced by CSS `url()`, never inlined (uhttpd has no gzip). A photo
|
||||
* carries no bakeable opacity, so a scrim is mandatory for legibility: --fs-photo-scrim, a wash
|
||||
* derived from --fs-bg so it follows palette, mode and tint (03-palettes.css), painted as the
|
||||
* TOP background layer over the photo on the SAME element. That keeps it between image and
|
||||
* content with no ::before
|
||||
* and no z-index, and no backdrop-filter — a blur on a full-viewport surface is the most
|
||||
* expensive paint there is on a phone GPU, the scrim already carries the readability, and
|
||||
* reduced transparency turns the scrim opaque anyway (95-a11y-media.css).
|
||||
*
|
||||
* One-element rule, like the pattern: .fs-shell where there is chrome, the bare <body> on the
|
||||
* login page. Content blocks paint their own opaque --fs-panel and float above the scrim, so it
|
||||
* only has to carry the little text sitting directly on the canvas. --fs-login-bg-url is `none`
|
||||
* until a photo is uploaded. */
|
||||
/* The `file` wallpaper: scrim + photo on one element — design-system.md,
|
||||
* "Wallpaper rendering: why the canvas moves to :root". */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) {
|
||||
background-image: linear-gradient(var(--fs-photo-scrim), var(--fs-photo-scrim)), var(--fs-login-bg-url);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
/* fixed, so the photo is one calm backdrop the page scrolls under, not a strip that slides
|
||||
* with every table. */
|
||||
background-attachment: fixed;
|
||||
background-attachment: fixed; /* one calm backdrop the page scrolls under, not a sliding strip */
|
||||
}
|
||||
/* .fs-main's opaque --fs-bg would cover the shell's single background layer — make it transparent
|
||||
* so the photo+scrim shows through the whole content area (15-wallpaper.css does the same for
|
||||
* the pattern). */
|
||||
/* transparent: an opaque --fs-bg here would cover the shell's one background layer. */
|
||||
:root[data-wallpaper="file"] .fs-main { background-color: transparent; }
|
||||
:root[data-wallpaper="file"] footer.fs-footer { background: transparent; }
|
||||
}
|
||||
@@ -9,16 +9,7 @@
|
||||
}
|
||||
/* placement only — the look lives in 10-chrome.css */
|
||||
.fs-brand { flex: 1 1 auto; min-width: 0; }
|
||||
/* A hostname is user-supplied and can outgrow any bar or column. `overflow-wrap: anywhere`
|
||||
* wraps it mid-word only when no better break exists (unlike word-break: break-all, a
|
||||
* normal hostname still breaks at its dots), and it lets the flex item's min-content size
|
||||
* shrink — which is what stops a long name pushing the menu out of the bar. It used to be
|
||||
* nowrap + ellipsis, which truncated the one string that identifies the router. */
|
||||
/* And a ceiling on the LINES, the width cap alone bounding the wrong axis: measured in the top
|
||||
* bar at 1280px, 8 characters give the ordinary 46px bar, 63 (the kernel's nodename limit) give
|
||||
* 73px, and a 4000-character hostname gave a 4273px tall bar. Three lines is past every real
|
||||
* hostname and still a bar. `-webkit-line-clamp` is the only cross-engine spelling today and
|
||||
* needs its two companions, where a max-height would slice a line in half. */
|
||||
/* Hostname handling, two rules for two failures — chrome.md, "Hostname handling". */
|
||||
.fs-wordmark {
|
||||
overflow-wrap: anywhere; min-width: 0;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
|
||||
@@ -48,15 +39,12 @@
|
||||
color: var(--fs-dim);
|
||||
transition: rotate var(--fs-dur-move);
|
||||
}
|
||||
/* `rotate`, not `transform: rotate()`: the INDIVIDUAL transform properties compose across
|
||||
* rules, so the RTL mirror below can set `scale` without either rule having to restate the
|
||||
* other's value. The fixed order is translate -> rotate -> scale, so a mirrored chevron still
|
||||
* points down when it opens. */
|
||||
/* `rotate`, not `transform: rotate()`: individual transform properties compose across rules,
|
||||
* so the RTL mirror below sets `scale` without either rule restating the other's value. */
|
||||
.fs-sidebar ul.nav > li.open > a .fs-chevron { rotate: 90deg; }
|
||||
/* ---- the two chevrons are DIRECTION, and direction is what mirrors ----
|
||||
* A disclosure marker and the rail's collapse arrow both point along the reading axis, so with
|
||||
* the document flipped (head.ut stamps dir) both point back at themselves. Everything else in
|
||||
* the chrome mirrors from the logical properties; these two are glyphs and need the transform. */
|
||||
/* The two chevrons are DIRECTION, not decoration, so with the document flipped (head.ut
|
||||
* stamps dir) they must be mirrored explicitly — logical properties do that for everything
|
||||
* else in the chrome, but a glyph needs the transform. */
|
||||
:root[dir="rtl"] .fs-chevron,
|
||||
:root[dir="rtl"] .fs-railtoggle .fs-ico { scale: -1 1; }
|
||||
.fs-sidebar ul.nav > li.has-sub > a { cursor: pointer; }
|
||||
@@ -67,34 +55,12 @@
|
||||
* them; only its LAYOUT (square button vs full-width row) belongs to a layout block. */
|
||||
|
||||
|
||||
/* ---- the bar: the default chrome, and the only copy of it ----
|
||||
*
|
||||
* The chrome is a bar unless something says otherwise: the TOP layout at every width, and the
|
||||
* SIDEBAR layout collapsed onto a phone. The vertical sidebar is the exception, written once
|
||||
* below as a single guarded override.
|
||||
*
|
||||
* This way round because the bar is needed when
|
||||
* (viewport <= 767px) OR (:root[data-layout="top"])
|
||||
* and CSS cannot OR a media query with an attribute selector in one selector, so writing the
|
||||
* bar under both guards means writing it twice — 55 of ~75 declarations identical, i.e. free to
|
||||
* drift. Inverted, each of the three chrome states is stated exactly once.
|
||||
*
|
||||
* The override wins on SPECIFICITY, never on source order:
|
||||
* :root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar (0,4,0) beats .fs-sidebar (0,1,0) */
|
||||
/* The bar is the default chrome and the only copy of it, unguarded — chrome.md, "The bar:
|
||||
* fit steps, scroll model, a rail that must match its guard". The vertical sidebar below is
|
||||
* the single guarded override, winning on SPECIFICITY, never on source order. */
|
||||
.fs-shell {
|
||||
display: flex; flex-direction: column;
|
||||
/* ---- `svh`, not `dvh`, and that is the phone's whole problem ----
|
||||
*
|
||||
* `dvh` tracks the viewport as browser UI comes and goes, and on iOS the URL bar slides
|
||||
* continuously WHILE THE USER SCROLLS, so a shell keyed on `dvh` is re-laid-out on every
|
||||
* frame of that animation — the shaking reported from an iPhone. `svh` is the small viewport
|
||||
* height, the value with the browser UI shown, and it does not move while the UI does. It is
|
||||
* also the right value here: this is a floor, so the shell must be at least one screenful
|
||||
* tall with the bar visible, and when the bar hides the page is free to be taller.
|
||||
*
|
||||
* The whole file follows that rule: a height that must not change under the reader uses
|
||||
* `svh`. `dvh` is for a thing that should GROW as the UI retracts. */
|
||||
min-height: 100svh; width: 100%;
|
||||
min-height: 100svh; width: 100%; /* svh not dvh — chrome.md, same heading */
|
||||
}
|
||||
.fs-sidebar {
|
||||
display: flex;
|
||||
@@ -107,14 +73,8 @@
|
||||
/* the submenu popups anchor to this bar and must escape it */
|
||||
overflow: visible;
|
||||
}
|
||||
/* A hostname can be enormous, and three things must be true at once:
|
||||
* flex: 0 1 auto + min-width: 0 — the brand may SHRINK (0 0 auto lets it push the menu and
|
||||
* the right cluster out of the bar);
|
||||
* max-width — it must also be CAPPED: the bar is flex-wrap, so instead of squeezing the brand
|
||||
* flexbox wraps the other items away and lets it sit alone on a 600px line;
|
||||
* and the bar grows in height (min-height, 50-toplayout.css) to hold the extra line.
|
||||
* 30ch tracks the text, not the viewport: a wide screen licenses more MENU, not a wider
|
||||
* hostname. */
|
||||
/* brand may shrink (min-width: 0) but is capped at 30ch, tracking the text not the viewport;
|
||||
* the extra line grows the bar's own min-height (50-toplayout.css). */
|
||||
.fs-brandrow { flex: 0 1 auto; order: 1; min-width: 0; max-width: min(100%, 30ch); padding: 0; }
|
||||
/* the bar has no section caption, no column to collapse, and its height follows its
|
||||
* content — so the flex filler has nothing to fill */
|
||||
@@ -127,13 +87,8 @@
|
||||
order: 3; flex: 1 1 100%;
|
||||
flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--fs-space-0-5);
|
||||
}
|
||||
/* The dropdown hangs off its OWN item, in every bar state (issue #19): `position: static` plus a
|
||||
* panel pinned at `left: 16px` anchors it to the bar's edge rather than to its trigger, so a
|
||||
* sidebar layout collapsed onto a bar opened a hover panel at the left edge while its trigger
|
||||
* sat mid-bar — measured at 760px, the item at x=253 and the panel at x=32. Travelling to the
|
||||
* panel leaves the <li>, nothing bridges the diagonal, and
|
||||
* the CSS hover closes it, so no submenu entry could be reached. A phone never shows it:
|
||||
* `hover: none` disables the hover rule outright. */
|
||||
/* anchors the dropdown to its OWN item, not the bar — issue #19, chrome.md, "A bar dropdown
|
||||
* anchors to its own <li>". A phone never shows it: `hover: none` disables the rule. */
|
||||
.fs-sidebar ul.nav > li.has-sub { position: relative; }
|
||||
.fs-sidebar ul.nav > li > a {
|
||||
display: flex; align-items: center;
|
||||
@@ -145,11 +100,8 @@
|
||||
.fs-sidebar ul.nav > li > a .fs-ico,
|
||||
.fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
/* submenu = a popup card under its own item: sized to its content rather than stretched
|
||||
* across the bar, and capped to the viewport so a long label wraps instead of overflowing.
|
||||
* `left: 0` = the item's left edge (the <li> above is the containing block); an item near
|
||||
* the right edge is nudged back inside by menu-footstrap.js clampDropdown, in every bar
|
||||
* state — the JS gate is the same question this rule asks, not a layout test. */
|
||||
/* popup card under its own item, sized to content and capped to the viewport; clampDropdown
|
||||
* (menu-footstrap.js) nudges one back inside near the right edge, every bar state. */
|
||||
.fs-sidebar ul.nav ul,
|
||||
.fs-sidebar ul.nav > li.open > ul {
|
||||
display: none;
|
||||
@@ -161,8 +113,8 @@
|
||||
border-radius: var(--fs-radius-lg); box-shadow: var(--fs-shadow-pop);
|
||||
}
|
||||
/* an invisible bridge across the 8px gap, so travelling from the item down into the panel
|
||||
* does not drop the hover and close it. Belongs to the BAR, not to one layout: the hover
|
||||
* rule below is unguarded, so every state that can open a panel on hover needs it. */
|
||||
* does not drop the hover and close it. Unguarded: every state that can open a panel on
|
||||
* hover needs it, not just one layout. */
|
||||
.fs-sidebar ul.nav > li.has-sub > ul::before {
|
||||
content: ""; position: absolute; inset-inline: 0; top: -10px; height: 10px;
|
||||
}
|
||||
@@ -194,26 +146,14 @@
|
||||
* them instead of pushing one cluster to the bar's far edge */
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: var(--fs-bar-ctl-bg); color: var(--fs-dim);
|
||||
background: transparent; color: var(--fs-dim);
|
||||
font: inherit; font-weight: var(--fs-weight); font-size: var(--fs-type); line-height: 1.1;
|
||||
text-align: start; cursor: pointer;
|
||||
transition: background var(--fs-dur), border-color var(--fs-dur), color var(--fs-dur);
|
||||
}
|
||||
/* ---- when the right cluster runs out of room: .fs-ind-compact, then .fs-bar-actrow ----
|
||||
*
|
||||
* Both are set by fs-chrome.js (fitCluster) by measurement, and both belong to the BAR rather
|
||||
* than to a layout: the top layout is a bar at every width and the sidebar layout becomes one on
|
||||
* a phone. Under a data-layout="top" guard they miss the common case — a phone in the default
|
||||
* sidebar layout, whose bar then shows the full pills with Log out wrapped alone onto a second
|
||||
* row at the left edge.
|
||||
*
|
||||
* Step 1, .fs-ind-compact: the pills lose their prose and become icon squares matching the other
|
||||
* cluster controls, which frees ~200px and on the top layout is often enough to keep the MENU on
|
||||
* the brand's row as well.
|
||||
* Step 2, .fs-bar-actrow: the cluster still does not fit, so the brand row takes the full width
|
||||
* and the whole cluster moves down together, keeping the right edge for free from the auto
|
||||
* inline-start margin above. max-width must be released along with the flex basis, or the 30ch
|
||||
* cap leaves the brand short of the row it was just given and the cluster rides back up. */
|
||||
/* .fs-ind-compact then .fs-bar-actrow, both by fitCluster (fs-chrome.js) — chrome.md, "Right
|
||||
* cluster: two fit steps". max-width releases with the flex basis, or the 30ch cap leaves
|
||||
* the brand short of the row it was just given. */
|
||||
.fs-sidebar.fs-bar-actrow .fs-brandrow { flex: 1 1 100%; max-width: 100%; }
|
||||
|
||||
/* the poll pill: label off, masked glyph on. Static green arrows, grey when the poll is paused —
|
||||
@@ -259,7 +199,7 @@
|
||||
flex: 0 0 auto; order: 2; justify-content: center;
|
||||
width: var(--fs-btn-size); height: var(--fs-btn-size); padding: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
background: var(--fs-bar-ctl-bg);
|
||||
background: transparent;
|
||||
}
|
||||
/* on a square button the BORDER carries the state — the fill and the ink come from the
|
||||
* shared hover/active groups in 10-chrome.css */
|
||||
@@ -271,45 +211,19 @@
|
||||
.fs-logout .fs-ico { width: 18px; height: 18px; flex: 0 0 auto; }
|
||||
.fs-sidebar ul.nav .fs-ico { flex: 0 0 auto; }
|
||||
|
||||
/* ---- the vertical sidebar: the single override, the only thing that is not a bar ----
|
||||
* Guarded positively (never write a `:not([data-layout=…])` guard). Everything the bar declared
|
||||
* above must be answered here, which is what makes it checkable.
|
||||
*
|
||||
* When it applies is not "viewport >= 768px": what matters is how much room is left for the
|
||||
* CONTENT once the sidebar takes its cut, and that cut is not constant — 224px expanded, 68px as
|
||||
* a rail — so the two give way at different widths, which one breakpoint cannot express.
|
||||
* `data-narrow` is set by fs-chrome.js (fitShell) when the content column would fall below
|
||||
* --fs-content-min, measured from the sidebar's ACTUAL cut.
|
||||
*
|
||||
* `min-width: 521px` is not a second guess but a floor keeping the chrome correct with JS
|
||||
* DISABLED, where no data-narrow is ever written: below 521 no cut leaves 500px anyway, so it
|
||||
* states the measurement's conclusion in advance. */
|
||||
/* The vertical sidebar: the single override, guarded POSITIVELY (never `:not([data-layout=
|
||||
* …])`); everything the bar declared above must be answered here. The `data-narrow`
|
||||
* measurement and the 521px JS-disabled floor: chrome.md, "`data-narrow`". */
|
||||
@media (min-width: 521px) {
|
||||
/* Scroll model for the desktop sidebar layout: the shell is pinned to the viewport and the
|
||||
* CONTENT column scrolls inside itself, so the sidebar is a STATIC element that stays put —
|
||||
* never position:sticky. That is the fix for issue #7: a sticky sidebar is a composited GPU
|
||||
* layer, and with overflow:visible (needed so the rail flyouts escape sideways) every hover
|
||||
* repaint left a 1px seam of the flyout at the layer's edge until a full repaint cleared it.
|
||||
* A static sidebar is not a layer, so there is nothing to leave stale, and it drops the
|
||||
* z-index juggling too: sticky always makes a stacking context, while a static sidebar makes
|
||||
* none, so the flyouts resolve --fs-z-flyout in the root stacking context. Confined to the
|
||||
* desktop sidebar; the top layout and the phone bar keep window scrolling. */
|
||||
/* Static, never sticky (issue #7) — chrome.md, "A static sidebar, never position:
|
||||
* sticky". Confined here; the top layout and the phone bar keep window scrolling. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-shell {
|
||||
flex-direction: row; height: 100svh; overflow: hidden;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-main {
|
||||
height: 100svh; overflow-y: auto;
|
||||
/* `contain: paint`, the fix for issue #12: .fs-shell is height:100svh with overflow:hidden and
|
||||
* .fs-main is its own overflow:auto scroller, so Chrome never grows the document past the
|
||||
* viewport — but Firefox propagates ~158px of an out-of-flow descendant's scrollable
|
||||
* overflow past both overflow ancestors up to the initial containing block, painting a
|
||||
* second, whole-page scrollbar beside .fs-main's own (measured on the Overview, where the
|
||||
* .fs-ovl grid makes the column ~2800px tall: html.scrollHeight 1058 against a 900px
|
||||
* viewport, and 900 once contained). overflow:hidden does not stop it: it hides the extra
|
||||
* scrollbar while the document still scrolls those 158px, dragging the fixed sidebar
|
||||
* off-screen. Paint containment makes .fs-main the containing block for that descendant
|
||||
* and clips it. LuCI modals are position:fixed on <body>, not inside .fs-main, so the new
|
||||
* containing block does not re-base them. No-op in Chrome. */
|
||||
/* Firefox-only overflow leak past the static sidebar (issue #12) — chrome.md,
|
||||
* "contain: paint … is the fix for issue #12". */
|
||||
contain: paint;
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar {
|
||||
@@ -344,8 +258,7 @@
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-railtoggle { display: inline-flex; }
|
||||
/* :not(:empty):not(.single) in the SHOW rule, not a separate hide pair: the bar's base
|
||||
* rule (top of this file) already hides .fs-modemenu, so an empty/single-mode strip
|
||||
* simply never opts in. The hide pair existed twice (here and 50-toplayout.css),
|
||||
* byte-identical and unpinned — the drift shape @mirror exists for, folded instead. */
|
||||
* simply never opts in. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-modemenu:not(:empty):not(.single) { display: flex; margin-top: var(--fs-space-1); }
|
||||
|
||||
/* vertical main nav */
|
||||
@@ -359,11 +272,8 @@
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li > a {
|
||||
display: flex; align-items: center; gap: var(--fs-space-3);
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3);
|
||||
/* Bold is the root level's own signal, and the one the type scale can promise:
|
||||
* `--fs-weight-bold` has a face everywhere, while `--fs-weight-normal` has none of its own
|
||||
* — so lightening the CHILDREN instead looks like hierarchy on a system stack and like
|
||||
* nothing at all on a router whose admin installed a 600/700 subset. Only the accordion is
|
||||
* deep enough to need the distinction. */
|
||||
/* Bold is the root level's own signal (--fs-weight-normal has no face of its own, so
|
||||
* lightening children reads as hierarchy instead); only the accordion needs it. */
|
||||
font-size: var(--fs-type); font-weight: var(--fs-weight-bold); white-space: normal;
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -387,22 +297,12 @@
|
||||
border: 0; border-radius: 0; box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* and the bar's hover bridge goes with the popup. The panel is static here, so the
|
||||
* bridge's containing block falls back to the <li> — an invisible 10px strip sitting
|
||||
* ABOVE the section, i.e. over the previous menu item, swallowing its clicks. The rail
|
||||
* (below) is a popup again but flies its panel out sideways, and brings its own
|
||||
* horizontal bridge (li.has-sub::after). */
|
||||
/* the bar's hover bridge does not follow: static here, its containing block would fall
|
||||
* back to the <li> and swallow clicks above it. The rail brings its own (::after). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.has-sub > ul::before { content: none; }
|
||||
/* ---- the open section is a group, and has to look like one ----
|
||||
*
|
||||
* An indent alone does not say where a long section ends: after ten children the next root
|
||||
* category reads as an eleventh child (issue #37). A hairline down the group's reading edge
|
||||
* draws that boundary and the wider bottom margin separates the last child from the next
|
||||
* category.
|
||||
*
|
||||
* No `:not([data-rail="true"])` guard: in the rail this same `ul` becomes a flyout card,
|
||||
* whose rule below restates `border` and `margin` as shorthands at higher specificity, so
|
||||
* neither declaration here survives into it. */
|
||||
/* An open section is a GROUP: an indent alone does not say where it ends, and after ten
|
||||
* children the next root category read as an eleventh (issue #37). No :not([data-rail])
|
||||
* guard needed — the rail's own rule below restates these at higher specificity. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar ul.nav > li.open > ul {
|
||||
display: flex;
|
||||
margin: var(--fs-space-1) 0 var(--fs-space-3) var(--fs-space-7);
|
||||
@@ -436,9 +336,9 @@
|
||||
flex-wrap: wrap; gap: var(--fs-space-1-5);
|
||||
padding: 0 var(--fs-space-1-5) var(--fs-space-1);
|
||||
}
|
||||
/* In the column the indicator is not one of a row of buttons but one of a stack of
|
||||
* ROWS — Appearance and Log out below it run the sidebar's full width. A pill floating
|
||||
* at 87px in a 195px column read as a stray chip; give it its neighbours' width. */
|
||||
/* In the column the indicator is one of a stack of ROWS, not a row of buttons —
|
||||
* Appearance and Log out below it run the sidebar's full width, so a pill at its own
|
||||
* content width read as a stray chip. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators .label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow]) .fs-sidebar #indicators [data-indicator] {
|
||||
flex: 1 1 100%;
|
||||
@@ -447,32 +347,21 @@
|
||||
}
|
||||
|
||||
|
||||
/* THE APPEARANCE CONTROLS USED TO LIVE HERE, as a popover on <body> plus the segmented and
|
||||
* slider controls it was built from. They are a PAGE now (System -> Appearance) and their CSS
|
||||
* moved with them, to styles/pages/80-appearance.css — a page's styles belong in the page layer,
|
||||
* and the shell no longer draws any of it. */
|
||||
/* The Appearance controls used to live here (a popover on <body>). They are a PAGE now
|
||||
* (System -> Appearance): styles/pages/80-appearance.css. */
|
||||
|
||||
/* ---- collapsed sidebar (icon rail) ----
|
||||
* <html data-rail="true">, persisted in localStorage and re-applied before paint by head.ut.
|
||||
* Labels collapse and a section's submenu becomes a flyout anchored right of its icon, opened on
|
||||
* hover or on focus — which is what a tap on the section link produces on touch.
|
||||
*
|
||||
* The guard must be exactly the vertical sidebar's: same media floor, same :not([data-narrow]).
|
||||
* It once said `min-width: 768px` against the vertical block's 521, and between the two the
|
||||
* vertical rules applied without the rail ones, so the sidebar sprang back to its full 224px —
|
||||
* it EXPANDED as the window shrank — while the JS measurement was subtracting the rail's 68px.
|
||||
* The rail is a MODE of the vertical sidebar and can never be visible under conditions the
|
||||
* vertical sidebar is not. */
|
||||
/* Collapsed sidebar (icon rail): <html data-rail="true">, persisted in localStorage and
|
||||
* re-applied before paint by head.ut. Labels collapse and a section's submenu becomes a
|
||||
* flyout anchored right of its icon. The guard below must equal the vertical sidebar's
|
||||
* exactly — chrome.md, "The rail's guard must be the vertical sidebar's, exactly". */
|
||||
@media (min-width: 521px) {
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar {
|
||||
width: var(--fs-rail-w); padding: var(--fs-space-5) var(--fs-space-2-5);
|
||||
/* the default `overflow-y: auto` computes overflow-x to `auto` as well,
|
||||
* which would clip the flyouts; the rail is short enough not to scroll */
|
||||
overflow: visible;
|
||||
/* NO z-index / no sticky: the sidebar is a static element (see the scroll-model note
|
||||
* above), so it is not a composited layer and its overflow:visible flyouts leave no
|
||||
* stale seam (issue #7). The flyouts still cover the content on their own --fs-z-flyout
|
||||
* in the root stacking context. */
|
||||
/* no z-index/sticky: static, like the vertical sidebar (issue #7) — its flyouts
|
||||
* still cover the content on their own --fs-z-flyout in the root stacking context. */
|
||||
}
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brandrow { flex-direction: column; gap: var(--fs-space-2-5); padding: 0 0 var(--fs-space-5); }
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-brand { flex: 0 0 auto; justify-content: center; }
|
||||
@@ -486,12 +375,9 @@
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-themerow > .fs-label,
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-logout > .fs-label,
|
||||
/* The chevron is an affordance for a section you can UNFOLD; a rail item has no label and no
|
||||
* accordion — its children fly out sideways — so it says nothing and crowds the icon.
|
||||
*
|
||||
* The selector must be this long: the vertical override turns the chevron back on at
|
||||
* (0,6,3), so a plainer `.fs-sidebar .fs-chevron` here is only (0,6,0) and loses. Match its
|
||||
* shape and add the rail attribute: (0,7,3). */
|
||||
/* a rail item has no accordion — its children fly out sideways — so the chevron says
|
||||
* nothing and crowds the icon. Selector kept this long because the vertical override
|
||||
* turns the chevron back on at (0,6,3), so a plainer form here would only be (0,6,0). */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a .fs-chevron { display: none; }
|
||||
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > a,
|
||||
@@ -514,9 +400,8 @@
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li:not(.has-sub) > a:hover::after { opacity: 1; }
|
||||
|
||||
/* the flyout: opened by hover on a mouse, or by .open which the menu JS toggles on tap
|
||||
* (exclusively — menu-footstrap.js flyoutMode). The hover rule is gated on a real
|
||||
* pointer: mobile browsers leave :hover stuck on the tapped element, which would block
|
||||
* tap-to-close. */
|
||||
* (exclusively — menu-footstrap.js flyoutMode). Hover gated on a real pointer: mobile
|
||||
* browsers leave :hover stuck on the tapped element, which would block tap-to-close. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub > ul {
|
||||
display: none;
|
||||
position: absolute; inset-inline-start: 100%; top: -8px; margin: 0; margin-inline-start: var(--fs-space-2-5);
|
||||
@@ -529,13 +414,8 @@
|
||||
@media (hover: hover) {
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li.has-sub:hover > ul { display: flex; }
|
||||
}
|
||||
/* The flyout's heading, so the collapsed icon still names its section.
|
||||
*
|
||||
* It must UNDO the bar's hover bridge (issue #22): this same ::before is the bar's invisible
|
||||
* 10px hover bridge above the panel, so re-pointing `content` at the title while leaving
|
||||
* `position`, `inset` and `height` in force renders the heading as a 10px-tall absolute box
|
||||
* above the flyout, clipped in half by the panel's own overflow. The rail brings its own
|
||||
* bridge as `li.has-sub::after`. */
|
||||
/* The flyout's heading reuses the bar's hover-bridge pseudo-element (issue #22) —
|
||||
* chrome.md, same heading as above. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar ul.nav > li > ul[data-title]::before {
|
||||
content: attr(data-title);
|
||||
position: static; inset: auto; height: auto;
|
||||
@@ -549,19 +429,10 @@
|
||||
width: 12px; height: 100%;
|
||||
}
|
||||
|
||||
/* Collapsed rail: swap the "Refreshing" word for a green refresh glyph — a masked SVG tinted
|
||||
* with --fs-good, so it follows the palette. Only poll-status becomes an icon; other
|
||||
* indicators keep their pill.
|
||||
*
|
||||
* These two must carry the FULL guard, not the [data-rail] half alone: head.ut stamps
|
||||
* data-rail from localStorage pre-paint while data-narrow is written later by an async
|
||||
* module, so on a narrow phone a rail user has every other rail rule excluded by the floor
|
||||
* while these two match — the pill paints as a bare green glyph inside the phone bar until
|
||||
* the modules land, permanently if they fail.
|
||||
*
|
||||
* The box is --fs-ctl-h-sm rather than --fs-btn-size: one step under the bar's compact pill,
|
||||
* the rail being a column of icons rather than a row of equal squares. A literal here would
|
||||
* silently opt out of the Density axis. */
|
||||
/* "Refreshing" -> a green masked refresh glyph. Needs the FULL guard, not [data-rail]
|
||||
* alone: data-rail stamps pre-paint, data-narrow lands later from an async module, so a
|
||||
* narrow phone would paint a bare glyph in the phone bar until the modules land.
|
||||
* --fs-ctl-h-sm follows the Density axis, one step under the bar's pill. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"] {
|
||||
font-size: 0; width: var(--fs-ctl-h-sm); height: var(--fs-ctl-h-sm); padding: 0;
|
||||
border: 0; box-shadow: none; background: var(--fs-good);
|
||||
@@ -569,45 +440,29 @@
|
||||
-webkit-mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
mask: var(--fs-icon-refresh) center / var(--fs-icon-refresh-sz) no-repeat;
|
||||
/* @endmirror */
|
||||
/* STATIC, deliberately. A spinner promises something you are WAITING for; this is a
|
||||
* poll running quietly in the background forever, and a permanently spinning glyph
|
||||
* makes an idle router look busy. The spin also broke the click that pauses the poll —
|
||||
* a target that never stops moving cannot be hit. */
|
||||
/* Static, deliberately: this is a poll running quietly forever, not something being
|
||||
* waited on, and a permanently spinning glyph also broke the click that pauses it. */
|
||||
}
|
||||
|
||||
/* paused: the same glyph gone quiet. Without this the rail kept saying GREEN while the poll
|
||||
* was stopped — the one thing the indicator exists to tell you, and it was lying. (The pill
|
||||
* gets this for free: its --fs-good only applies to [data-style="active"].) */
|
||||
/* paused: the same glyph gone quiet — without it the rail kept saying GREEN while the
|
||||
* poll was stopped, the one thing the indicator exists to report. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] .fs-sidebar #indicators [data-indicator="poll-status"]:not([data-style="active"]) {
|
||||
background: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* Every other indicator — "Unsaved Changes: N" and whatever a third-party app shows. Its label
|
||||
* is prose and the rail is 68px: that pill wants 86px, wraps onto three lines and hangs 34px
|
||||
* past the rail's edge over the content (issue #14). The pill is not ours to shorten, so it
|
||||
* becomes a square in the poll glyph's box showing the COUNT alone, lifted into
|
||||
* data-fs-badge by fs-chrome.js because a text node cannot be reached by a selector.
|
||||
* `font-size: 0` kills the prose without removing it: the label stays in the DOM for a
|
||||
* screen reader and in the `title` for the pointer.
|
||||
*
|
||||
* What the badge says is decided in the JS, so this stays one rule per box: as two CSS rules
|
||||
* it needs an extra attribute and a `:not()` to tell them apart, which pushes the sheet's
|
||||
* widest selector past the css-metrics ratchet.
|
||||
*
|
||||
* `.fs-sidebar` is deliberately absent: #indicators is an id. */
|
||||
/* Badges its count instead of wrapping its prose (issue #14) — chrome.md, "A
|
||||
* collapsed-rail indicator badges its count". One rule per box: two would need an extra
|
||||
* attribute + :not() past the css-metrics specificity ratchet. `.fs-sidebar` is
|
||||
* deliberately absent: #indicators is an id. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"]) {
|
||||
/* min-width, not width: the count is unbounded (a big config can queue three digits) and
|
||||
* a fixed box would clip it. It grows from the poll glyph's square and stops at the
|
||||
* rail's inner width. */
|
||||
/* min-width, not width: the count is unbounded (a big config can queue three digits)
|
||||
* and a fixed box would clip it. */
|
||||
font-size: 0; min-width: var(--fs-ctl-h-sm); max-width: 100%; height: var(--fs-ctl-h-sm);
|
||||
box-sizing: border-box; padding: 0 var(--fs-space-1); overflow: hidden;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
/* The top bar's compact step paints the same badge (the .fs-ind-compact rule above, whose
|
||||
* copy of this paint is mirror-pinned to it) and CANNOT share this rule: that one applies at
|
||||
* every width, while this one must stay inside the 521px floor —
|
||||
* below it a rail user has data-rail stamped pre-paint but data-narrow not yet written, so a
|
||||
* merged rule would strip the phone bar's indicator to a bare digit until the modules land. */
|
||||
/* .fs-ind-compact above paints the same badge (mirror-pinned) but cannot SHARE this
|
||||
* rule: it applies at every width, this one must stay inside the 521px floor. */
|
||||
:root[data-layout="sidebar"]:not([data-narrow])[data-rail="true"] #indicators [data-indicator]:not([data-indicator="poll-status"])::after {
|
||||
/* @mirror ind-badge/paint */
|
||||
content: attr(data-fs-badge);
|
||||
@@ -628,32 +483,16 @@
|
||||
.fs-indicators { flex-wrap: wrap; }
|
||||
.fs-content { padding: var(--fs-space-5) var(--fs-content-pad); flex: 1 1 auto; width: 100%; max-width: var(--fs-content-max); margin: 0 auto; }
|
||||
|
||||
/* ---- the staging area a client navigation renders into ----
|
||||
* fs-router.js renders the incoming page into a second `#view` inside this wrapper, first in
|
||||
* tree order, and moves its children into the live one when the render completes, so the page on
|
||||
* screen is never emptied while the next one loads.
|
||||
*
|
||||
* Hidden but LAID OUT, and the three declarations are not interchangeable. `visibility: hidden`
|
||||
* takes it off the screen while keeping the box: several views size themselves from the element
|
||||
* they render into, and `display: none` would hand them a zero width they keep for the life of
|
||||
* the instance. `height: 0` stops the page under it from moving, and `overflow: clip` keeps the
|
||||
* content overflowing that zero height from painting — `clip` rather than `hidden`, since a
|
||||
* scroll container would be a new scrollport in the middle of the content column. */
|
||||
/* fs-router.js builds the incoming page in a second #view here, then moves its children into
|
||||
* the live one, so the visible page is never emptied while the next one loads. `visibility:
|
||||
* hidden` (not display: none) keeps the box so a self-sizing view sees no zero width;
|
||||
* `height: 0` stops the page moving; `overflow: clip` (not hidden) skips a second scrollport. */
|
||||
.fs-staging { visibility: hidden; height: 0; overflow: clip; }
|
||||
|
||||
/* ---- and the hairline that says a slow navigation is under way ----
|
||||
* Shown only once a navigation outlives 150 ms (fs-router.js), so a warm click never flashes it.
|
||||
*
|
||||
* Fixed, at the top of the viewport, and a child of <body> — all three load-bearing. In the
|
||||
* content column it is a flex item of `.fs-main`, and a column flex container shrinks its 2px to
|
||||
* zero (measured: height 0 in the sidebar layout, and with `margin-bottom: -2px` the content
|
||||
* painted over what was left); and it cannot live inside `.fs-shell` either, that box carrying
|
||||
* `contain: paint`, which
|
||||
* makes a fixed descendant position against the shell and be clipped by it. The top of the
|
||||
* viewport is also where a browser puts its own progress.
|
||||
*
|
||||
* `transform` and `opacity` only, both composited, so this cannot cost the render it is
|
||||
* reporting on. Reduced motion keeps the bar and drops its animation. */
|
||||
/* Slow-navigation hairline, shown once a nav outlives 150ms (fs-router.js). Fixed, and a
|
||||
* child of <body>, not .fs-main (a column flex container shrinks its 2px to zero) or
|
||||
* .fs-shell (contain: paint would re-base and clip a fixed descendant). transform/opacity
|
||||
* only, both composited, so drawing this never costs the render it reports on. */
|
||||
#fs-nav-progress {
|
||||
position: fixed; inset-inline: 0; top: 0;
|
||||
height: 2px;
|
||||
@@ -679,43 +518,22 @@
|
||||
}
|
||||
|
||||
/* ---------- footer ----------
|
||||
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright (absorbed
|
||||
* from base, which keeps only the flex skeleton), so a third-party view emitting its own
|
||||
* <footer> stays themed too. */
|
||||
* Bare `footer`, not `footer.fs-footer`: the theme owns footer paint outright, so a
|
||||
* third-party view emitting its own <footer> stays themed too. */
|
||||
footer {
|
||||
padding: var(--fs-space-3-5) var(--fs-content-pad); border-top: var(--fs-hairline);
|
||||
color: var(--fs-dim); font-size: var(--fs-type); background: var(--fs-bg);
|
||||
/* Centre the flex LINE, not just the text: base makes the footer a flex container
|
||||
* (base/50-chrome.css) and declares no `justify-content`, so the initial `flex-start` packs
|
||||
* the single <span> at the start and leaves text-align nothing to centre, the span
|
||||
* shrinking to its content. */
|
||||
/* centre the flex LINE (base declares no justify-content) so text-align has something
|
||||
* to centre rather than a <span> already shrunk to its content */
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
/* And space the lines, because the two links become adjacent TARGETS the moment the line wraps.
|
||||
* The footer is one sentence carrying the LuCI link and the OpenWrt link; at the width where
|
||||
* it breaks they land on consecutive lines, and with the inherited leading that is 16px
|
||||
* between their centres — inside the 24px envelope WCAG 2.2 SC 2.5.8 requires around a
|
||||
* target smaller than 24x24, which an inline link always is. Found by tools/live-audit.mjs on
|
||||
* CI's runner and not on the stands: the wrap width depends on text metrics, so the same
|
||||
* 568px viewport breaks the line there and not here. 26px, not 24: the criterion is that the
|
||||
* two circles must not meet, so 24 is the boundary rather than a pass.
|
||||
*
|
||||
* A FLOOR, not a fixed number, and not a bare ratio either: `--fs-type` follows the Density
|
||||
* axis, so a plain `26px` would be the one leading in this file that ignores it, while a
|
||||
* unitless `2` follows the axis straight back under 24px at Compact (11.7px x 2 = 23.4) —
|
||||
* where the wrap this rule exists for is most likely. `max()` is both: 26px at the default
|
||||
* and at Compact, 29.9px at Large. */
|
||||
/* target-size floor for the two links this sentence carries (WCAG 2.2 SC 2.5.8) —
|
||||
* chrome.md, "A footer link pair needs 26px". */
|
||||
line-height: max(26px, calc(var(--fs-type) * 2));
|
||||
}
|
||||
footer a { color: var(--fs-dim); }
|
||||
footer a:hover { color: var(--fs-accent); }
|
||||
|
||||
/* the phone bar is the base above; only the content gutter is width-driven */
|
||||
@media (max-width: 767px) {
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
}
|
||||
|
||||
|
||||
/* Notifications — ui.addNotification prepends .alert-message as the first child of
|
||||
* #maincontent (.fs-main). Order it above the content and give it card spacing. */
|
||||
.fs-main > .alert-message {
|
||||
@@ -726,25 +544,19 @@
|
||||
.fs-main > .fs-content { order: 2; }
|
||||
.fs-main > .fs-footer { order: 3; }
|
||||
|
||||
/* phone tier: content gutter, alert-message width and wrap — merged, none override each
|
||||
* other (distinct selectors), must stay after the base rules above they narrow */
|
||||
@media (max-width: 767px) {
|
||||
/* width:auto, not the base width above: that calc subtracts the DESKTOP gutter
|
||||
* (--fs-content-pad, 28px a side) while .fs-content's own gutter is 16px here — the
|
||||
* alert came out 24px narrower than the content and hugged the left (measured at 700px:
|
||||
* insets 16/40 where the content sits 16/16). auto + the side margins matches the
|
||||
.fs-content { padding: var(--fs-space-4); }
|
||||
/* width: auto, not the base width above: that calc subtracts the DESKTOP gutter while
|
||||
* .fs-content's own gutter is narrower here — auto plus the side margins tracks the
|
||||
* content column exactly at any width in this tier. */
|
||||
.fs-main > .alert-message { margin: var(--fs-space-3) var(--fs-space-4) 0; width: auto; }
|
||||
}
|
||||
|
||||
/* Phone: alerts (e.g. the sysupgrade banner, injected by ui.addNotification as a
|
||||
* `.fs-main > .alert-message` — a SIBLING of #view, not a child) lay their action buttons
|
||||
* in a nowrap flex row, so on a ~344px screen the trailing Dismiss button was shoved past
|
||||
* the panel and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main so it covers
|
||||
* those top-level alerts and any inside #view. */
|
||||
@media (max-width: 767px) {
|
||||
/* a top-level alert's action buttons were nowrap, so Dismiss was shoved past the panel
|
||||
* and clipped by .fs-main's overflow-x: clip. Scoped to .fs-main for #view's too. */
|
||||
.fs-main .alert-message { flex-wrap: wrap; }
|
||||
.fs-main .alert-message > * { min-width: 0; margin-inline-start: 0; }
|
||||
/* the action row is a nested flex that stayed nowrap, so the first button's label got
|
||||
* clipped when Dismiss sat beside it. Wrap the inner rows; let labels break. */
|
||||
/* the action row is a nested flex that stayed nowrap and clipped its first label */
|
||||
.fs-main .alert-message div { flex-wrap: wrap; min-width: 0; }
|
||||
.fs-main .alert-message .btn,
|
||||
.fs-main .alert-message button { white-space: normal; max-width: 100%; }
|
||||
@@ -755,36 +567,28 @@
|
||||
.fs-indicators [data-indicator],
|
||||
#indicators .label,
|
||||
#indicators [data-indicator] {
|
||||
/* An indicator sits in a ROW of controls — in the bar, followed by Search and Log out — so it
|
||||
* must look like one of them or the cluster reads as three unrelated things. The same
|
||||
* --fs-btn-size height (a box sized by its font's line-box comes out 28px, visibly the odd
|
||||
* one out) and the same --fs-radius corner, not --fs-radius-pill: a capsule beside two
|
||||
* rounded squares announces itself as a different kind of object, which it is not. Width
|
||||
* stays the content's. */
|
||||
/* wears the row's own --fs-btn-size and --fs-radius, not a pill, or it reads as a
|
||||
* different kind of object beside Search and Log out */
|
||||
display: inline-flex; align-items: center;
|
||||
min-height: var(--fs-btn-size);
|
||||
text-transform: none;
|
||||
font-weight: var(--fs-weight); font-size: var(--fs-type);
|
||||
/* the bar's control surface, not the panel: this pill sits in the same row as the square
|
||||
* buttons and wears what they wear. Verified on the live DOM rather than by reading —
|
||||
* ui.js emits the indicator as a bare `<span data-indicator=…>` with NO class, so a rule
|
||||
* written against `.label` (as one here briefly was) matches nothing at all. */
|
||||
background: var(--fs-bar-ctl-bg); background-image: none;
|
||||
/* the bar's control surface — ui.js emits a bare `<span data-indicator=…>` with NO
|
||||
* class, so a `.label`-keyed rule matches nothing */
|
||||
background: transparent;
|
||||
border: var(--fs-hairline);
|
||||
color: var(--fs-dim);
|
||||
border-radius: var(--fs-radius); padding: 0 var(--fs-space-3); box-shadow: none;
|
||||
}
|
||||
.fs-indicators [data-indicator][data-style="active"],
|
||||
#indicators [data-indicator][data-style="active"] { color: var(--fs-good); }
|
||||
/* nothing else gives a clickable indicator a pointer cursor (base's `header
|
||||
* [data-indicator]` rule carries geometry only, and this theme emits no <header> at all),
|
||||
* so "Refreshing" showed a text caret. */
|
||||
/* nothing else gives a clickable indicator a pointer cursor — base's rule carries geometry
|
||||
* only, and this theme emits no <header> at all — 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-chrome.js promotes a clickable indicator to role="button" with Enter and Space, so it
|
||||
* takes the control ring the buttons beside it take (55-buttons.css). :focus-visible, not
|
||||
* :focus: a pointer press on the pill should not draw a ring. */
|
||||
.fs-indicators [data-indicator][data-clickable]:focus-visible,
|
||||
#indicators [data-indicator][data-clickable]:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
@layer theme {
|
||||
/* One thin meter for every .cbi-progressbar (overview Memory/Storage/Network, Software
|
||||
* disk space, package manager …): slim rounded track, accent fill, and the LuCI title
|
||||
* value floated just above the bar's right edge (mono/dim) instead of stuffed inside a fat
|
||||
* pill. No per-page overrides. */
|
||||
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
|
||||
* the right edge instead of stuffed inside a fat pill. */
|
||||
.cbi-progressbar {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
@@ -19,10 +17,8 @@
|
||||
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. */
|
||||
/* Colour carries the reading too: data-fs-level set from JS (80/92% thresholds) every poll
|
||||
* tick. Solid tokens, no color-mix, nothing owed to 04-nocolormix.css. */
|
||||
.cbi-progressbar[data-fs-level="warn"] > div {
|
||||
background: var(--fs-warn);
|
||||
}
|
||||
@@ -31,34 +27,15 @@
|
||||
}
|
||||
.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
|
||||
* density axis and this one sat outside it. Identical at the default density. */
|
||||
position: absolute; inset-inline: auto 0; bottom: calc(100% + var(--fs-space-1)); top: auto;
|
||||
font-family: var(--fs-font-mono); font-size: var(--fs-type-xs); font-weight: var(--fs-weight-normal);
|
||||
color: var(--fs-dim); white-space: nowrap; text-shadow: none;
|
||||
}
|
||||
|
||||
/* The value sits BESIDE the bar wherever the bar already shares its line with a name.
|
||||
*
|
||||
* The default above puts it over the bar's far edge, which assumes the Overview's shape: the
|
||||
* meter owns a full-width line and its name sits on the line above, so the value lands at the
|
||||
* far end of that name's line and the two read as one row. Two shapes are not that, and CSS
|
||||
* cannot ask whether a row carries its own label, so each is named:
|
||||
*
|
||||
* `.cbi-value-field > .cbi-progressbar` the meter IS the field of a CBI form row, where the
|
||||
* value otherwise lands against the row divider rather than beside its own bar
|
||||
* `.table:has(.tr.table-titles) .td …` a meter COLUMN in a multi-column data table, where
|
||||
* the floated value lands on the row divider too
|
||||
*
|
||||
* Both want the same geometry and differ in one number, so the geometry is written once and
|
||||
* `--fs-meter-value` is the knob: a form row holds `-140.0 dBm`, half again as wide as a table
|
||||
* cell's `100% (1.2 GiB)`. The Overview and the package manager keep the value above the bar,
|
||||
* their meters being neither shape. */
|
||||
/* ONE RULE PER SHAPE, not one selector list for both, and the split is not cosmetic: the table
|
||||
* half needs `:has()`, and an engine that cannot parse a selector discards the WHOLE rule —
|
||||
* siblings included. Grouped, the form row (which needs nothing new) lost its placement on such
|
||||
* an engine along with the cell that was unreachable there anyway. The declarations they share
|
||||
* are @mirror-pinned instead, so the two copies cannot drift. */
|
||||
/* Value moves BESIDE the bar where the row has no name above it: a CBI form row and a table
|
||||
* meter column, sharing one geometry via --fs-meter-value. ONE RULE PER SHAPE, not a shared
|
||||
* selector list — the table half needs :has(), and a non-parsing engine drops the WHOLE
|
||||
* rule, form row included. @mirror pins the shared declarations. */
|
||||
.cbi-value-field > .cbi-progressbar {
|
||||
/* @mirror meter/beside */
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
@@ -76,8 +53,7 @@
|
||||
margin-inline-end: var(--fs-meter-value);
|
||||
/* @endmirror */
|
||||
--fs-meter-value: var(--fs-space-10);
|
||||
/* narrower than this it stops reading as a meter at all; the column gives up the rest. */
|
||||
min-width: 60px;
|
||||
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
|
||||
}
|
||||
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar::after {
|
||||
/* @mirror meter/beside-value */
|
||||
@@ -86,21 +62,9 @@
|
||||
/* @endmirror */
|
||||
}
|
||||
|
||||
/* …and back above the bar once that column becomes a card.
|
||||
*
|
||||
* The rule above puts the value beside the bar because a table column shares its row with other
|
||||
* columns. A carded row is not that: `.fs-stacked` gives each cell half the card, so "beside"
|
||||
* is outside the cell and the value runs across the neighbouring column and over the label
|
||||
* above it (reported twice from a phone, at every density).
|
||||
*
|
||||
* So the card puts it back over the bar's far edge and buys it a line: the bar takes a top
|
||||
* margin of one --fs-type-xs line, so the value sits between the label and the bar instead of
|
||||
* on either. 6-9px of overlap before, none after.
|
||||
*
|
||||
* The selector carries `:has(.tr.table-titles)` for specificity alone — that is what the
|
||||
* beside-rule above matches on, and `:has()` counts its argument, which makes it (0,6,0), so a
|
||||
* plainer selector here loses (measured: `margin-inline-end` still 40px). The title row is
|
||||
* still in the DOM when the table is carded; the card only hides it. */
|
||||
/* Carded: "beside" ran the value over the neighbouring column's label (`.fs-stacked` halves
|
||||
* the row). Back over the bar with a one-line top margin (was 6-9px overlap).
|
||||
* `:has(.tr.table-titles)` stays for SPECIFICITY, matching the beside-rule's (0,6,0). */
|
||||
.cbi-section .table.fs-stacked:has(.tr.table-titles) .td .cbi-progressbar {
|
||||
margin-inline-end: 0;
|
||||
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.cbi-section { padding: var(--fs-card-pad); margin-bottom: var(--fs-card-gap); }
|
||||
/* a bare <legend>; the ones inside a .cbi-section are covered below. The ink is the heading
|
||||
* ramp's (45-misc.css) — legend is kept out of that group so this rule stays its one owner;
|
||||
* no line-height here: base's `fieldset legend` sets 1 and every real legend sits in a
|
||||
* fieldset, so stating leading here would silently repaint them all through the layer. */
|
||||
/* bare <legend> only; ink from the heading ramp. No line-height — base already sets 1. */
|
||||
legend { font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
|
||||
/* overview (luci-mod-status) DOM:
|
||||
@@ -23,42 +20,22 @@
|
||||
font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
margin: 0; padding: 0; border: 0;
|
||||
}
|
||||
/* A bare section title (no .cbi-title wrapper) sat flush against the content below it —
|
||||
* on System → System the "System Properties" h3 touched the tab strip. Give it the same
|
||||
* 12px the .cbi-title wrapper already carries above. Excludes .cbi-title h3: that variant
|
||||
* gets its gap from the wrapper's own margin-bottom, so flagging the inner h3 too would
|
||||
* double it. */
|
||||
/* bare title (no wrapper) sat flush below — same 12px; excludes .cbi-title h3, already gapped */
|
||||
.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 — has no air at all: on Status -> Overview the stock includes stack `table + h3 +
|
||||
* table` inside one .cbi-section, and the heading reads as another row of the table above it.
|
||||
* The rule above cannot cover it, `:first-child` being what excludes the one that opens the
|
||||
* card. The two values are the ladder's own: a heading is separated from what came before by
|
||||
* more than from what it introduces. */
|
||||
/* a heading FOLLOWING a table had no air; separates more from what precedes than 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;
|
||||
* a data table keeps the `.table` frame below. Nested (`.table .table`, `.cbi-value .table`)
|
||||
* and the config-diff dialog (`.cbi-modal .table`) go with key/value. */
|
||||
/* [id]/.fs-dt = data table; no id = key/value, no frame/clip; nested tables too */
|
||||
.cbi-section .table:not([id]):not(.fs-dt),
|
||||
.cbi-section-node .table:not([id]):not(.fs-dt),
|
||||
.cbi-modal .table, .table .table, .cbi-value .table {
|
||||
border: 0; border-radius: 0; overflow: visible; background: none;
|
||||
}
|
||||
.cbi-section .table { margin: 0; width: 100%; }
|
||||
/* A data table straight in #view (no section: package-manager, Startup) misses the width above,
|
||||
* so it sized to preferred width and overflowed the content column — which base already fixes
|
||||
* for EVERY `.table` (`width: 100%`, base/40-tables.css), and has for as long as both rules have
|
||||
* existed. Switched off against the 24 tables that match it across eight pages: no computed
|
||||
* style moved, so the rule only restated a value one layer down. Removed. */
|
||||
/* Config-table fields fill their cell — moved from base with the generic field box in
|
||||
* theme/60-inputs.css (in base these beat its 210px in-layer; left behind they would lose
|
||||
* to it by layer), together with the numeric-cell refinement below them. */
|
||||
/* moved from base with the generic field box (theme/60-inputs.css) */
|
||||
.table.cbi-section-table input[type="password"],
|
||||
.table.cbi-section-table input[type="text"],
|
||||
.table.cbi-section-table textarea,
|
||||
@@ -69,31 +46,15 @@
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-table-cell select,
|
||||
.table.cbi-section-table .td.cbi-section-table-cell .cbi-select { width: inherit; }
|
||||
/* The filter row (form.js renderHeaderRows) is the one place a field cannot claim the width it
|
||||
* wants: it lives in a table HEADER cell, so the column is as wide as the DATA under it. The
|
||||
* rule above makes it fill that cell; what does not fit is the TEXT, LuCI writing the
|
||||
* placeholder as `Filter ` plus the column title, so a long translated title is sliced
|
||||
* mid-glyph (issue #12). The flag column is the worse half: form.js writes `width: 30px` inline
|
||||
* there, of which the theme's padding ate 24, leaving less than one glyph.
|
||||
*
|
||||
* A denser box and denser type buy back ~20px per column; the ellipsis changes the FAILURE, a
|
||||
* placeholder too long for its column now ending instead of being cut through a letter. Nothing
|
||||
* is lost to it — the column title says the same word directly above. No !important, so the
|
||||
* inline 30px still stands: it is upstream's sizing for a one-character field. */
|
||||
/* filter placeholder sliced mid-glyph on a long title (issue #12); ellipsis changes the
|
||||
* failure instead. No !important — the inline 30px flag width still stands. */
|
||||
.table.cbi-section-table .cbi-section-filter {
|
||||
padding: var(--fs-space-1) var(--fs-space-1-5);
|
||||
font-size: var(--fs-type-xs);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* width:100% cannot shrink a table below min-content — one long unbreakable value holds the
|
||||
* floor. Breaking values reflows it into the column instead of scrolling, and that break is the
|
||||
* DEFAULT for every cell (base/40-tables.css), so a copy here would say nothing the cascade does
|
||||
* not.
|
||||
*
|
||||
* A rule on this property is back a few lines down, and it is the INVERSE rather than a copy: a
|
||||
* data table gets `break-word` while it is still a table, so its columns keep a floor and an
|
||||
* overflow can be seen. Read that block before touching either. DHCP leases need finer control
|
||||
* (only DUID/IPv6 may break) and get it from `white-space: nowrap`: pages/50-leases.css. */
|
||||
/* width:100% cannot shrink below min-content; the inverse rule further down restores the
|
||||
* floor on a data table — read that block before touching either. */
|
||||
.cbi-section .table .td,
|
||||
.cbi-section .table .th {
|
||||
padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-2-5) 0;
|
||||
@@ -101,86 +62,34 @@
|
||||
line-height: 1.45; vertical-align: middle;
|
||||
text-align: start;
|
||||
}
|
||||
/* Row actions hug the far edge. Base says so too but loses to the `text-align: start` above (an
|
||||
* earlier layer), so restate it. `.cbi-section-table-cell` is the discriminator:
|
||||
* form.TableSection's action cells carry it (firewall zones, port forwards); the hand-built
|
||||
* ones of Wireless (`.td.middle`) and DHCP leases do not, and stay left. */
|
||||
/* hugs the far edge, beating text-align:start above (form.TableSection's own action cells;
|
||||
* hand-built ones stay left) */
|
||||
.cbi-section .table .td.cbi-section-table-cell.cbi-section-actions { text-align: end; }
|
||||
.cbi-section .table .tr:last-child .td { border-bottom: 0; }
|
||||
/* Case is the app's, not the theme's: `text-transform: uppercase` stood here and printed LuCI's
|
||||
* own `IPv4 address` as `IPV4 ADDRESS`, which reads as a typo in a column heading rather than as
|
||||
* a style (issue #39). Everything else that made these read as headings — the smaller size, the
|
||||
* weight, the faint ink, the tracking — stays. Four rules carry the same decision: the title row
|
||||
* below, the card label (mirrored into theme/65-dropdown.css) and the alert's table in
|
||||
* theme/35-alerts.css. */
|
||||
/* case is the app's: uppercase broke LuCI's own casing (issue #39); weight/size/ink alone
|
||||
* still read as a heading. */
|
||||
.cbi-section .table .th {
|
||||
color: var(--fs-eyebrow-color); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs);
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
}
|
||||
|
||||
/* --- DATA tables ([id] or JS-tagged .fs-dt): leases, wifi, processes, routes … ---
|
||||
* Symmetric padding so cells clear the table's own contour (below).
|
||||
*
|
||||
* `:is([id], .fs-dt)` is one discriminator at ONE specificity: written as two selector lists the
|
||||
* two names for "data table" land at different weights — `.cbi-section .table[id] .td` (0,4,0)
|
||||
* against `table.fs-dt .td` (0,2,1), the latter losing to the key/value default (0,3,0) — so a
|
||||
* table known only by the JS tag keeps the flush-left padding, with no mono face, no row hover
|
||||
* and no header rule. Status -> Routing emits exactly that shape. */
|
||||
/* --- DATA tables ([id] or JS-tagged .fs-dt) --- `:is()` at ONE specificity: split, a table
|
||||
* known only by the tag (Status -> Routing) lost to key/value's default. */
|
||||
.cbi-section .table:is([id], .fs-dt) .td,
|
||||
.cbi-section .table:is([id], .fs-dt) .th,
|
||||
table.fs-dt .td, table.fs-dt .th { padding: var(--fs-space-2-5) var(--fs-space-3); }
|
||||
.cbi-section .table:is([id], .fs-dt) .td { font-family: var(--fs-font-mono); font-size: var(--fs-type); color: var(--fs-text); }
|
||||
|
||||
/* ---- the floor, and it belongs to the one tier a measurement reads ----
|
||||
*
|
||||
* `base/40-tables.css` breaks every value `anywhere`, the one break value that also lowers the
|
||||
* cell's min-content contribution — to a single character. That is what keeps a table nobody
|
||||
* measures inside its card, and it is also a LIE told to the engine: with no floor, auto table
|
||||
* layout is free to starve a column and the browser answers "it fits", truthfully and uselessly.
|
||||
*
|
||||
* So the floor goes back exactly where it is READ: a data table, while it is still a table.
|
||||
* `break-word` breaks the same values in the same places — the two differ only in whether the
|
||||
* break counts towards min-content (css-text-3 §5.4) — so nothing about the rendering changes;
|
||||
* what changes is that the column can no longer be starved below it, and an overflow becomes
|
||||
* visible to `fit.overflows()`.
|
||||
*
|
||||
* `:not(.fs-stacked)` is the whole guard, and it is the state the fitter measures in (it strips
|
||||
* the class first, fs-fit rule 1). Once carded, every cell has the row to itself.
|
||||
*
|
||||
* base/40-tables.css is deliberately NOT touched: `break-word` there was tried and recorded as a
|
||||
* failure (a table stayed 355px wide in a 346px column, and .fs-main clips), a tier with no card
|
||||
* and no scroll having nowhere to put the overflow. */
|
||||
/* the min-content FLOOR belongs here: break-word (unlike base's -anywhere) does not lower
|
||||
* min-content, so an overflow stays visible to fit.overflows(). :not(.fs-stacked) is the
|
||||
* fitter's own measuring state. */
|
||||
.table.fs-dt:not(.fs-stacked) .td { overflow-wrap: break-word; }
|
||||
/* …and the ONE column the fitter decided may be shredded rather than card the whole table. Written
|
||||
* by fs-select.js on the cells of a single column, stripped before every measurement. (0,5,0) — it
|
||||
* beats the floor above on specificity, not on source order. */
|
||||
/* the ONE column the fitter allows to shred rather than card the table (fs-select.js);
|
||||
* (0,5,0) beats the floor above on specificity, not source order */
|
||||
.table.fs-dt:not(.fs-stacked) .td.fs-td-break { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---- and the one flag, because the view writes its shredding inline ----
|
||||
*
|
||||
* `luci-mod-status`'s processes.js emits `E('span', {'style': 'word-break: break-word'}, …)`.
|
||||
* `word-break: break-word` is the deprecated alias defined as "`word-break: normal` plus
|
||||
* `overflow-wrap: anywhere`" (css-text-3 §5.1), so it lowers the cell's min-content to a single
|
||||
* character from an INLINE declaration no cascade layer can outrank: measured at 720px, the
|
||||
* Command column sat at 126px against a widest token of 353px and forcing the column's
|
||||
* `overflow-wrap` any of three ways changed nothing, the floor being erased inside the span.
|
||||
* Neutralised, the same table asks for 963px in 688px of room, which is the truth.
|
||||
*
|
||||
* This re-times the view's intent rather than overruling it: the column may still shred, but
|
||||
* when the fitter marks it (`.fs-td-break`, one rung before carding) rather than always. Buttons
|
||||
* and inputs are excluded — base gives them `break-all` deliberately, and a control label is not
|
||||
* a value being read.
|
||||
*
|
||||
* The `!important` is the sanctioned kind: it fights an inline `style=`, the one adversary a
|
||||
* layer cannot reach.
|
||||
*
|
||||
* A CARD IS NOT EXEMPT, though `:not(.fs-stacked)` stood here. The inline alias damages the
|
||||
* MEASUREMENT, and a carded table is not measured — but it is not the only adversary. LuCI's
|
||||
* luci-mod-dashboard ships `.Dashboard … .devices-info .tr .td:first-child { word-break:
|
||||
* break-all }` in view/dashboard/css/custom.css with no layer around it, and `break-all` splits a
|
||||
* token that would have fitted: measured at 390px, `t3e-syp.home.iot` over six lines and the
|
||||
* caption `HOSTNAME` cut as `HOSTNA`/`ME`. A card's own breaking is unchanged — `.td` below keeps
|
||||
* `overflow-wrap: anywhere`, so a token too long for the line still breaks. */
|
||||
/* processes.js's inline style= cannot be outranked by a layer — !important is the sanctioned
|
||||
* fight against it. A CARD IS NOT EXEMPT: an unlayered app rule breaks a token that would fit. */
|
||||
.table.fs-dt .td,
|
||||
.table.fs-dt .td :not(button):not(.btn):not(input):not(select):not(textarea) {
|
||||
word-break: normal !important;
|
||||
@@ -188,27 +97,8 @@
|
||||
|
||||
/* Whether a table cards, breaks one column or scrolls is MEASURED (fs-select.js), at any width. */
|
||||
|
||||
/* ---- the header row stays while the rows go past ----
|
||||
*
|
||||
* Processes renders 114 rows and Connections a few hundred: past the first screenful the column
|
||||
* headings are gone and every value is an unlabelled string of digits. The card view solves that
|
||||
* by printing the heading above each value; a table that FITS had no equivalent.
|
||||
*
|
||||
* On the CELLS, not the row: `position: sticky` needs a box with a background, and in the
|
||||
* separated border model a row's background does not travel with a sticky cell — the heading
|
||||
* would float over the rows with the values showing through. So each `.th` sticks and carries
|
||||
* the header's own fill.
|
||||
*
|
||||
* What it sticks TO depends on which box scrolls. In the vertical sidebar `.fs-main` is its own
|
||||
* scroller and nothing overlaps its top edge, so the offset is 0. In the bar layouts the
|
||||
* DOCUMENT scrolls under a sticky bar whose height is not `--fs-bar-h` — it grows when the brand
|
||||
* wraps, when the menu takes a row or when the cluster does, all decided by measurement — so
|
||||
* fs-chrome.js publishes `--fs-bar-live` and this reads it, with the designed height as the
|
||||
* fallback for the frame before the chrome has been fitted.
|
||||
*
|
||||
* And a dialog is neither, which is why every one of these selectors names its root. The roots
|
||||
* ride in `:where()` so they cost nothing: spelled out they weigh (1,9,0) against the (1,7,0)
|
||||
* ceiling tools/css-metrics.mjs enforces, while the cell rule that has to win is (0,6,0). */
|
||||
/* sticks each .th, not the row — a row's fill does not travel with a sticky cell here. 0 in
|
||||
* the sidebar (.fs-main scrolls itself), --fs-bar-live where the document does. */
|
||||
:where(:root[data-layout="sidebar"]:not([data-narrow]) #view, #modal_overlay)
|
||||
.table.fs-dt:not(.fs-stacked) .tr.table-titles .th {
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
@@ -220,53 +110,24 @@
|
||||
background: var(--fs-panel2);
|
||||
}
|
||||
|
||||
/* ---- a measurement must not throw the reader ----
|
||||
*
|
||||
* `fitTables()` decides a data table's tier by taking its marks OFF and asking the engine
|
||||
* (fs-fit rule 1), and the reading forces layout, so for that pass the document really is laid
|
||||
* out with a table several screens taller than the one on screen.
|
||||
*
|
||||
* Scroll anchoring turns that into a visible bug: the browser keeps the node under the reader
|
||||
* still by adjusting the offset, and re-picks that node whenever the offset changes for any
|
||||
* other reason — which, while the user scrolls, is every frame. The pick can land inside a table
|
||||
* in the un-stacked state the fitter is about to undo, and the next frame the engine faithfully
|
||||
* corrects the offset by the difference. Measured on the Overview at 390x844 with the poll
|
||||
* driving real re-renders: 18 movements nobody asked for in 1500 frames, the worst 1877px,
|
||||
* always a pair.
|
||||
*
|
||||
* So the tables the fitter re-stamps are declared ineligible as anchors — and only they, because
|
||||
* every other node staying eligible is what keeps the good half of anchoring working. NOT on the
|
||||
* scroll container, which is the answer that suggests itself and is worse: with
|
||||
* `html, body, #maincontent { overflow-anchor: none }` the same run still moved 12 times, up to
|
||||
* 788px, having thrown that compensation away as well. */
|
||||
/* fitTables() forces a layout with marks off, and anchoring can re-pick a node inside it
|
||||
* (18 jumps/1500 frames measured) — anchoring.md, "What the engine is told". */
|
||||
.table.fs-dt { overflow-anchor: none; }
|
||||
|
||||
.cbi-section .table:is([id], .fs-dt) .tr:not(.table-titles):not(.placeholder):hover .td { background: var(--fs-panel2); }
|
||||
.cbi-section .table:is([id], .fs-dt) .tr.table-titles .th { border-bottom-color: var(--fs-border); }
|
||||
/* Contour around the whole data table. A real <table> needs border-collapse:separate or the
|
||||
* radius is silently dropped. Scoped to #view too: Startup renders a .table.fs-dt in a bare
|
||||
* #view wrapper with no section, and drew frameless under a section-scoped selector. */
|
||||
/* contour (border-collapse:separate, or the radius is silently dropped); #view too —
|
||||
* Startup renders .table.fs-dt with no section */
|
||||
.cbi-section .table[id], #view .table[id],
|
||||
.cbi-section .table.fs-dt, .cbi-section table.fs-dt, table.fs-dt {
|
||||
border-collapse: separate; border-spacing: 0;
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius-lg);
|
||||
/* EXPLICIT, not an omission: the generic `.table` rule below still says `overflow: hidden`
|
||||
* — which clipped the last column of a wide table — and not repeating it here does not
|
||||
* stop it applying, which is what kept the bug alive. The corner cells (below) round the
|
||||
* frame instead, and long values wrap so the table shrinks to fit. */
|
||||
/* EXPLICIT: the generic .table rule below still says overflow:hidden, clipped otherwise */
|
||||
overflow: visible;
|
||||
}
|
||||
/* The frame is overflow:visible, so nothing clips the square corner cells to its radius and
|
||||
* they poked past it. Round the four OUTER cells — the frame's corners, not a per-row pill.
|
||||
* `#view .table[id]` is listed because the frame rule draws the border there too.
|
||||
*
|
||||
* `:is(.tr.table-titles, .thead)` because a header is any of the shapes fs-select.js adopts:
|
||||
* `.tr.table-titles` is the bare-th-row markup, `.thead` covers a real `<thead>` whether it
|
||||
* holds its `<th>`s directly (E()-built, no `<tr>`) or through one — adoptMarkup() never adds
|
||||
* `.tr` to that inner row, so a class-only selector has to reach the `.th` by descent, not by
|
||||
* child. Without `.thead` the header kept its square top corners under any real `<thead>`: the
|
||||
* DHCP and Wi-Fi dashboard tables measured a first/last `.th` radius of 0 against the frame's
|
||||
* 12px. */
|
||||
/* frame is overflow:visible, so nothing clips the corner cells — round the four OUTER ones.
|
||||
* `:is(.tr.table-titles, .thead)` covers both header shapes; without .thead a real <thead>
|
||||
* kept square top corners. */
|
||||
.cbi-section .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
#view .table[id] :is(.tr.table-titles, .thead) .th:first-child,
|
||||
table.fs-dt :is(.tr.table-titles, .thead) .th:first-child { border-top-left-radius: var(--fs-radius-lg); }
|
||||
@@ -279,73 +140,28 @@
|
||||
.cbi-section .table[id] .tr:last-child .td:last-child,
|
||||
#view .table[id] .tr:last-child .td:last-child,
|
||||
table.fs-dt .tr:last-child .td:last-child { border-bottom-right-radius: var(--fs-radius-lg); }
|
||||
/* Empty table: L.ui.Table adds a single-cell .tr.placeholder. Spanning it across the table is
|
||||
* base's job (base/40-tables.css); what is left here is the FRAMED table's own flavour — the
|
||||
* plate reads as prose, left-aligned like the rows it stands in for, and carries the frame's
|
||||
* bottom corners so the rounding does not cut across it. */
|
||||
/* empty table: L.ui.Table adds .tr.placeholder; spanning it is base's job, framed flavour left here */
|
||||
.cbi-section .table[id] .tr.placeholder .td,
|
||||
#view .table[id] .tr.placeholder .td,
|
||||
table.fs-dt .tr.placeholder .td {
|
||||
/* the framed flavour pads its cells `--fs-space-2-5` a side (above), and that shorthand is in
|
||||
* THIS layer, so it overrides base's placeholder padding and made the plate 4px taller than
|
||||
* the row reserved for it — measured on Wireless as 67px inside 63px, printing below the
|
||||
* frame's bottom border. Put base's number back for this one cell. */
|
||||
padding-block: var(--fs-space-2);
|
||||
padding-block: var(--fs-space-2); /* this layer's own cell padding made the plate 4px tall */
|
||||
text-align: start;
|
||||
color: var(--fs-dim); font-family: var(--fs-font-sans);
|
||||
border-radius: 0 0 var(--fs-radius-lg) var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
/* --- KEY/VALUE tables: System, Memory ---
|
||||
* "no id" is not enough: form.TableSection/GridSection also render `.table.cbi-section-table`
|
||||
* with no id, where a 40%/nowrap first column starves the data columns. So exclude it and .fs-dt
|
||||
* explicitly.
|
||||
*
|
||||
* Also exclude the realtime graph legend: `[style*="table-layout"]` is the one no-id `.table`
|
||||
* LuCI hands an inline `table-layout:fixed`, where a 40% first column would only starve the
|
||||
* other five. A DDNS-style multi-column status table is not excluded and keeps the key/value
|
||||
* first column. */
|
||||
/* --- KEY/VALUE: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
|
||||
* excluded along with .fs-dt and the legend's inline table-layout. */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child {
|
||||
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
|
||||
}
|
||||
.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. */
|
||||
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
|
||||
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
|
||||
@container fs-content (max-width: 560px) {
|
||||
/* Stacked, the row's own `.td, .th` rule above (line 97) still spends a FULL cell padding
|
||||
* on each half of the pair — 10px top + 10px bottom on the caption, the same on the value,
|
||||
* plus a hairline under EACH — 40px of padding-block and two dividers for one caption/value
|
||||
* pair, so the gap inside a pair (caption to its own value) equalled the gap between pairs
|
||||
* and the pair read as two rows, not one. Same fix as the meter row's
|
||||
* (pages/20-overview.css): move the rhythm off the CELLS onto the ROW. `--fs-space-1 0
|
||||
* --fs-space-1-5` — 4px + 6px, a quarter of the 40px it replaces — is the pair's own outer
|
||||
* padding, and the hairline moves with it so a stack outside the Overview page (which zeroes
|
||||
* it separately) draws ONE divider per pair instead of one under the caption as well. With
|
||||
* the cells at `padding: 0`, caption and value sit flush against each other — nothing between
|
||||
* them but line-height — and the record boundary is the one line the reader can see. */
|
||||
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr {
|
||||
display: block;
|
||||
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
|
||||
@@ -368,13 +184,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the realtime graph legend (status/realtime/*) ---
|
||||
* The one no-id `.table` LuCI renders with inline `table-layout:fixed;width:100%` and six cells
|
||||
* per row. `fixed` splits the row into six equal columns, so on a phone each is ~1/6 of the
|
||||
* content width and the labels overflow onto the value beside them. Stock luci-theme-bootstrap
|
||||
* folds the six cells into label/value PAIRS on a narrow screen; do the same. `display:flex` on
|
||||
* the row takes the cells out of the fixed table layout, so the inline `table-layout` no longer
|
||||
* bites. Keyed on that inline attribute so only this legend is touched. */
|
||||
/* --- the realtime graph legend: the one no-id .table with inline table-layout:fixed, six
|
||||
* equal columns overflowing on a phone — folded into label/value pairs. */
|
||||
@media (max-width: 767px) {
|
||||
.cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt)[style*="table-layout"] .tr {
|
||||
display: flex; flex-wrap: wrap;
|
||||
@@ -389,43 +200,17 @@
|
||||
|
||||
/* generic links/labels inside cards */
|
||||
.cbi-section .ifacebox, .cbi-section .zonebadge { border-radius: var(--fs-radius); }
|
||||
/* moved from base with the generic field box (same layer arithmetic as the cell rules above) */
|
||||
.zonebadge input { width: 6em; }
|
||||
/* The device/MAC badge inside the Network status box kept base's flat 4px, reading as square
|
||||
* against the card's --fs-radius corners (issue #5.4). Scoped to the status box: the small
|
||||
* firewall zone .ifacebadge chips want to stay tight. Follows the Rounding axis via the token. */
|
||||
.zonebadge input { width: 6em; } /* moved from base with the generic field box */
|
||||
/* MAC badge kept base's flat 4px, square against the card corners (issue #5.4) */
|
||||
.network-status-table .ifacebox-body .ifacebadge { border-radius: var(--fs-radius); }
|
||||
/* …and the head has to round WITH the box: it sits inside the box's 1px border and carries the
|
||||
* zone colour as an inline background, so its own corners are what you see, and a 3px head
|
||||
* inside a 10px box cuts straight through the rounding with the zone colour spilling past the
|
||||
* corner (issue #7). Minus the 1px border it is inset by; max() because the Rounding axis goes
|
||||
* down to 0, where a bare subtraction is negative — an invalid declaration, silently dropped. */
|
||||
/* head rounds WITH the box (issue #7); max() since Rounding goes to 0, an invalid subtraction */
|
||||
.cbi-section .ifacebox > .ifacebox-head {
|
||||
--fs-head-radius: max(0px, calc(var(--fs-radius) - 1px));
|
||||
border-radius: var(--fs-head-radius) var(--fs-head-radius) 0 0;
|
||||
}
|
||||
|
||||
/* Inactive tab panes. Base's [data-tab-title] rule sets height:0/overflow:hidden, collapsing only
|
||||
* the CONTENT box — padding, border and margin still draw empty bars, and a pane left in flow
|
||||
* inflates scrollHeight (Interfaces scrolled ~585px past the footer, Network -> DNS another
|
||||
* ~750px). Zeroing the box does both jobs, and hiding the pane with `display: none` on top of
|
||||
* that buys nothing (Interfaces scrollHeight 1039 either way).
|
||||
*
|
||||
* It is `visibility`, not `display: none`, because a display:none pane has no width and a view
|
||||
* is entitled to measure one: stock channel_analysis.js builds its channel axis from
|
||||
* `graph.offsetWidth` once, in a rAF, so a pane that starts inactive reads 0 and the 5GHz graph
|
||||
* stays a collapsed blob for the life of the page. `visibility: hidden` keeps the pane laid out
|
||||
* at full width while still taking it out of the a11y tree and the tab order, and
|
||||
* height:0 + overflow:hidden keep it out of the scroll.
|
||||
*
|
||||
* `position: relative` is what makes that overflow hold: an absolutely positioned descendant is
|
||||
* clipped only by an overflow ancestor in its own containing-block chain, and a collapsed pane of
|
||||
* ordinary markup has none — the theme supplies them itself, the toggle switch parking its real
|
||||
* checkbox at `position: absolute` (theme/60-inputs.css). Measured: DHCP and DNS scrolled 1841px
|
||||
* against 1110px of content on 24.10 and 1925 vs 1439 on 25.12, i.e. ~500-700px of empty page
|
||||
* under the footer (issue #10).
|
||||
*
|
||||
* Hence any [data-tab-title], not just .cbi-section ones. */
|
||||
/* inactive tab panes: base's height:0/overflow:hidden still drew empty padding/border bars
|
||||
* (issue #10). visibility not display:none — stock JS reads a graph's width once in a rAF. */
|
||||
[data-tab-title]:not([data-tab-active="true"]) {
|
||||
visibility: hidden;
|
||||
position: relative;
|
||||
@@ -435,67 +220,43 @@
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
/* 6%, not 12%. The list inside is bold --fs-danger on a tint OF --fs-danger, so the tint
|
||||
* eats its own contrast: 12% measured 4.4:1 (axe-core). At 6% every palette clears 4.5
|
||||
* (4.87 / 4.95 / 5.28 / 6.09) and the box still reads red. */
|
||||
/* 6%, not 12%: bold --fs-danger on a tint OF --fs-danger eats its own contrast (12% measured
|
||||
* 4.4:1); at 6% every palette clears 4.5 (4.87/4.95/5.28/6.09) */
|
||||
.cbi-section-error { border-color: var(--fs-danger); background: color-mix(in srgb, var(--fs-danger) 6%, var(--fs-panel)); color: var(--fs-text); }
|
||||
.cbi-section-error ul li { color: var(--fs-danger); }
|
||||
|
||||
/* denser data tables (dhcp static leases, section-tables) */
|
||||
.cbi-section-table .th, .cbi-section-table .td,
|
||||
/* The `::before` is the GridSection name CELL on 24.10, where form.js renders none and base
|
||||
* generates the column from `data-title` (base/95-luci.css). It is not a `.th`/`.td`, so every
|
||||
* cell rule below has to name it too, or the column it draws lines up with nothing. Listing
|
||||
* `.tr::before` unqualified is safe: base names exactly the rows that get one, and a
|
||||
* pseudo-element whose content is `none` generates no box. */
|
||||
/* the GridSection name CELL on 24.10, a ::before from data-title where form.js has no real
|
||||
* cell — named here too or the column lines up with nothing */
|
||||
.table.cbi-section-table .th, .table.cbi-section-table .td,
|
||||
.table.cbi-section-table .tr::before { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; }
|
||||
/* The zero padding-left above is right only INSIDE a card, where the .cbi-section supplies that
|
||||
* gutter itself. Outside one it is wrong — Status -> Firewall renders `.table.cbi-section-table`
|
||||
* in a bare <div>, so the table draws its own frame and the header text ends up glued to that
|
||||
* frame's rounded corner (issue #7). A table carrying its own frame pads its own cells. */
|
||||
/* zero padding-left is right only INSIDE a card; a self-framed table glued its header text
|
||||
* to its own corner without this (issue #7) */
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .th,
|
||||
.table.cbi-section-table:not(.cbi-section *):not(.cbi-section-node *) .td { padding: var(--fs-space-2-5) var(--fs-space-3); }
|
||||
.cbi-section-table .tr.cbi-section-table-titles .th,
|
||||
.cbi-section-table .tr.table-titles .th { font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); line-height: 1.25; }
|
||||
|
||||
/* Standalone data tables (Startup, Processes, Routes) -> rounded card. Inside a CBI section
|
||||
* the section already IS the card, so the frame is stripped there (above): no double border. */
|
||||
/* standalone data tables -> rounded card; inside a CBI section the section already IS the
|
||||
* card, so the frame is stripped there (above) */
|
||||
.table {
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--fs-panel);
|
||||
}
|
||||
/* Separators go on the CELLS, not on the rows, and that is not a preference: the frame above needs
|
||||
* `border-collapse: separate` (a collapsed table silently drops the radius), and in the separated
|
||||
* model a row's border is not painted at all — on the .tr these rules paint nothing, and what
|
||||
* draws the lines is base's per-cell `border-top`, which also runs along the table's top edge
|
||||
* across the frame's rounded corners (issue #7). base no longer sets it, and the lines are
|
||||
* declared here, where they paint. Remove one and the other is not a fallback.
|
||||
*
|
||||
* `:not(:last-child)` lives IN the body-row selector: a trailing bottom border draws a straight
|
||||
* line the full width and pokes past the frame's rounded bottom corners. */
|
||||
/* separators on the CELLS: the frame needs border-collapse:separate, a row's own border
|
||||
* paints nothing. :not(:last-child) keeps a trailing one off the rounded corner. */
|
||||
.table .tr.table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles > .th,
|
||||
.table .tr.cbi-section-table-titles::before { border-bottom: var(--fs-hairline); }
|
||||
.table .tr:not(.table-titles):not(.cbi-section-table-titles):not(:last-child) > .td,
|
||||
.table .tr.cbi-section-table-row:not(:last-child)::before { border-bottom: var(--fs-hairline); }
|
||||
/* Because the separator above is a per-CELL border, an app that nudges one cell with
|
||||
* `position: relative; top: …` (luci-mod-dashboard's `.devices-info .td:nth-child(3)`) drags
|
||||
* that cell's border-bottom out of line with its row neighbours — a step, not a slanted line,
|
||||
* since each cell paints its own hairline. Bootstrap never shows this because its rows are
|
||||
* `border-collapse: collapse`, with no per-cell border to displace. The app's rule lands in the
|
||||
* same fenced `theme` layer (fs-sheets.js), so an ID root outranks its 7 classes on specificity
|
||||
* alone and needs no `!important`; `top` AND `bottom`, not just `top: 0`, so this also cancels
|
||||
* a `bottom` offset and leaves `position: relative` itself untouched for any
|
||||
* absolutely-positioned descendant. `top`/`bottom`, not the `inset-block` shorthand: the sheet
|
||||
* has never used a logical inset property before and `css-floor` has no support decision for
|
||||
* one, where the physical pair is already on the floor. Measured: offset .td top -3px -> 0, its
|
||||
* border-bottom back level with its neighbours'. */
|
||||
/* per-CELL border, so a foreign `position: relative; top:` (dashboard) drags one cell's
|
||||
* border out of line — `top` AND `bottom` cancels either without touching `relative` itself */
|
||||
:is(#view, #modal_overlay) .fs-dt .td { top: auto; bottom: auto; }
|
||||
/* descendant combinator (not >) so a real <table>'s browser-inserted <tbody> is covered
|
||||
* too — one rule themes every data table, div- or table-based. */
|
||||
/* descendant combinator so a real <table>'s inserted <tbody> is covered too */
|
||||
.table .tr.table-titles, .table .tr.cbi-section-table-titles {
|
||||
background: transparent; /* drop the base panel2 header bar — match the dashboard's faint-label + underline look */
|
||||
}
|
||||
@@ -505,12 +266,7 @@
|
||||
letter-spacing: var(--fs-eyebrow-tracking);
|
||||
background: transparent;
|
||||
}
|
||||
/* The row's NAME, which must read the same on both releases: 25.12 renders a real
|
||||
* `.td.cbi-section-table-titles`, 24.10 base's `::before`. Both are a LABEL, not a value, so
|
||||
* both take the UI face — the surrounding value cells are mono, and base makes this one bold, so
|
||||
* with no 700 mono face shipped every rule name is drawn in synthetic bold mono, smearing the
|
||||
* grid on the one string in the row that is a name. Specificity (0,3,0), not source order, is
|
||||
* what beats the (0,1,0) mono rule. */
|
||||
/* row NAME reads the same on both releases (real cell / ::before) — a LABEL, sans face */
|
||||
.table.cbi-section-table .td.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-row::before {
|
||||
font-family: var(--fs-font-sans); font-weight: var(--fs-weight-bold); color: var(--fs-text);
|
||||
@@ -518,74 +274,25 @@
|
||||
.table .tr:not(.table-titles):not(.cbi-section-table-titles) {
|
||||
transition: background var(--fs-dur);
|
||||
}
|
||||
/* Row hover ONLY on real data/config tables. A plain layout `.table` — Diagnostics is one
|
||||
* <tr> of input+button spanning the whole card, with no header row, so fs-select never tags
|
||||
* it .fs-dt — lit the ENTIRE block on hover (issue #5.3), and a data table's placeholder row
|
||||
* ("no active leases") lit up too (issue #5.2, plus :not(.placeholder) above). Data tables
|
||||
* hover per-cell above; config tables (.cbi-section-table, never .fs-dt) hover data rows here. */
|
||||
/* :not(.placeholder) — a config table's "This section contains no values yet" row carries
|
||||
* .cbi-section-table-row too, so it lit up under the pointer exactly as the data tables' did
|
||||
* before the rule above excluded theirs (issue #15: reported on UPnP, but every empty
|
||||
* GridSection had it — firewall's NAT rules, IP sets, static routes). A hover cue promises a
|
||||
* row you can act on; there is no row. */
|
||||
/* row hover ONLY on real data/config tables (an untagged plain .table lit its whole block,
|
||||
* issue #5.3; a placeholder row lit up too, issue #5.2/#15) */
|
||||
.table.cbi-section-table .tr.cbi-section-table-row:not(.placeholder):hover { background: var(--fs-panel2); }
|
||||
/* keep section/modal tables flush (no forced hover-card look) */
|
||||
.cbi-section .table > .tr, .cbi-modal .table > .tr { border-radius: 0; }
|
||||
|
||||
/* .fs-dt is set by fs-select.js on a #view .table that has a header row and is NOT a
|
||||
* .cbi-section-table — replacing a live `:has(.tr.table-titles):not(.cbi-section-table)`
|
||||
* the style engine re-evaluated on every mutation of these polled tables (processes,
|
||||
* routes, leases). */
|
||||
/* .fs-dt: fs-select.js on a header-row #view .table that is NOT .cbi-section-table —
|
||||
* replacing a live :has() selector re-evaluated on every mutation. */
|
||||
|
||||
/* ---- the card stack: one copy, every data table, no @container threshold ----
|
||||
*
|
||||
* Three @container thresholds meant two copies of these declarations, CSS being unable to share
|
||||
* a block across thresholds — 55 duplicated declarations, free to drift. All three really asked
|
||||
* "does it OVERFLOW?", which the browser can compute: fs-select.js measures it and sets
|
||||
* .fs-stacked, so every table finds its own width, a third-party app's included. Only the
|
||||
* judgement a measurement cannot make survives, in fs-select.js: "too cramped to be a table".
|
||||
*
|
||||
* A config table (.cbi-section-table) is deliberately not here and keeps its @container
|
||||
* (theme/65-dropdown.css): its rows hold widgets that bake in a width from the layout they were
|
||||
* rendered in, so un-collapsing one to read it CHANGES what is read — the firewall zone table
|
||||
* then claimed 1747px where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* Shape = stock LuCI's phone layout: label ABOVE its value, cell = half the row. Label:value on
|
||||
* one line with the value right-flushed costs a leases row 7 full-width lines and gives the
|
||||
* value ~40% of the width; half-width pairs fold it into 4. Values stay left-aligned.
|
||||
*
|
||||
* ONE CONTRACT — do not split it again. The stack is MEASURED and fires at ANY width, while
|
||||
* `display: block` and the `.hide-xs`/`.hide-sm` drops once lived only inside
|
||||
* `@media (max-width: 767px)` (theme/90-responsive.css). The window between the two is real: the
|
||||
* sidebar layout's content column is (viewport - 224 - 56), so from ~768 to 860px it is already
|
||||
* below CRAMPED while that query is off. Measured at 790/820/850px, status_leases stacked with
|
||||
* `display: table` still in force and wifi_assoclist_table rendered all five of its .hide-xs
|
||||
* cells; at 760px both were correct. So they key off the CLASS. */
|
||||
/* ---- a table nobody has answered for yet takes no room ----
|
||||
*
|
||||
* A poll tick REPLACES these tables, and a fresh one carries no marks: for the moment between
|
||||
* landing and being stamped it is laid out as a full-width table, several screens taller than
|
||||
* the card stack it is about to be. The engine re-anchors on that intermediate and throws the
|
||||
* reader 612px out and back, twice per tick. fs-select.js answers in a MutationObserver
|
||||
* microtask, i.e. before paint — but only if nothing forces layout in between, and an app that
|
||||
* reads a width right after rendering does exactly that.
|
||||
*
|
||||
* So the table waits outside the layout until it has an answer. `display: none`, not
|
||||
* `visibility: hidden`, because a hidden box still occupies its height.
|
||||
*
|
||||
* Guarded on the fitters being alive: `:root[data-fs-fit]` is written by fs-fit.js and by
|
||||
* nothing else, so a document where that file never ran shows every table exactly as before this
|
||||
* rule existed. A stylesheet that can hide content on its own is one that can hide it
|
||||
* forever. */
|
||||
/* One card-stack copy, no @container: fs-select.js already measures OVERFLOW and sets
|
||||
* .fs-stacked. The config table keeps its own @container — its widgets bake in a width. */
|
||||
/* a poll REPLACES a data table with a fresh, unmarked copy that briefly lays out full-width,
|
||||
* throwing the reader 612px out and back — waits OUTSIDE the layout until fs-select.js
|
||||
* answers, guarded on :root[data-fs-fit]. */
|
||||
:root[data-fs-fit] #view .table.fs-dt:not(.fs-fitted),
|
||||
:root[data-fs-fit] #modal_overlay .table.fs-dt:not(.fs-fitted) { display: none; }
|
||||
|
||||
.table.fs-stacked { display: block; width: 100%; }
|
||||
/* `.fs-drop-xs` is the same drop one rung earlier: fs-select.js writes it on a table that has
|
||||
* overflowed, before it decides to card, because the view author has already said which columns
|
||||
* are expendable and a table with two fewer columns often fits. Same declaration, so the two
|
||||
* selectors share the block, and the class is what makes the drop a MEASURED decision rather
|
||||
* than a viewport guess (theme/90-responsive.css keeps that copy for tables that never card). */
|
||||
/* .fs-drop-xs is the same drop one rung earlier, on an overflowed table before carding */
|
||||
.table.fs-stacked .td.hide-sm, .table.fs-stacked .th.hide-sm,
|
||||
.table.fs-stacked .td.hide-xs, .table.fs-stacked .th.hide-xs,
|
||||
.table.fs-drop-xs .td.hide-sm, .table.fs-drop-xs .th.hide-sm,
|
||||
@@ -595,19 +302,10 @@
|
||||
.table.fs-stacked .thead,
|
||||
.table.fs-stacked .tr.cbi-section-table-titles,
|
||||
.table.fs-stacked .tr.cbi-section-table-descr { display: none; }
|
||||
/* align-items: stretch (the default), NOT flex-start: the cells carry a per-cell
|
||||
* border-bottom, so when one cell of a pair wraps (a 2-line hostname beside a 1-line MAC)
|
||||
* a flex-start neighbour stays short and its separator paints 52px higher — a broken line
|
||||
* mid-card (measured). Stretched, both cells take the flex line's height and the
|
||||
* separators meet; content still sits at the top (the cells are blocks). */
|
||||
/* A `<tfoot>` with no `.tr` inside it IS the row — see the footer block below for which view
|
||||
* writes which shape.
|
||||
*
|
||||
* TWO rules, not one list: a selector list is not forgiving, so a browser without `:has()`
|
||||
* (Firefox before 121) throws away the WHOLE list, and `.table.fs-stacked .tr` — every card on
|
||||
* every stacked table — loses `display: flex` with it. Split, the `:has()` half degrades alone.
|
||||
* The duplicated body is under `css-dup`'s three-declaration floor, so it will not be flagged;
|
||||
* the two must change together. */
|
||||
/* stretch (default), not flex-start: a 2-line cell's border-bottom broke out of line with a
|
||||
* 1-line neighbour's (52px) */
|
||||
/* a <tfoot> with no .tr IS the row. TWO rules: a browser without :has() drops a whole list,
|
||||
* losing display:flex on every card. Under css-dup's floor — keep both in step by hand. */
|
||||
.table.fs-stacked .tr { display: flex; flex-wrap: wrap; }
|
||||
.table.fs-stacked tfoot:not(:has(> .tr)),
|
||||
.table.fs-stacked .tfoot:not(:has(> .tr)) { display: flex; flex-wrap: wrap; }
|
||||
@@ -616,12 +314,8 @@
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* The column label, lifted above its value (LuCI writes it per cell as data-title).
|
||||
*
|
||||
* Pinned: the config table (theme/65-dropdown.css) needs these exact declarations under its
|
||||
* @container guard, and CSS cannot share a block between a class and a container query — the two
|
||||
* guards do not OR. tools/mirror.mjs keeps the copies byte-identical; edit one and the build
|
||||
* fails until you edit the other, a duplicate detector going quiet once two copies differ. */
|
||||
/* the column label, lifted above its value. PINNED: the config table needs this under its
|
||||
* own @container guard, which cannot share a block with a class. */
|
||||
.table.fs-stacked .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
@@ -629,104 +323,55 @@
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* A footer cell is the fourth member here, and it has to carry `[data-title]` in its own selector
|
||||
* to win: `.table.fs-stacked tfoot .td::before` is (0,3,1) against the caption rule's (0,4,0) and
|
||||
* loses wherever it is written. Measured on the wifi footer with the titles stamped: `content`
|
||||
* computed to "SSID" over `Total:`. With the attribute it is (0,4,1) and (0,5,0). */
|
||||
/* a footer cell needs [data-title] in its OWN selector to win the caption rule's (0,4,0) */
|
||||
.table.fs-stacked .td.cbi-section-actions::before,
|
||||
.table.fs-stacked .td:not([data-title])::before,
|
||||
.table.fs-stacked .td[data-title=""]::before,
|
||||
.table.fs-stacked tfoot .td[data-title]::before,
|
||||
.table.fs-stacked .tfoot .td[data-title]::before { content: none; }
|
||||
/* The row's buttons are not a label/value pair — full width below the pairs.
|
||||
* PINNED with the config table's copy — see above. */
|
||||
/* row buttons are not a label/value pair — full width below the pairs. PINNED, see above. */
|
||||
.table.fs-stacked .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
flex-wrap: wrap; gap: var(--fs-space-2); padding-top: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* LuCI groups a row's buttons in ONE inner <div> (startup.js wraps all five), so the td's own
|
||||
* flex-wrap above never fires — the div is a single flex item, and .fs-main's overflow-x:clip
|
||||
* CUT the tail button (Startup at 390px, measured: the div needs 350px in a 324px cell and
|
||||
* "Stop" vanished). Same trap the config table hit on the interfaces row; PINNED to its copy
|
||||
* in theme/65-dropdown.css. */
|
||||
/* LuCI groups buttons in ONE inner <div>, so this flex-wrap never fires and .fs-main clipped
|
||||
* the tail button. PINNED to the config table's copy. */
|
||||
.table.fs-stacked .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* Nothing in a half-width cell may force the row wider than the viewport: .fs-main is
|
||||
* overflow-x:clip, so an over-wide child is CUT, not scrolled to. */
|
||||
/* nothing in a half-width cell may force the row wider than the viewport — .fs-main clips,
|
||||
* it does not scroll to an over-wide child */
|
||||
.table.fs-stacked .td > *,
|
||||
.table.fs-stacked .td .ifacebadge { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* ---- the footer, in both shapes upstream writes it ----
|
||||
*
|
||||
* A `<tfoot>` is still a table-footer-group inside a `display: block` table, so in a card it drew
|
||||
* as a box beside the cards: measured at 390px on the station list, 186px wide against 354.
|
||||
* `20_lan.js` builds `tfoot > tr > td` and needs only the footer out of the way, the `.tr` above
|
||||
* already being a flex row; `30_wifi.js` appends the `<td>`s to the `<tfoot>` through E(), where
|
||||
* no parser runs and no row is implied, so there the footer has to become the row — that arm is
|
||||
* up with the `.tr` rule. `:has(> .tr)` tells them apart.
|
||||
*
|
||||
* An empty footer cell goes entirely: a card is a list of facts and an empty cell is not one. The
|
||||
* captions are suppressed up in the `content: none` group, for the specificity reason stated
|
||||
* there. */
|
||||
/* a <tfoot> is still a table-footer-group in a display:block table, drawing beside the cards
|
||||
* instead of under them; `:has(> .tr)` tells the two shapes upstream writes it in apart. */
|
||||
.table.fs-stacked tfoot:has(> .tr),
|
||||
.table.fs-stacked .tfoot:has(> .tr) { display: block; }
|
||||
.table.fs-stacked tfoot .td:empty,
|
||||
.table.fs-stacked .tfoot .td:empty { display: none; }
|
||||
|
||||
/* --- the DATA table's flavour (L.ui.Table, tagged .fs-dt) ---
|
||||
* Clip rows to the frame's corners: zebra paints its background on the .tr and the frame is
|
||||
* overflow:visible, so a striped row's square background pokes past the corners. Safe once
|
||||
* stacked — a flex-wrap row fits the width, so there is no horizontal column left to clip, which
|
||||
* is the only thing overflow:visible protected. `#view .table.fs-dt.fs-stacked` (1,3,0) outranks
|
||||
* the frame's `#view .table[id]` (1,2,0) on specificity, not on order.
|
||||
*
|
||||
* `#modal_overlay` as well, because the FRAME already reaches a dialog and this clip did not: a
|
||||
* data table inside a dialog's `.cbi-section` takes the frame from `.cbi-section .table[id]`
|
||||
* above and has the same corners with the same zebra painted square across them. */
|
||||
/* --- DATA table flavour (L.ui.Table, tagged .fs-dt) --- clip to the frame's corners: zebra
|
||||
* paints on the .tr while the frame is overflow:visible, poking past the radius. */
|
||||
#view .table.fs-dt.fs-stacked,
|
||||
#modal_overlay .table.fs-dt.fs-stacked { overflow: hidden; }
|
||||
/* `:not(.placeholder)` — the plate is out of flow (base/40-tables.css) and its row exists only to
|
||||
* reserve the height it needs. Padding is subtracted from that reservation (border-box), so the
|
||||
* card's row inset left 41px of the 63px the plate is: measured on Wireless as 10px of text
|
||||
* hanging below the frame once the table stacked. A row with no rows in it has nothing to inset. */
|
||||
/* :not(.placeholder) — the empty-state plate is out of flow; its row only reserves height,
|
||||
* and padding subtracted from that left text hanging below the frame (Wireless) */
|
||||
.table.fs-dt.fs-stacked .tr:not(.placeholder) { padding: var(--fs-space-2-5) var(--fs-space-4) var(--fs-space-3); }
|
||||
/* ---- the pair width, and the adversary specificity cannot reach ----
|
||||
*
|
||||
* Half the row is what makes the card a card. It was `.fs-stacked .td` at (0,2,0), deliberately
|
||||
* low so the unguarded `.table .td.col-N` below, at (0,3,0), could beat it on specificity rather
|
||||
* than source order — which holds against every rule in a LAYER and none outside one.
|
||||
* luci-mod-dashboard ships `@media (max-width: 640px) { .Dashboard … .tr .td { flex: 2 2 10% } }`
|
||||
* unlayered, and that beats every layered rule at any specificity: measured at 390px, four cells
|
||||
* of a station row at 81px on one line, `flex-basis` computing to 10% — the pairs never formed.
|
||||
*
|
||||
* Hence the flag, the sanctioned kind. Spent narrowly, and by SELECTOR because a flag would
|
||||
* outrank these two families as well: `.cbi-section-actions` is a full-width line of buttons,
|
||||
* `[class*="col-"]` is the view author's own weight. The third `:not()` is not one of those — it
|
||||
* is how this rule loses to the meter below, both being flagged: without it (0,5,0) beats (0,4,0)
|
||||
* and the meter silently keeps half a line. */
|
||||
/* half the row makes the card — low specificity (0,2,0) so col-N below can beat it (holds
|
||||
* against a LAYERED rule only). Third :not() loses on purpose to the meter below. */
|
||||
.table.fs-stacked .td:not(.cbi-section-actions):not([class*="col-"]):not(:has(.cbi-progressbar)) {
|
||||
flex: 1 1 50% !important;
|
||||
}
|
||||
/* A meter takes the whole line: `.cbi-progressbar::after` prints the bar's title above its right
|
||||
* edge (25-progressbar.css), which in a card is where the cell's own caption sits — measured at
|
||||
* 390px, `SIGNAL STRENGTH` and `RSSI: 65% (-57dBm)` drawn over each other. Beside the bar instead
|
||||
* would be paid out of the bar, too short at half a row to read as one. */
|
||||
/* a meter takes the whole line — its own title prints over the cell's caption otherwise */
|
||||
.table.fs-stacked .td:has(.cbi-progressbar) { flex: 1 1 100% !important; }
|
||||
|
||||
/* ---- column weights: one unguarded block, unguarded on purpose ----
|
||||
* A weight (1-10) is a share of a CARDED row — the JS says which cell is a narrow badge and
|
||||
* which the wide status text; without it every cell falls back to the flat 50% above.
|
||||
*
|
||||
* Do not copy them under a guard. `flex` applies only to a FLEX ITEM, and a `.td` becomes one
|
||||
* exactly when its `.tr` turns into a flex container — by either mechanism (`.fs-stacked`,
|
||||
* measured at any width, or the config table's @container, which can card on a 1200px desktop).
|
||||
* Anywhere else they are inert, so one copy serves all three tiers. Basis N*30px, as in stock,
|
||||
* so the weights compose the same way. */
|
||||
/* column weights (1-10): a share of a CARDED row, unguarded — `flex` only applies once a
|
||||
* `.td` becomes a flex item, so one copy serves every tier and is inert elsewhere. */
|
||||
.table .td.col-1, .table .th.col-1 { flex: 1 1 30px; }
|
||||
.table .td.col-2, .table .th.col-2 { flex: 2 2 60px; }
|
||||
.table .td.col-3, .table .th.col-3 { flex: 3 3 90px; }
|
||||
@@ -741,81 +386,30 @@
|
||||
/* general table cell padding — compact but readable (Startup/Processes/etc) */
|
||||
.table .td { padding-top: var(--fs-space-2); padding-bottom: var(--fs-space-2); }
|
||||
|
||||
/* ---- the last resort: a foreign <table> the card stack cannot claim ----
|
||||
*
|
||||
* fs-select.js tags any `#view table` that HAS a header row as `.table.fs-dt`, so it is measured
|
||||
* and cards like every other data table. What is left here is the table with no header at all: a
|
||||
* log, a statistics matrix, a layout table. Those must not be carded even if they overflow — a
|
||||
* card prints `data-title` above each value, and a matrix has no titles to print, so stacking one
|
||||
* produces a column of numbers with nothing saying what they are. Comparison is the point of
|
||||
* that shape; it scrolls instead.
|
||||
*
|
||||
* `display: block` plus `overflow-x: auto` on the table ITSELF, not on a wrapper: the theme
|
||||
* cannot add markup around someone else's element, and the block box is what gives it a scroll
|
||||
* container. The rows still lay out as a table, a `display: block` <table> wrapping its children
|
||||
* in an anonymous table box.
|
||||
*
|
||||
* Unguarded by a media query, because the viewport was never the fact that mattered: a narrow
|
||||
* panel or a split window overflows at any width, and a phone report is only where it was
|
||||
* noticed first.
|
||||
*
|
||||
* A dialog is the worst place to leave it out, hence the `#modal_overlay` half of the selector:
|
||||
* the overlay is the scroll container (base/60-modal.css) and the dialog is centred in it with
|
||||
* `margin: auto`, so an over-wide child scrolls the dialog's own edge off the screen instead of
|
||||
* scrolling itself. Measured at 390px with a four-column `nowrap` log table: in #view it scrolls
|
||||
* inside itself (358px shown of 636px, page scroll 0); in a dialog it rendered 636px wide in
|
||||
* 317px of room and gave the overlay 282px of horizontal scroll. */
|
||||
/* `.fs-xscroll` is the same outcome reached the other way: fs-select.js writes it on a `.table`
|
||||
* with no header row that has been measured to overflow — the shape a bare `<table>` selector
|
||||
* cannot reach, a third-party app being free to render its log as `<div class="table">`. A class
|
||||
* rather than a selector, and measured rather than declared, for a spec-level reason:
|
||||
* `overflow-x: auto` forces `overflow-y` to compute to `auto` too (css-overflow-3 §3.1), so a
|
||||
* table given this becomes a clip for every popup inside it, and ui.js then walks up from an open
|
||||
* dropdown, finds the TABLE as the scroll parent and sizes the list against it. The fitter
|
||||
* refuses the class to any table holding a widget, so this rule needs no `:has()` ladder. */
|
||||
/* last resort: no header row, never carded (no data-title to print). .fs-xscroll: same,
|
||||
* MEASURED to overflow; refused to a table holding a widget (clips a popup inside). */
|
||||
#view table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#modal_overlay table:not(.table):not(.cbi-section-table):not(.fs-dt),
|
||||
#view .table.fs-xscroll, #modal_overlay .table.fs-xscroll {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
/* the scroll gesture stops at this table instead of dragging the page sideways */
|
||||
overscroll-behavior-x: contain;
|
||||
/* the scrollbar IS the affordance here. A gradient "there is more to the right" shadow is the
|
||||
* usual trick and does not work on these tables: it has to paint on the scroll container,
|
||||
* behind rows that carry their own zebra fill, so it shows through every second row only. */
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior-x: contain; /* the gesture stops here instead of dragging the page */
|
||||
scrollbar-width: thin; /* the scrollbar IS the affordance; a shadow trick shows through zebra rows */
|
||||
}
|
||||
|
||||
/* ---- a table of controls stacks: it is a form laid out as a table ----
|
||||
*
|
||||
* Written by fs-select.js on a header-less table that overflows and holds a control. Scrolling
|
||||
* one is not an option — the popup inside would be clipped by the scroll container it just
|
||||
* became — and doing nothing leaves it cut: at 320px of room the row needed 338 and the button
|
||||
* was past `.fs-main`'s clip with no way to reach it.
|
||||
*
|
||||
* `display: block` on all three levels, the one place this theme wants it: the rows are a form,
|
||||
* so there is nothing to compare across them and nothing to keep in columns. Each control takes
|
||||
* the width of its line and the table cannot exceed its parent by construction. Nothing here
|
||||
* creates a scroll container, so an open dropdown still escapes. */
|
||||
/* a header-less table holding a control stacks instead of scrolling — a scroll container
|
||||
* would clip the popup inside; nothing here creates one, so a dropdown still escapes. */
|
||||
.table.fs-rowstack,
|
||||
.table.fs-rowstack .tr,
|
||||
.table.fs-rowstack .td,
|
||||
.table.fs-rowstack .th { display: block; width: auto; }
|
||||
/* the cells were table-cells a moment ago and carried its side padding; as blocks they need the
|
||||
* vertical rhythm instead, or the controls sit flush against each other */
|
||||
/* the cells were table-cells a moment ago (side padding); as blocks they need the vertical
|
||||
* rhythm instead, or the controls sit flush against each other */
|
||||
.table.fs-rowstack .td { padding-inline: 0; padding-block: var(--fs-space-1); }
|
||||
|
||||
/* ---- what makes a scrolled table readable: the row keeps its name ----
|
||||
*
|
||||
* Scrolling a table sideways takes the identity column away first, leaving a screenful of values
|
||||
* belonging to nothing. Pinning the first cell of every row is the one thing a card view gives
|
||||
* for free and a scrolled table does not.
|
||||
*
|
||||
* The cells need their own background (a row's does not travel with a sticky cell in the
|
||||
* separated model, as for the header above) and a hairline on the inline end, so the pinned
|
||||
* column reads as an edge rather than as text sliding over text. `z-index: 2` keeps it over the
|
||||
* sticky header's `1` where the two cross, at the corner cell. */
|
||||
/* pins the row's NAME while scrolling — its own background (a row's fill does not travel
|
||||
* with a sticky cell) and a hairline so it reads as an edge, not text over text. */
|
||||
#view .table.fs-xscroll .tr > :first-child,
|
||||
#modal_overlay .table.fs-xscroll .tr > :first-child,
|
||||
#view .table.fs-xscroll tr > :first-child,
|
||||
@@ -824,8 +418,8 @@
|
||||
background: var(--fs-panel);
|
||||
border-inline-end: var(--fs-hairline);
|
||||
}
|
||||
/* a keyboard user reaches the scroll box by tabbing to it (fs-select.js gives it tabindex and a
|
||||
* name); it has to show that it has focus — outline, not box-shadow, so forced-colors keeps it */
|
||||
/* a keyboard user tabs to the scroll box (fs-select.js gives it tabindex+name) — outline, not
|
||||
* box-shadow, so forced-colors keeps it */
|
||||
#view .table.fs-xscroll:focus-visible,
|
||||
#modal_overlay .table.fs-xscroll:focus-visible {
|
||||
outline: 2px solid var(--fs-accent);
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
@layer theme {
|
||||
/* Alert panel: a left accent stripe on a panel card; the type variants recolour the frame.
|
||||
* `background: var(--fs-panel)` used to be repeated per variant to out-specify base's
|
||||
* gradients — a layer does that for free (theme beats base whatever the specificity), so
|
||||
* one rule does it. */
|
||||
/* alert panel: a left accent stripe on a panel card; variants recolour the frame */
|
||||
.alert-message {
|
||||
padding: var(--fs-space-3-5) var(--fs-space-4);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
border: var(--fs-hairline);
|
||||
/* logical: the stripe marks the READING start of the alert, so it swaps sides with the
|
||||
* document (LuCI ships ar/fa/he catalogues) — physical, it sat on the far edge in RTL. */
|
||||
/* logical: the stripe marks the READING start, swapping sides in RTL (LuCI ships ar/fa/he) */
|
||||
border-inline-start: 3px solid var(--fs-accent);
|
||||
/* The surface is OPAQUE and the status tint rides on top of it. A variant that replaces the
|
||||
* background with `var(--fs-*-fill)` paints an 18% wash with nothing behind it — invisible on
|
||||
* a flat canvas, and transparent to the wallpaper under Appearance -> Wallpaper, where the
|
||||
* tile reads straight through every status alert.
|
||||
*
|
||||
* So --fs-panel stays the background-COLOUR for every variant and the tint is a
|
||||
* background-IMAGE layer over it. The named `-fill` step is unchanged, so an untinted router
|
||||
* sees the same pixels, and the local `--fs-alert-tint` is what keeps this one rule instead
|
||||
* of six `background:` overrides that each have to re-state the panel. */
|
||||
/* --fs-panel stays the background-COLOUR; the tint is a background-IMAGE layer over it,
|
||||
* not a replacement — a `-fill` background alone is transparent to the wallpaper. */
|
||||
background-color: var(--fs-panel);
|
||||
background-image: linear-gradient(var(--fs-alert-tint, transparent), var(--fs-alert-tint, transparent));
|
||||
color: var(--fs-text);
|
||||
gap: var(--fs-space-3);
|
||||
}
|
||||
/* ---- status lives in the stripe and the surface; the ring is always neutral ----
|
||||
*
|
||||
* Split between `border-color` (all four sides, including the 3px stripe) and
|
||||
* `border-left-color` (the stripe alone), four of seven alerts wear a coloured ring and three a
|
||||
* grey one — a hierarchy nobody designed, with the difference between "not applied yet" and
|
||||
* "this is about to fail" hanging on one pixel of frame.
|
||||
*
|
||||
* The surface carries it instead, at the ladder's named `-fill` step, which
|
||||
* docs/design-system.md describes as the callout surface — exactly what an alert is. Not a fresh
|
||||
* `color-mix()`: an unnamed level is how four spellings of one tint appeared here before.
|
||||
*
|
||||
* `:not(.btn)` is load-bearing: `admin/system/flash` renders its confirm control as
|
||||
* `<label class="btn alert-message danger">`, a BUTTON wearing the alert classes, and without
|
||||
* the guard the tint out-ranks `.btn.danger` on source order: the "Force upgrade" control faded
|
||||
* from its danger fill (rgb(234,122,116)) to a pale wash. */
|
||||
/* status lives in stripe+surface, ring stays neutral. :not(.btn) is load-bearing: a confirm
|
||||
* control is a `<label class="btn alert-message danger">`, and without the guard the tint
|
||||
* out-ranked `.btn.danger`, fading its fill. */
|
||||
.alert-message.warning:not(.btn) {
|
||||
border-color: var(--fs-border); border-inline-start-color: var(--fs-warn);
|
||||
--fs-alert-tint: var(--fs-warn-fill);
|
||||
@@ -51,59 +28,32 @@
|
||||
border-inline-start-color: var(--fs-good);
|
||||
--fs-alert-tint: var(--fs-good-fill);
|
||||
}
|
||||
/* `.info` is a STATUS and tints like the others; a bare .alert-message stays a flat panel, because
|
||||
* a note is not a status.
|
||||
*
|
||||
* `.notice` is grouped HERE rather than with .success, where it is pixel-identical — same fill,
|
||||
* same rail, no other channel — so one status word carries two meanings while the theme's own
|
||||
* `.label.notice` is accent and `.cbi-tooltip.notice` neutral. What settles the direction is
|
||||
* what LuCI means by it: ui.js emits `notice spinning` for the "applying configuration changes"
|
||||
* banner, i.e. IN PROGRESS rather than done, and stock luci-theme-bootstrap keeps green for
|
||||
* success alone. Informational, so accent. */
|
||||
/* .notice groups HERE, not with .label.notice's accent: ui.js emits `notice spinning` for an
|
||||
* IN-PROGRESS banner, so it reads as informational rather than success. */
|
||||
.alert-message.info:not(.btn), .alert-message.notice:not(.btn) {
|
||||
border-inline-start-color: var(--fs-accent);
|
||||
--fs-alert-tint: var(--fs-accent-fill);
|
||||
}
|
||||
|
||||
/* ---- a spinning alert is a glyph and a message, side by side ----
|
||||
*
|
||||
* The spinner is a `::before` on the element carrying `.spinning` (base/95-luci.css), and it is
|
||||
* `inline-block` so that it sits in the flow of the button labels and `em.spinning` texts that
|
||||
* usually host it. This host is neither: `ui.changes.displayStatus()` builds the apply dialog as
|
||||
* a BLOCK box whose content is a BLOCK element, so the glyph opens a line box of its own and the
|
||||
* message goes underneath it.
|
||||
*
|
||||
* Grid rather than flex, because the caller decides how many children there are and flex would
|
||||
* lay a second one out beside the first: `displayStatus()` passes one `<p>` today but builds two
|
||||
* elements for its non-spinning states, and `addNotification()` takes `spinning` from anyone.
|
||||
* Two columns — glyph, then everything else. Measured over the three shapes a caller can
|
||||
* produce: the content started 24.6 / 20.6 / 19.5px BELOW the glyph's line and now starts on it.
|
||||
*
|
||||
* The notification variant is deliberately untouched: ui.js writes `style="display:flex"` on the
|
||||
* element itself, and an inline style beats a layered rule, so those keep their own flex row. */
|
||||
/* spinner ::before is inline-block for its usual inline hosts, but displayStatus() builds a
|
||||
* BLOCK host, opening its own line. GRID, not flex: the caller decides child count. */
|
||||
.alert-message.spinning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
/* centres the glyph against the FIRST row rather than pinning it to that row's top */
|
||||
align-items: center;
|
||||
/* `.alert-message`'s own `gap` above is the row gap between stacked children; the column gap
|
||||
* is the glyph's own spacing and is the smaller step, so it is stated rather than inherited
|
||||
* from that shorthand. */
|
||||
align-items: center; /* centres the glyph against the FIRST row, not pinned to its top */
|
||||
/* the column gap is the glyph's own spacing, stated rather than inherited from the row `gap` */
|
||||
column-gap: var(--fs-space-2);
|
||||
}
|
||||
/* the glyph is a grid item now, so its inline margin would double the column gap; and it is
|
||||
* pinned to row 1 so that it stays beside the FIRST child however many follow */
|
||||
/* grid item now, so its inline margin would double the column gap; row 1 keeps it beside the
|
||||
* FIRST child however many follow */
|
||||
.alert-message.spinning::before { grid-row: 1; margin-inline-end: 0; }
|
||||
.alert-message.spinning > * { grid-column: 2; }
|
||||
|
||||
/* h2 is what the theme's own notices emit (the document had no heading above h4 at all); h4 is
|
||||
* here because a third-party alert may use it, and coverage only grows. h5/h6 joined for the
|
||||
* same reason — base styled them and the theme did not, so an alert heading looked like an
|
||||
* alert heading down to h4 and then fell back to base's. */
|
||||
/* h2 is what the theme's own notices emit; h4 covers a third-party alert, h5/h6 joined so an
|
||||
* alert heading does not fall back to base's look past h4. */
|
||||
.alert-message :is(h2, h3, h4, h5, h6) { margin: 0 0 var(--fs-space-1); font-size: var(--fs-type-lg); font-weight: var(--fs-weight-bold); color: var(--fs-text); }
|
||||
/* the alert's own content reset — moved from base with the heading ramp (45-misc.css): its
|
||||
* `line-height: inherit` beat the ramp's tight leading in-layer, and left behind it would
|
||||
* lose to the ramp by layer (measured: alert headings dropped 21 -> 16.8px) */
|
||||
/* moved from base with the heading ramp: line-height:inherit beat the ramp's leading
|
||||
* in-layer, left behind it loses by layer (21 -> 16.8px) */
|
||||
.alert-message h4,
|
||||
.alert-message h5,
|
||||
.alert-message pre,
|
||||
@@ -115,78 +65,43 @@
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
/* --fs-text, not --fs-dim: the paragraph IS the message. The line below already gives every
|
||||
* ul/ol/li/pre inside an alert full-strength ink, so a two-sentence alert had its first
|
||||
* sentence muted and its bullet list not — and on the tinted surface above, dim is the value
|
||||
* that measured 4.06:1 when the Reset button had it (55-buttons.css). */
|
||||
/* --fs-text, not --fs-dim: the paragraph IS the message — dim measured 4.06:1 on the tint */
|
||||
.alert-message p { margin: var(--fs-space-1) 0; color: var(--fs-text); font-size: var(--fs-type); }
|
||||
/* …and the body elements an alert may carry. Absorbed from base/90-widgets.css, same values:
|
||||
* the theme had claimed only <p> out of that rule's six selectors. */
|
||||
/* the body elements an alert may carry, absorbed from base/90-widgets.css (same values) */
|
||||
.alert-message :is(ul, ol, li, pre) { color: var(--fs-text); margin: .25em 0; }
|
||||
/* inner table (firmware Current/Available) — flatten so it does not read as a nested card
|
||||
* on the alert panel; subtle row dividers only */
|
||||
/* inner table (firmware Current/Available) flattened — no nested-card look, row dividers only */
|
||||
.alert-message .table { margin: var(--fs-space-2-5) 0; background: transparent; border: 0; box-shadow: none; border-radius: 0; }
|
||||
/* --fs-text, not --fs-eyebrow-color: everywhere else an eyebrow sits on --fs-panel, but here the
|
||||
* surface is a status tint, and the muted ink does not survive it — measured on the firmware
|
||||
* table over the warning fill, hicontrast/light: 3.35-3.36:1 against AA's 4.5. The eyebrow still
|
||||
* reads as one from its tracking and size; only the ink changes. */
|
||||
/* --fs-text, not --fs-eyebrow-color: the muted ink does not survive the status tint
|
||||
* (3.35-3.36:1 against AA's 4.5). */
|
||||
.alert-message .table .th,
|
||||
/* The title-row selectors are not decoration: `.table .tr.table-titles .th` in 30-tables.css is
|
||||
* FOUR classes (0,4,0) and out-ranks the plain one above (0,3,0), so without these the header
|
||||
* kept the eyebrow ink no matter what this rule said — being later in the sheet buys nothing
|
||||
* against a heavier selector, which is the point of the "never win on source order" rule. */
|
||||
/* title-row (0,4,0) outranks the plain rule above (0,3,0) regardless of source order */
|
||||
.alert-message .table .tr.table-titles .th,
|
||||
.alert-message .table .tr.cbi-section-table-titles .th { color: var(--fs-text); font-weight: var(--fs-eyebrow-weight); font-size: var(--fs-type-xs); letter-spacing: var(--fs-eyebrow-tracking); }
|
||||
/* the column gap is --fs-space-3 like every other cell in the theme; --fs-space-3-5 here was the
|
||||
* only 14px cell gap in the sheet, and nothing above defends it — the comment covers flattening
|
||||
* the frame, not the rhythm. (The row density stays --fs-space-2: a table inside an alert is a
|
||||
* dense readout, same as the config tables that state it deliberately.) */
|
||||
/* column gap --fs-space-3 like every other cell; row density --fs-space-2, a dense readout */
|
||||
.alert-message .table .th, .alert-message .table .td { padding: var(--fs-space-2) var(--fs-space-3) var(--fs-space-2) 0; background: transparent; border-color: color-mix(in srgb, var(--fs-border) 60%, transparent); }
|
||||
.alert-message .table .td { color: var(--fs-text); }
|
||||
|
||||
/* The validation tooltip (ui.js pops it on a .cbi-tooltip-container). Its colour words were the
|
||||
* one status surface the theme never claimed, and nothing could see it: the gallery rendered a
|
||||
* plain `.cbi-tooltip` only, and an un-rendered widget shows no diff, which reads as "already
|
||||
* dead". The gallery renders all four now.
|
||||
*
|
||||
* The fill stays SOLID, unlike `.label` below: a tooltip floats over arbitrary content, so a
|
||||
* border carrying the status would have to read against whatever happens to be behind it. The
|
||||
* ink is the role's own `--fs-on-*`, which 03-palettes.css sizes per palette and per mode — a
|
||||
* global white failed AA on seven of the eight dark-palette fills. */
|
||||
/* One elevation for the widget. base lit it with a raw 2px halo in the hairline colour, which
|
||||
* measures 1.08-1.86:1 against the surfaces a tooltip lands on, while the theme overrode the
|
||||
* single instance somebody happened to look at. A tooltip floats over ARBITRARY content — the
|
||||
* same reason the colour variants below stay opaque — so it is the last surface that should be
|
||||
* separated from the page by a hairline. */
|
||||
/* validation tooltip: fill stays SOLID, unlike .label below — floats over arbitrary content,
|
||||
* so a border alone cannot carry the status. Shadow replaces base's raw 2px halo (1.08-1.86:1). */
|
||||
.cbi-tooltip { box-shadow: var(--fs-shadow-pop); }
|
||||
/* base reveals it on :hover alone. The container is `cursor: help` and carries no tabindex in
|
||||
* anything LuCI ships, so nothing can focus it today and this matches nothing — but the
|
||||
* focusability belongs to ui.js, and a third-party app that gives its own tooltip container a
|
||||
* tabindex gets a keyboard-reachable tooltip the day it does. */
|
||||
/* base reveals it on :hover alone; no tabindex in anything LuCI ships today, but a third
|
||||
* party's own container may add one */
|
||||
.cbi-tooltip-container:focus-within .cbi-tooltip:not(:empty) { inset-inline-start: auto; opacity: 1; }
|
||||
.cbi-tooltip.info { background: var(--fs-accent); border-color: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.cbi-tooltip.success { background: var(--fs-good); border-color: var(--fs-good); color: var(--fs-on-good); }
|
||||
.cbi-tooltip.error { background: var(--fs-danger); border-color: var(--fs-danger); color: var(--fs-on-danger); }
|
||||
.cbi-tooltip.notice { background: var(--fs-panel2); border-color: var(--fs-border); color: var(--fs-text); }
|
||||
|
||||
/* The `.label` pill. LuCI core barely uses it, but any luci-app-* may reach for it, so the
|
||||
* theme owns it in tokens (base's copy is absorbed). `.important` is the conventional name
|
||||
* for what we call accent; `.danger` has no counterpart there and is added because
|
||||
* packages use it. See docs/gallery.html. */
|
||||
/* .label: core barely uses it, any luci-app-* may. .important is bootstrap's accent name. */
|
||||
.label {
|
||||
display: inline-block; padding: var(--fs-space-1) var(--fs-space-2); border-radius: var(--fs-radius-pill);
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
color: var(--fs-dim); text-shadow: none; text-transform: none;
|
||||
/* carried over from the absorbed styles/base .label rule */
|
||||
white-space: nowrap; margin: .125em 0 .125em .4em;
|
||||
white-space: nowrap; margin: .125em 0 .125em .4em; /* carried over from the absorbed base rule */
|
||||
}
|
||||
/* The variants set no background, deliberately: they inherit the opaque --fs-panel2 from `.label`
|
||||
* above. A translucent tint of their own colour under text of that colour drags the background
|
||||
* toward the text and eats the contrast it needs, and being translucent it renders differently
|
||||
* on every surface, so no single percentage is safe — measured across palette x mode, every
|
||||
* variant landed at 3.4-4.4:1. On --fs-panel2 the palette guarantees >=4.5 by construction, and
|
||||
* the coloured border carries the status just as loudly. */
|
||||
/* no background: a translucent tint under same-colour text eats contrast (3.4-4.4:1);
|
||||
* inherited opaque --fs-panel2 guarantees >=4.5. */
|
||||
.label.important, .label.notice { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
.label.warning { border-color: var(--fs-warn); color: var(--fs-warn); }
|
||||
.label.success { border-color: var(--fs-good); color: var(--fs-good); }
|
||||
@@ -194,11 +109,8 @@
|
||||
a.label:link, a.label:visited { color: var(--fs-dim); }
|
||||
a.label:hover { text-decoration: none; color: var(--fs-text); }
|
||||
|
||||
/* The show/hide toggles in a section title are .label pills too, but clickable. SINGLE
|
||||
* owner of the pill look: pages/20-overview.css used to restate it (page layer, unscoped —
|
||||
* so it silently won everywhere) with drifted values; these are the ones that were actually
|
||||
* rendering. The trailing overrides (text-transform … box-shadow) kill the uppercase /
|
||||
* margin / min-width / badge decorations base used to put on these pills. */
|
||||
/* section-title toggles are .label pills, clickable — SINGLE owner (pages/20-overview.css
|
||||
* used to restate this, unscoped, drifted) */
|
||||
.cbi-title .label {
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-weight);
|
||||
padding: var(--fs-space-1) var(--fs-space-3); border-radius: var(--fs-radius-pill);
|
||||
@@ -208,12 +120,11 @@
|
||||
margin: 0; min-width: 0; box-shadow: none;
|
||||
}
|
||||
.cbi-title .label:hover { color: var(--fs-text); border-color: var(--fs-dim); opacity: 1; }
|
||||
/* These pills toggle section visibility — interactive, so keyboard focus must show. */
|
||||
/* toggles section visibility — interactive, so keyboard focus must show */
|
||||
.cbi-title .label:focus-visible {
|
||||
outline: none; border-color: var(--fs-accent); box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Surface stays OPAQUE (--fs-panel2 inherited from the base pill): accent text on
|
||||
* --fs-accent-soft is the accent-on-its-own-tint AA failure measured at 4.21:1 in the open
|
||||
* dropdown (theme/65-dropdown.css) — the border carries the accent instead. */
|
||||
/* opaque surface (--fs-panel2 inherited): accent text on --fs-accent-soft measured 4.21:1
|
||||
* (theme/65-dropdown.css) — the border carries the accent instead */
|
||||
.cbi-title .label.notice { opacity: 1; color: var(--fs-accent); border-color: var(--fs-accent); }
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
@layer theme {
|
||||
/* ---- tab strips: one component, four entry points ----
|
||||
* LuCI emits a strip under four selectors with no markup difference worth honouring:
|
||||
* #tabmenu ul.tabs page tabs, rendered into the server shell
|
||||
* ul.cbi-tabmenu.mode mode switcher (Available / Installed / Updates)
|
||||
* .tabs view tabs (Routing -> IPv4 / IPv6)
|
||||
* .cbi-tabmenu CBI form tabs (System -> General / Logging / …)
|
||||
* They agree on everything except the active marker: `.active` for `ul.tabs`, `.cbi-tab` for
|
||||
* every `.cbi-tabmenu` flavour. All four render as pills in a card. */
|
||||
/* one component, four LuCI selectors, no markup difference worth honouring: #tabmenu ul.tabs
|
||||
* (page tabs), ul.cbi-tabmenu.mode (mode switcher), .tabs (view tabs), .cbi-tabmenu (CBI form
|
||||
* tabs). Active marker: .active on ul.tabs, .cbi-tab on every .cbi-tabmenu flavour. */
|
||||
#tabmenu { margin-bottom: 0; }
|
||||
|
||||
/* the card */
|
||||
@@ -23,14 +18,12 @@
|
||||
background: var(--fs-panel);
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
/* kills a folder-tab baseline gradient (the 1px x 29px background image the strip
|
||||
* conventionally carries) by zeroing its size rather than restating it — base's copy is
|
||||
* absorbed, but any gradient a view or third-party app sets is sized away too */
|
||||
/* zeroes the folder-tab baseline gradient (1px x 29px) instead of restating it, so a
|
||||
* third-party gradient the strip carries is sized away too */
|
||||
background-size: 0 0;
|
||||
}
|
||||
|
||||
/* a strip nested inside a .cbi-section card -> soft inset row, no frame (a card inside a
|
||||
* card reads as a bug) */
|
||||
/* a strip nested in .cbi-section -> soft inset row, no frame (a card inside a card reads as a bug) */
|
||||
.cbi-section .tabs,
|
||||
.cbi-section .cbi-tabmenu:not(.mode):not(.map) {
|
||||
background: var(--fs-panel2);
|
||||
@@ -49,22 +42,16 @@
|
||||
color: var(--fs-dim);
|
||||
}
|
||||
|
||||
/* the theme's own page tabs and the mode switcher never carry a sentence-length label, so
|
||||
* `none` is right for them — `ul.cbi-tabmenu.mode > li` below wins specificity over the plain
|
||||
* `.cbi-tabmenu > li` rule that follows, so the mode pills stay `none` regardless of order. */
|
||||
/* page tabs and the mode switcher never carry a sentence-length label; ul.cbi-tabmenu.mode > li
|
||||
* wins specificity over .cbi-tabmenu > li below, so mode pills stay none regardless of order */
|
||||
#tabmenu ul.tabs > li,
|
||||
ul.cbi-tabmenu.mode > li,
|
||||
.tabs > li {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* the PLAIN CBI form tabs (ui.js, not the mode switcher): adblock's Overview page names one
|
||||
* "Настройки межсетевого экрана", measured 293px in a 264px column at 320px/Large — 28.6px past
|
||||
* it, section clipped by 30px; EN measured 0. The `a` already carries `white-space: nowrap;
|
||||
* overflow: hidden; text-overflow: ellipsis` (base/50-chrome.css) — it never engaged because the
|
||||
* li's `max-width: none` left its automatic minimum size at the label's full content width, so
|
||||
* flexbox never shrank it. `100%` is smaller than that content width once the column is, which
|
||||
* is exactly the case that broke; everywhere the tab already fits, this is a no-op. */
|
||||
/* CBI form tabs only, not the mode switcher: a long label overflowed its column by 28.6px at
|
||||
* 320px/Large, EN measured 0 — docs/chrome.md "Tab strips" has the measurement */
|
||||
.cbi-tabmenu > li {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -109,18 +96,9 @@
|
||||
font-weight: var(--fs-weight-bold);
|
||||
}
|
||||
|
||||
/* ---- keyboard focus: a ring that survives the ACTIVE pill ----
|
||||
*
|
||||
* base/50-chrome.css drew it as `outline: 2px solid var(--fs-accent)` inset by -2px, which is
|
||||
* invisible on exactly the tab most likely to be focused, the active pill's own background being
|
||||
* that same accent (measured on the router: both rgb(86,157,245)).
|
||||
*
|
||||
* One rule for both states, on --fs-focus-ring-solo: a tab flips nothing else on focus, so the
|
||||
* ring IS the indicator, and the tinted --fs-focus-ring measures 1.15-1.29:1 against the strip —
|
||||
* the first version of this fix took that tint for the inactive pill and swapped one invisible
|
||||
* indicator for another.
|
||||
* -solo's 2px surface gap is also what lets one value serve the active pill. box-shadow, not
|
||||
* outline, because the strip clips and a shadow is not clipped by the scroll container. */
|
||||
/* one ring for both states, on --fs-focus-ring-solo: base's outline is invisible on the active
|
||||
* pill, whose background is the same accent — docs/chrome.md "Tab strips" has the contrast
|
||||
* numbers. box-shadow, not outline, because the strip clips and a shadow is not */
|
||||
#tabmenu ul.tabs > li > a:focus-visible,
|
||||
ul.cbi-tabmenu.mode > li > a:focus-visible,
|
||||
.tabs > li > a:focus-visible,
|
||||
@@ -129,14 +107,8 @@
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Density steps for a crowded strip: fs-chrome.js (fitTabStrips) MEASURES whether the tabs
|
||||
* wrap to a second row and escalates .fs-dense1 -> .fs-dense2, mutually exclusive (dense1 is
|
||||
* removed the moment dense2 is added, so a pill is never trimmed by both at once). Both steps
|
||||
* give the pill the SAME padding (--fs-space-1 --fs-space-2) and, where the rule sets it at
|
||||
* all, the SAME font-size (--fs-type) — dense1 does not touch font-size, dense2 restates it;
|
||||
* neither actually shrinks it further, since --fs-type is already the strip's own base size.
|
||||
* Only the strip's own `gap` is touched by a dense rule, and only at d2; past that floor the
|
||||
* strip may wrap. The id in the first selector matches the #tabmenu rule's specificity. */
|
||||
/* dense1 -> dense2 (fitTabStrips, fs-chrome.js) escalate mutually exclusively on wrap; both
|
||||
* give the pill the same padding, dense2 alone also tightens the strip's own gap */
|
||||
#tabmenu ul.tabs.fs-dense1 > li > a,
|
||||
.tabs.fs-dense1 > li > a,
|
||||
.cbi-tabmenu.fs-dense1 > li > a { padding: var(--fs-space-1) var(--fs-space-2); }
|
||||
|
||||
@@ -1,143 +1,66 @@
|
||||
@layer theme {
|
||||
/* The realtime graphs size their drawing from #view, not from the box they draw into:
|
||||
* `width = document.querySelector('#view').offsetWidth - 2`, in every realtime view and in the
|
||||
* luci-app-*-status copies of them. That assumption holds only in a theme whose .cbi-section has
|
||||
* no gutter; ours is a card, 16px of padding plus a 1px border, so the canvas comes out 34px
|
||||
* narrower than the drawing — and the views draw NEWEST-first from the right, so the freshest
|
||||
* samples are the ones falling off the edge. It reads as a phone bug only because the loss is
|
||||
* absolute: 34px of a 1124px desktop plot is 3%, of a 322px phone plot 10%.
|
||||
*
|
||||
* So bleed the graph box back out to the card's border edge, making the canvas exactly
|
||||
* `#view - 2` again. The bleed is padding plus border, derived from --fs-card-pad rather than
|
||||
* restated, or narrowing the gutter would silently re-clip every graph.
|
||||
*
|
||||
* The `!important` fights the inline `width: 100%` the view writes on the box: nothing in a
|
||||
* cascade layer can outrank an inline declaration. */
|
||||
/* realtime graphs size their canvas from #view, clipping 34px off the newest samples — docs/css.md "Misc: the realtime graph bleed" */
|
||||
#view .cbi-section > div[style]:has(> svg) {
|
||||
width: auto !important;
|
||||
margin-inline: calc(-1 * (var(--fs-card-pad) + 1px));
|
||||
}
|
||||
|
||||
/* …and the same box's frame, which the same inline style paints BLACK: every realtime view writes
|
||||
* `border:1px solid #000;background:#fff` on it. Black is a colour no palette of this theme
|
||||
* contains, so on a dark surface the graph reads as a framed cut-out, and the frame lands
|
||||
* exactly on the card's own border edge because of the bleed above (issue #32).
|
||||
*
|
||||
* Wider than the bleed rule on purpose: channel_analysis.js hangs off a bare [data-tab] div and
|
||||
* must not be bled, but its border is just as black, so this asks only "an inline-styled box
|
||||
* wrapping an <svg>".
|
||||
*
|
||||
* Only the COLOUR moves. The 1px stays, being the graph's own edge, and `background: #fff` is
|
||||
* left alone: the <svg> is sized to the box's content edge, so the white never paints. Only an
|
||||
* author `!important` outranks an inline declaration, and `border-color` alone is enough — the
|
||||
* width and style already agree with what the theme would ask for. */
|
||||
/* same box's frame, painted BLACK inline — only colour moves; wider than the bleed above, channel_analysis.js's box must not be bled but is just as black */
|
||||
#view div[style]:has(> svg) {
|
||||
border-color: var(--fs-border) !important;
|
||||
}
|
||||
|
||||
/* WCAG 2.2 SC 1.4.1 (Use of Color, A): inside a block of text a link may not be identified
|
||||
* by COLOUR ALONE — it needs a non-colour cue or 3:1 against the surrounding text, and
|
||||
* --fs-accent on --fs-text measures 1.4:1 in dark mode. So: underline links in prose.
|
||||
* Scoped to prose deliberately — nav links, buttons, pills and table cells are identified
|
||||
* by position and shape, and underlining them would be noise. */
|
||||
/* WCAG 1.4.1: --fs-accent on --fs-text measures 1.4:1 in dark, needs a non-colour cue in
|
||||
* prose. Nav links/buttons/pills are identified by position and shape instead. */
|
||||
p a, dd a,
|
||||
.cbi-value-description a,
|
||||
.alert-message a:not(.btn):not(.cbi-button) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* A <strong>/<b> is a LABEL, and a label is UI text, so it takes the UI face even on a monospaced
|
||||
* surface. LuCI builds every status readout as `<strong>MAC:</strong> ac:1f:6b:…`: the strong
|
||||
* names the datum, the text after it IS the datum. The theme puts mono on those surfaces, the
|
||||
* strong inherits it, and <strong> also means weight 700 — so the label asks for a bold mono
|
||||
* face that exists for no other reason: 227 elements across seven pages, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw the word "MAC:".
|
||||
*
|
||||
* It must SET the face, not merely be excluded from the mono rule: an excluded element still
|
||||
* INHERITS its parent's. Excluding it was the first attempt and changed nothing.
|
||||
*
|
||||
* pre/code/#syslog/textarea are exempt: there a <strong> is part of the monospaced CONTENT. */
|
||||
/* a LABEL must SET sans, not merely be excluded — excluded still inherits mono (227 elements/
|
||||
* 20 KB of bold mono for nothing); exempt in pre/code/#syslog/textarea, monospaced CONTENT */
|
||||
:is(strong, b):not(pre *, code *, #syslog *, textarea *) {
|
||||
font-family: var(--fs-font-sans);
|
||||
}
|
||||
|
||||
/* Same argument, two holdouts. `.ifacebadge` is a BADGE — chrome, not a value — sitting in a mono
|
||||
* cell, and `.ifacebadge-active` makes it bold; sans keeps the active/inactive weight cue (WCAG
|
||||
* 1.4.1, that distinction not being carried by colour) with faces the page already has.
|
||||
* `<code>`/`<pre>` hold a LITERAL, which does not inherit its container's emphasis.
|
||||
*
|
||||
* The second selector is not redundant: the mono rule in theme/60-inputs.css is (0,1,0), a TIE
|
||||
* with a bare `.ifacebadge`, and a tie is settled by source order, which 60-inputs would win.
|
||||
* Win on specificity instead, at (0,2,0). */
|
||||
/* `.ifacebadge` is a BADGE (chrome, not a value) in a mono cell; wins on specificity (0,2,0)
|
||||
* over theme/60-inputs.css's (0,1,0) mono rule, not source order */
|
||||
.ifacebadge,
|
||||
.cbi-value-field .ifacebadge { font-family: var(--fs-font-sans); }
|
||||
code, pre { font-weight: var(--fs-weight-normal); }
|
||||
|
||||
/* ---- the poll's `fade-in` is not animated at all, and that is the whole rule ----
|
||||
*
|
||||
* `fade-in` is upstream's class and luci-mod-status re-marks with it on EVERY poll tick — each
|
||||
* section, and the wrapper holding all of them — so anything this theme hangs on that class
|
||||
* replays on every tick across the entire content column.
|
||||
*
|
||||
* A fade plus a 4px slide moved the page under the reader; the fade alone still pulsed the whole
|
||||
* column on every tick, reported from an iPhone as shaking that happens WITHOUT SCROLLING AT
|
||||
* ALL, which is what named it — a poll tick is the only thing that happens on a still page.
|
||||
*
|
||||
* So the theme animates nothing on it. The class stays in the markup, being upstream's, and the
|
||||
* `fade-in` keyframes in base/95-luci.css stay for the coverage reason stated there. */
|
||||
/* upstream's fade-in re-marks on EVERY poll tick, animating it pulsed a still page — docs/css.md "Misc: fade-in animates nothing" */
|
||||
.cbi-title-ref { color: var(--fs-accent); }
|
||||
.close { color: var(--fs-text); text-shadow: none; opacity: .6; }
|
||||
.close:hover { opacity: 1; }
|
||||
.ifacebox { background: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.ifacebox .ifacebox-head { background: var(--fs-panel2); color: var(--fs-text); }
|
||||
/* The active-interface highlight lives in THIS layer, next to the head it overrides: the
|
||||
* same rule in base was dead — a layer beats specificity, and the plain `.ifacebox-head`
|
||||
* above repainted `.active` along with the rest. */
|
||||
/* lives in THIS layer, next to the head it overrides: the same rule in base was dead, a layer beats specificity */
|
||||
.ifacebox .ifacebox-head.active { background: var(--fs-accent); color: var(--fs-on-accent); }
|
||||
.ifacebadge { background: var(--fs-panel2); border-color: var(--fs-border); }
|
||||
.ifacebadge-active { border-color: var(--fs-accent); }
|
||||
.zonebadge-empty { border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* The @container PLUMBING for the responsive tables — only WHICH boxes are query containers, under
|
||||
* what names. The card rules are not here: a CBI section table cards under `@container
|
||||
* fs-content (max-width: 992px)` (theme/65-dropdown.css — 992, not the row's own 960px floor,
|
||||
* because the query reads THIS element, one level above the `.cbi-section` that spends
|
||||
* `--fs-card-pad` before the table ever sees the width), and a data table cards by MEASUREMENT
|
||||
* (.fs-stacked, fs-select.js) at any width.
|
||||
*
|
||||
* Query the CONTENT width, not the viewport: the sidebar eats width, so a viewport breakpoint
|
||||
* stacks too late or too early depending on layout.
|
||||
*
|
||||
* Named, deliberately — an unnamed @container binds to the nearest ancestor that happens to have
|
||||
* 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 .cbi-map, #view and .modal (below),
|
||||
* so a query resolves to the nearest of the three. */
|
||||
/* @container PLUMBING only, card rules live with the tables. Named: an unnamed query binds to the nearest container-type ancestor, silently re-targeted by a new one */
|
||||
.cbi-map { container-type: inline-size; container-name: fs-content; }
|
||||
#view { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
|
||||
/* A modal is not inside #view — ui.js appends #modal_overlay to <body> — so every @container query
|
||||
* above resolves to nothing in there and no table stacks: on a phone the Edit-interface dialog
|
||||
* rendered its form at desktop widths and cut the inputs off. Carry both names so a dialog
|
||||
* stacks exactly like the page, a third-party app's modal included. */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; }
|
||||
/* ui.js appends #modal_overlay outside #view, so the names above must repeat here or no dialog query resolves.
|
||||
* box-shadow merged in from theme/70-modal.css: base's elevation = --fs-bg's hex in footstrap
|
||||
* light, a glow not a shadow; others take --fs-shadow-pop */
|
||||
.modal { container-type: inline-size; container-name: fs-view fs-content; box-shadow: var(--fs-shadow-pop); }
|
||||
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped: any .pager gets an inline
|
||||
* centred row with compact square buttons. */
|
||||
/* pager (« prev · text · next ») — generic, not page-scoped */
|
||||
.pager.center { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2-5); padding: var(--fs-space-3-5) 0; }
|
||||
.pager .text { flex: 0 0 auto; font-size: var(--fs-type); color: var(--fs-dim); }
|
||||
|
||||
/* The heading ramp, absorbed from base wholesale (docs/css.md). Stock LuCI hands h2 to a page title
|
||||
* and h3/legend to a section title, and the heading-card rule below plus theme/30-tables.css
|
||||
* repaint exactly those two, so most of the ramp is only ever rendered by a third-party
|
||||
* luci-app-* — styled anyway, coverage being a contract. Every step is a scale token and the
|
||||
* ramp is monotonic. `legend` is deliberately not in the group: theme/30-tables.css owns it, and
|
||||
* a member here would fight that at equal specificity, i.e. on source order. */
|
||||
/* absorbed from base; stock LuCI only renders h2/h3, the rest is third-party coverage.
|
||||
* `legend` excluded: theme/30-tables.css owns it. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
/* 1.5 is prose leading; a heading is a label and looks slack at it. */
|
||||
/* 1.5 is prose leading; a heading is a label and looks slack at it */
|
||||
line-height: var(--fs-leading-tight);
|
||||
}
|
||||
h1 { margin-bottom: var(--fs-space-4); font-size: var(--fs-type-2xl); }
|
||||
@@ -147,9 +70,8 @@
|
||||
h5 { font-size: var(--fs-type); }
|
||||
h6 { font-size: var(--fs-type-xs); color: var(--fs-dim); text-transform: uppercase; }
|
||||
|
||||
/* Heading card (h2 + .cbi-map-descr): everything sitting directly on the page background
|
||||
* gets a .cbi-section card's frame, so a page reads as a stack of panels instead of loose
|
||||
* text floating over the (possibly patterned) background. */
|
||||
/* heading card: h2 + .cbi-map-descr get a .cbi-section card's frame, so the page reads as a
|
||||
* stack of panels rather than loose text over the background */
|
||||
.cbi-map > h2,
|
||||
#view > h2 {
|
||||
background: var(--fs-panel);
|
||||
@@ -161,8 +83,7 @@
|
||||
font-weight: var(--fs-weight-bold);
|
||||
color: var(--fs-text);
|
||||
}
|
||||
/* a heading followed by a description: split the card across the two siblings (no wrapper
|
||||
* element exists to hold both) */
|
||||
/* split the card across the two siblings — no wrapper element exists to hold both */
|
||||
.cbi-map > h2:has(+ .cbi-map-descr),
|
||||
#view > h2:has(+ .cbi-map-descr) {
|
||||
border-bottom: 0;
|
||||
@@ -184,43 +105,18 @@
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
|
||||
/* An action-button row (a `.right` wrapper holding Add/Save) sitting directly under a field
|
||||
* had no gap above it — the apk "Repo Public Keys -> Add key" paste box, and every other
|
||||
* paste-a-value-then-submit layout, pinned its button to the textarea. Scoped to a `.right`
|
||||
* immediately following a form control, so it never touches right-aligned text or cells. */
|
||||
/* .right holding Add/Save directly under a field had no gap: pinned the button to the textarea */
|
||||
#view :is(textarea, input[type="text"], input[type="password"], .cbi-dropdown) + .right {
|
||||
margin-top: var(--fs-space-2-5);
|
||||
}
|
||||
|
||||
/* A button row belonging to the PAGE TITLE rather than to any section. Nothing gives it a bottom
|
||||
* margin, so it sits flush against the first .cbi-section below and reads as part of that card
|
||||
* instead of as a page-level action; the same 16px every .cbi-section uses.
|
||||
*
|
||||
* And a top margin for the same reason at the other edge, the row sitting directly under the
|
||||
* page heading with nothing between them. Half the bottom gap, because a heading and its own
|
||||
* action belong together. */
|
||||
/* button row belonging to the PAGE TITLE, not a section — same 16px as .cbi-section, half above */
|
||||
.cbi-title-buttons { margin-block: var(--fs-space-2) var(--fs-card-gap); }
|
||||
|
||||
/* The per-section Delete button (`.cbi-section-remove`, what luci-base renders for every NAMED
|
||||
* section: SQM's queues, the firewall's zones, any app that lets you add and drop instances) sat
|
||||
* flush against whatever follows it — usually that section's own tab bar. Reported upstream on
|
||||
* SQM, but nothing here is about SQM: it is one class luci-base gives every such section, and
|
||||
* the gap belongs to the pattern rather than to the page. */
|
||||
/* Delete button luci-base renders for every NAMED section sat flush against what follows — one class, not one app */
|
||||
.cbi-section-remove { margin-bottom: var(--fs-space-2); }
|
||||
|
||||
/* A top-level block in a map that is not a section still keeps the page's rhythm.
|
||||
*
|
||||
* A view may return a bare widget where a section is expected — `s.render = () => new
|
||||
* ui.Textarea(...).render()` hands the map a plain `<div>` with no `.cbi-section` — so nothing
|
||||
* gives that block the gap every section carries and the next section starts against it. Stock
|
||||
* bootstrap measures the same 0px, but there a section is a stretch of page and here it is a
|
||||
* CARD, so the missing gap reads as two cards fused.
|
||||
*
|
||||
* Written against what a map CONTAINS rather than against one app: the exclusions are the
|
||||
* children luci-base itself puts there and which own their spacing already — the map heading and
|
||||
* its description, the action bar, a tabbed map's tab bar and pane, and sections.
|
||||
*
|
||||
* `:not(:empty)` because a map can hold a placeholder that renders nothing. */
|
||||
/* a view may hand the map a bare widget with no `.cbi-section`; exclusions are the children luci-base already spaces itself */
|
||||
.cbi-map > *:not(.cbi-section):not(.cbi-map-descr):not(.cbi-page-actions):not(h2):not(legend):not(.cbi-tabmenu):not(.cbi-map-tabbed):not(:empty) {
|
||||
margin-bottom: var(--fs-card-gap);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
@layer theme {
|
||||
/* ---- the top-layout bar: :root[data-layout="top"], every width ----
|
||||
*
|
||||
* A DELTA, not a layout. The bar itself — sticky, blurred, row, popup submenus, square icon
|
||||
* buttons, right cluster — is the unguarded base in 20-shell.css, the bar being what the chrome
|
||||
* IS unless the vertical sidebar overrides it. This file adds what the top-layout bar wants on
|
||||
* top of that:
|
||||
* - a designed bar height, and padding aligned to the content column;
|
||||
* - the menu on the brand's row instead of wrapped beneath it.
|
||||
*
|
||||
* No `@media` floor: "does the menu fit on the brand's row?" is a property of the CONTENT (5
|
||||
* sections stock, eleven with a few luci-app-*), not of the viewport, so the whole bar is
|
||||
* MEASURED — fs-chrome.js shrinks the pills, collapses the poll pill to an icon, then stacks the
|
||||
* menu onto its own row, at any width. The under-item dropdown and its clamp therefore apply at
|
||||
* every width too. */
|
||||
/* a DELTA on top of the bar's unguarded base (20-shell.css), every width — no @media floor,
|
||||
* fitChrome measures instead — docs/css.md "Toplayout: measured, not media-queried". */
|
||||
:root[data-layout="top"] .fs-sidebar {
|
||||
/* min-height, not height: the bar has a designed height but must be able to GROW —
|
||||
* a long hostname wraps onto a second line inside the capped brand (20-shell.css),
|
||||
* and a fixed height would clip it. */
|
||||
/* min-height not height: a long hostname wraps the capped brand onto a second line (20-shell.css) */
|
||||
min-height: var(--fs-bar-h); height: auto;
|
||||
gap: var(--fs-space-3);
|
||||
/* nowrap while the menu rides the brand's row. Without it flexbox wraps the BAR, carrying the
|
||||
* menu onto a second line by itself while its own pills stay in one row — a stacked bar in
|
||||
* everything but the styling. It also destroys the fits-on-one-row measurement, a menu handed
|
||||
* a whole row of course fitting. Forbidding the wrap forces the menu to share the row and to
|
||||
* wrap its OWN pills when it cannot, which is the signal fitChrome measures. */
|
||||
/* nowrap or flexbox wraps the BAR itself instead of the menu's own pills — the signal fitChrome measures */
|
||||
flex-wrap: nowrap;
|
||||
/* align the bar's contents to the .fs-content column, so the brand and the menu line
|
||||
* up with the headings and the cards below */
|
||||
/* aligns the bar to .fs-content so the brand/menu line up with the headings below */
|
||||
padding: 0 max(20px, calc((100% - var(--fs-content-max)) / 2 + var(--fs-content-pad)));
|
||||
}
|
||||
|
||||
/* the menu rides the brand's row; its flex:1 opens the gap that pushes the right-hand
|
||||
* cluster to the content column's right edge */
|
||||
/* flex:1 opens the gap pushing the right cluster to the column's edge */
|
||||
:root[data-layout="top"] .fs-sidebar ul.nav {
|
||||
order: 2; flex: 1 1 auto; margin-inline-start: var(--fs-space-2);
|
||||
}
|
||||
@@ -39,30 +19,16 @@
|
||||
:root[data-layout="top"] .fs-themerow,
|
||||
:root[data-layout="top"] .fs-logout { order: 3; margin-inline-start: 0; }
|
||||
|
||||
/* The under-item dropdown anchor, its hover bridge and the clamp are NOT here: they belong to
|
||||
* the BAR and live unguarded in 20-shell.css. They used to be this file's, and that WAS issue
|
||||
* #19 — the sidebar layout collapsed onto a bar is the same chrome, and it inherited a panel
|
||||
* pinned to the bar's left edge with no bridge, unreachable by hover. This file is the top
|
||||
* layout's delta; a rule every bar state needs is not a delta. */
|
||||
/* dropdown anchor/bridge/clamp live unguarded in 20-shell.css, not here (issue #19) */
|
||||
|
||||
/* the mode strip (only rendered when a router has more than one top-level mode) takes
|
||||
* its own full-width row under the bar */
|
||||
/* :not(:empty):not(.single) instead of a hide pair — the bar's base rule (20-shell.css)
|
||||
* already hides the strip; see the sidebar twin there for why the pair was folded. */
|
||||
/* :not(:empty):not(.single), not a hide pair: 20-shell.css already hides the mode strip */
|
||||
:root[data-layout="top"] .fs-modemenu:not(:empty):not(.single) {
|
||||
display: flex; order: 4; flex: 1 1 100%; margin: 0; gap: var(--fs-space-1);
|
||||
}
|
||||
|
||||
/* the bar replaces the sidebar's 22px top padding with the top-nav's 26px */
|
||||
:root[data-layout="top"] .fs-content { padding-top: var(--fs-space-6); }
|
||||
|
||||
/* ---- the menu does not fit on the brand's row: .fs-bar-stack ----
|
||||
*
|
||||
* Set by fs-chrome.js (fitChrome), not by a media query: whether the menu fits beside the brand
|
||||
* depends on how many sections the router HAS, so the width at which the bar must stack is a
|
||||
* property of the content and a hard-coded breakpoint gets it wrong in both directions. The JS
|
||||
* shrinks the pills first, then collapses the poll pill, and stacks only when even that still
|
||||
* overflows. */
|
||||
/* .fs-bar-stack: set by fitChrome (fs-chrome.js), not a media query — same reasoning as the header above */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack {
|
||||
flex-wrap: wrap; height: auto; padding: var(--fs-space-2-5) var(--fs-space-4);
|
||||
}
|
||||
@@ -75,16 +41,8 @@
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators,
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow { margin-inline-start: auto; }
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack #indicators:not(:empty) ~ .fs-themerow { margin-inline-start: 0; }
|
||||
/* Two .fs-themerow buttons, Search then Appearance: only the FIRST rendered far-edge item may
|
||||
* keep the auto inline-start margin, exactly as in the base bar, or flexbox splits the free
|
||||
* space between the two autos and Search drifts to mid-bar. The base `.fs-themerow ~
|
||||
* .fs-themerow` guard is out-specified by the auto rule above (0,4,0 against 0,2,0), so it must
|
||||
* be restated here. Bites when #indicators is :empty — right after an SPA nav, before
|
||||
* the poll pill returns. */
|
||||
/* only the FIRST far-edge item keeps the auto margin, restated here — base guard is out-specified (0,4,0 vs 0,2,0) */
|
||||
:root[data-layout="top"] .fs-sidebar.fs-bar-stack .fs-themerow ~ .fs-themerow { margin-inline-start: 0; }
|
||||
|
||||
/* The right cluster's own escalation — .fs-ind-compact and .fs-bar-actrow — is NOT here: it
|
||||
* belongs to the BAR, and the sidebar layout is a bar too once fitShell has stamped
|
||||
* data-narrow. Both live with the bar in 20-shell.css; this file is only the top layout's
|
||||
* delta, and the menu's .fs-bar-stack above is the part that really is top-only. */
|
||||
/* .fs-ind-compact/.fs-bar-actrow live with the bar in 20-shell.css too — only the top delta lives here */
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
@layer theme {
|
||||
/* Buttons placed straight in a field, not in a .control-group (app action rows
|
||||
* Enable/Disable, Start/Restart/Stop): inline-block buttons only get whitespace spacing
|
||||
* horizontally, so on wrap the rows collapsed together. Wrapping flex with a real gap. */
|
||||
/* buttons straight in a field (Enable/Disable, Start/Restart/Stop) only get whitespace
|
||||
* spacing inline-block, collapsing rows on wrap — wrapping flex with a real gap instead */
|
||||
.cbi-value-field:has(> .cbi-button) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* The button. The native `input` types are in the selector list on purpose: LuCI's own views
|
||||
* always use <button class="cbi-button">, but a third-party luci-app-* may emit a bare
|
||||
* <input type="submit">, and base only gives those width/height: auto — coverage is a
|
||||
* contract (docs/gallery.html). `.btn` and `.cbi-button` are interchangeable in LuCI markup
|
||||
* (a row-action button usually carries both), so every rule here names both. */
|
||||
/* native input types listed on purpose: LuCI always uses <button class="cbi-button">, but a
|
||||
* third-party app may emit a bare <input type="submit"> and base only gives those width/
|
||||
* height: auto — coverage is a contract (docs/gallery.html) */
|
||||
.cbi-button, .btn,
|
||||
input[type="button"], input[type="submit"], input[type="reset"] {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
/* auto, stated: theme/60-inputs.css's generic `input` box says 210px x 32px, and a base
|
||||
* reset would lose to it by layer — a button sizes to its label */
|
||||
/* auto, stated: theme/60-inputs.css's generic input box says 210x32px, a base reset would lose to it by layer */
|
||||
width: auto; height: auto;
|
||||
min-height: var(--fs-ctl-h);
|
||||
padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
@@ -25,32 +21,23 @@
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
box-shadow: none; text-shadow: none;
|
||||
/* border-COLOR, not `border`: the shorthand animates border-width too — a layout
|
||||
* animation on every hover instead of a paint one (absorbed from base with the ring) */
|
||||
/* border-COLOR not the shorthand, which also animates width — a layout animation on every hover */
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* bare `button` rides along: a third-party app's unclassed <button> hovered the same border
|
||||
* cue in base's old glow rule, and border-color is a no-op on its border: 0 box — harmless
|
||||
* there, meaningful the moment the app gives it a border */
|
||||
/* bare `button` rides along: harmless on a border:0 box, meaningful once an app gives it one */
|
||||
.cbi-button:hover, .btn:hover, button:hover,
|
||||
input[type="button"]:hover, input[type="submit"]:hover, input[type="reset"]:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* Keyboard focus, not decoration: without this a button shows nothing on focus. The
|
||||
* `box-shadow: none` above kills any stray focus glow (theme beats base regardless of
|
||||
* specificity), and the UA outline only ever shows on :focus-visible, which this rule restyles.
|
||||
* A plain mouse :focus deliberately shows nothing — a glow rings every clicked button, and the
|
||||
* pressed cue below already answers the click. Same --fs-focus-ring as every field. */
|
||||
/* keyboard focus only: box-shadow:none above kills any stray glow, plain mouse :focus shows
|
||||
* nothing on purpose — the pressed cue below already answers a click */
|
||||
.cbi-button:focus-visible, .btn:focus-visible,
|
||||
input[type="button"]:focus-visible, input[type="submit"]:focus-visible, input[type="reset"]:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
/* Solid variants sit ON --fs-accent/--fs-good/--fs-danger, so an accent-tinted ring is invisible
|
||||
* against them: ring in the surface colour with an accent halo. Written out as a literal ending
|
||||
* in --fs-accent-soft, that halo was a 10-15% tint at 1.15-1.29:1 and the only thing it painted
|
||||
* was the 2px gap; --fs-focus-ring-solo is the same geometry with a solid accent, under one
|
||||
* name. */
|
||||
/* solid variants sit ON --fs-accent/-good/-danger, an accent-tinted ring is invisible against
|
||||
* them — ring in the surface colour with a solid accent halo (--fs-focus-ring-solo) instead */
|
||||
.cbi-button-positive:focus-visible, .cbi-button-action:focus-visible,
|
||||
.cbi-button-negative:focus-visible, .cbi-button-remove:focus-visible,
|
||||
.cbi-button-apply:focus-visible, .cbi-button-save:focus-visible,
|
||||
@@ -61,19 +48,14 @@
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
|
||||
/* Pressed state. The `box-shadow: none` above would erase any press cue, so the inset shadow
|
||||
* is declared here — the sole copy (base's identical one was absorbed). */
|
||||
/* the sole copy of the pressed inset shadow (base's identical one was absorbed) */
|
||||
.cbi-button:active, .btn:active, .btn.active,
|
||||
input[type="button"]:active, input[type="submit"]:active, input[type="reset"]:active {
|
||||
box-shadow: inset 0 2px 4px color-mix(in srgb, var(--fs-border) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Solid variants — three fills, not six. `.cbi-button-*` is what LuCI's own views emit and
|
||||
* `.btn.<colour-word>` what a third-party luci-app-* tends to emit, and base paints those words
|
||||
* with gradients that are dead: the plain `.btn` rule above sets background, border and colour
|
||||
* in a later layer, so an unclaimed `.btn.danger` renders as a neutral grey button with no
|
||||
* colour cue. Coverage is a contract, so the theme claims them — in the SAME rule as their
|
||||
* .cbi-button-* twin, because written apart a recolour lands on one half and not the other. */
|
||||
/* three fills, not six: `.cbi-button-*` (LuCI's own views) and `.btn.<colour-word>` (third-party
|
||||
* apps) painted together in one rule, or a recolour lands on one half and not the other */
|
||||
.cbi-button-positive, .btn.success {
|
||||
background: var(--fs-good); border-color: transparent; color: var(--fs-on-good);
|
||||
}
|
||||
@@ -84,10 +66,7 @@
|
||||
.btn.danger, .btn.error {
|
||||
background: var(--fs-danger); border-color: transparent; color: var(--fs-on-danger);
|
||||
}
|
||||
/* This hover must stay AHEAD of the page-action Save rule below: at equal specificity its
|
||||
* `border-color: transparent` would erase Save's outline on hover. (.cbi-button-remove is
|
||||
* deliberately absent — it never had the lift, and the row-action rules colour it their
|
||||
* own way.) */
|
||||
/* must stay AHEAD of the page-action Save rule below, or its border-color:transparent erases Save's outline on hover at equal specificity */
|
||||
.cbi-button-positive:hover, .cbi-button-action:hover, .cbi-button-apply:hover,
|
||||
.cbi-button-save:hover, .cbi-button-negative:hover,
|
||||
.btn.success:hover, .btn.info:hover, .btn.danger:hover, .btn.error:hover {
|
||||
@@ -95,10 +74,8 @@
|
||||
}
|
||||
.cbi-button-reset, .cbi-button-link { background: transparent; border-color: var(--fs-border); color: var(--fs-dim); }
|
||||
|
||||
/* Size words. The `.btn` rule above re-sets padding and radius from the token scale in a
|
||||
* later layer, so a size word that carried only a font-size (as base's absorbed copy did)
|
||||
* came out with default-button padding. Theme owns .btn.large/.small in full now, geometry
|
||||
* on the radius scale rather than base's hardcoded 6px. */
|
||||
/* .btn owns size words in full now: the plain rule above re-sets padding/radius in a later
|
||||
* layer, so a size word carrying only font-size (base's old copy) got default padding */
|
||||
.btn.large { font-size: var(--fs-type-lg); min-height: var(--fs-ctl-h-lg); padding: var(--fs-space-2) var(--fs-space-4); border-radius: var(--fs-radius); }
|
||||
.btn.small { font-size: var(--fs-type-xs); min-height: var(--fs-ctl-h-sm); padding: var(--fs-space-1) var(--fs-space-2-5); border-radius: var(--fs-radius-sm); }
|
||||
|
||||
@@ -110,8 +87,7 @@
|
||||
}
|
||||
.alert-message .btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Page action bar (.cbi-page-actions) — the Save/Apply footer. base renders a tall
|
||||
* half-empty plate with bottom-only radius; make it a compact rounded action bar. */
|
||||
/* Save/Apply footer: base renders a tall half-empty plate with bottom-only radius, made a compact rounded bar */
|
||||
.cbi-page-actions {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -124,21 +100,10 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--fs-space-2);
|
||||
/* The design call: WRAP the bar rather than let its items shrink into it. Save/Reset are
|
||||
* plain text with no ellipsis, so their min-content protects them — a no-wrap row's shrink
|
||||
* factor lands entirely on the Save & Apply split control, whose caption DOES have an
|
||||
* ellipsis to give (theme/65-dropdown.css) and so absorbs the whole squeeze. Measured on the
|
||||
* gallery's own fixture (EN "Save & Apply"): a no-wrap row cut it by up to 87px at 320px/
|
||||
* Normal and 119px at 320px/Large, worse than the numbers this bug was filed against
|
||||
* (43-68px on a live page, where the sidebar leaves less room than the gallery's isolated
|
||||
* card) — same fault, same direction, this fixture just has less room to give.
|
||||
* `flex-wrap: wrap` sends the split control (or Save+Reset) to its own line once they no
|
||||
* longer fit one, so the caption is squeezed only by its OWN box, never by a sibling's:
|
||||
* the same fixture measures 0px cut at every width/density this bug was filed against, RU
|
||||
* or EN, after this one line. The caption still owns an ellipsis for the genuine edge it
|
||||
* cannot solve — a full RU sentence at Large density in a 320px column has nowhere left to
|
||||
* grow even alone on its line (measured: 33px still short there) — and that is the right
|
||||
* place for it to give, not a sibling button's unrelated width. */
|
||||
/* WRAP the bar rather than let items shrink into it: a no-wrap row put the whole squeeze on
|
||||
* the Save & Apply split control's caption (measured up to 119px cut at 320px/Large) instead
|
||||
* of sharing it — docs/css.md "Buttons: the page-action bar wraps, not shrinks" has the
|
||||
* before/after numbers. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cbi-page-actions .cbi-button,
|
||||
@@ -150,14 +115,8 @@
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Reset takes the same step of the ladder as Save (-soft at rest, -fill on hover), so the two read
|
||||
* as equally weighted secondary actions beside the solid Save & Apply — but off the DANGER role,
|
||||
* not the accent one: Reset discards your unsaved changes. (An opaque --fs-panel2 was the first
|
||||
* attempt and weighed MORE than Save's translucent 10-15% tint: a solid surface and a tint do
|
||||
* not read as the same fill, whatever their lightness.) The label is --fs-text for the same
|
||||
* reason as Save's — --fs-dim on
|
||||
* --fs-danger-soft measures 4.49:1 in the worst palette x mode block, under AA by a hundredth.
|
||||
* The role lives in the fill and the border. */
|
||||
/* Reset takes Save's same ladder step (-soft/-fill) off the DANGER role, since it discards
|
||||
* unsaved changes; label is --fs-text for the same over-tint reason as Save's below. */
|
||||
.cbi-page-actions .cbi-button-reset {
|
||||
background: var(--fs-danger-soft);
|
||||
border: 1px solid var(--fs-danger-line);
|
||||
@@ -168,12 +127,8 @@
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* There is no `.spinning` padding ladder here, and it must not come back: seven selectors
|
||||
* reserving room for an absolutely-positioned glyph, each climbing a class above whatever else
|
||||
* padded that element. The ladder lost twice in the field (issues #15 and #22), and every loss
|
||||
* looks the same — the spinner draws on top of the label, silently; #22 lost to a 0,5,0
|
||||
* data-table rule. base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, so it reserves its own space. */
|
||||
/* no `.spinning` padding ladder here, and it must not come back — base/95-luci.css puts the
|
||||
* glyph IN THE FLOW instead, reserving its own space (the ladder lost twice: issues #15, #22) */
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
margin-inline-end: var(--fs-space-3); padding: var(--fs-space-2) var(--fs-space-3-5);
|
||||
@@ -183,51 +138,22 @@
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||
|
||||
/* Row-action buttons (Edit / Delete / Start-Stop) in a table or a .cbi-section-actions container.
|
||||
* Compact so tables stay dense: neutral outline by default, with the variants colouring the ones
|
||||
* that carry meaning. The neutral :hover comes FIRST on purpose — last, it greys the coloured
|
||||
* variants back out on hover, which only an !important on their colours hides. Equal
|
||||
* specificity, so a variant hover must be written after it.
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8: a pointer target must be >= 24x24 px, or spaced so 24px circles centred on
|
||||
* adjacent targets do not overlap. At 28px tall with a 1px margin either side the circles DO
|
||||
* overlap, so the exemption does not apply; 32px clears it. */
|
||||
/* row-action buttons (Edit/Delete/Start-Stop): neutral outline, variants colour the ones that
|
||||
* carry meaning. Neutral :hover comes FIRST — after it, it would grey the coloured variants
|
||||
* back out on hover at equal specificity. 32px clears WCAG 2.5.8's 24px target/spacing rule;
|
||||
* 28px did not (adjacent 24px circles overlapped). */
|
||||
.cbi-section-actions .cbi-button, .cbi-section-actions .btn,
|
||||
.cbi-section-table .cbi-button {
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); font-size: var(--fs-type);
|
||||
/* `.td .cbi-button, .td .btn` dropped (task 0151): the action cells this was filed against
|
||||
* (form.TableSection's `.td.cbi-section-table-cell.cbi-section-actions`, per 30-tables.css's
|
||||
* own discriminator) are already reached by `.cbi-section-actions .cbi-button`/`.btn` below —
|
||||
* `.cbi-section-actions` sits on the .td itself, so the descendant selector matches with no
|
||||
* `.td` needed. What the bare `.td` reached and those two do not is every OTHER button in
|
||||
* every OTHER cell: measured collateral, 14 buttons including the icon-only rail drag handle
|
||||
* and a plain-cell "Reserve IP" (Wireless's hand-built `.td.middle`, DHCP leases — neither
|
||||
* carries `.cbi-section-actions`), `overflow: hidden` re-baselining an inline-block and
|
||||
* growing the DHCP leases rows 50px -> 50.84px (10 rows at 1280) for nothing that clipped.
|
||||
* base/95-luci.css sizes each button `flex: 1 1 4em` inside `.td.cbi-section-actions`. A
|
||||
* DEFINITE flex-basis is what let the fix below regress the desktop: with `overflow: hidden`
|
||||
* alone, the automatic minimum for a definite basis resolves to 0 in EVERY row regardless of
|
||||
* room, so `flex-grow` divides by count instead of by need even where nothing needed to
|
||||
* shrink — measured live on Firewall Zones at 1440px, three buttons that read 37.66/97.73/
|
||||
* 89.69px in v0.14.11 converged to 75.02/75.03/75.03px, 11-21px of caption ellipsised for
|
||||
* zero gain (same row total, 225.08px, either way). `flex-basis: auto` is the one-property
|
||||
* fix: it hands sizing back to CONTENT, so the automatic minimum is the caption's own
|
||||
* min-content again and `flex-grow` only ever redistributes genuine slack — reproduced
|
||||
* locally (docs/gallery.html's Startup fixture, unconstrained): auto restores the exact
|
||||
* v0.14.11 widths (101.58/137.72/112.08/230.25px) at 1440, 0px moved, with `overflow: hidden`
|
||||
* left in place. `overflow: hidden` + the ellipsis below still answer the original fault —
|
||||
* a row genuinely narrower than its captions' combined content now shrinks BELOW their
|
||||
* min-content, not before, and the caption gives (ellipsis), never the neighbour's box.
|
||||
* Sweep-measured on real captions at every density: 14-19px on Startup, 12-13px on
|
||||
* "Клонировать", 16-18px on "Перезапустить"/"Перезагрузить", 5px on "Принудительно
|
||||
* завершить" — 0 with the English caption in the same row. `.cbi-page-actions`'s
|
||||
* `flex-wrap: wrap` (above) does not reach these — that selector is the Save/Apply footer,
|
||||
* not a table row, and this is a different container with no line to wrap onto. */
|
||||
/* flex-basis: auto, not a definite basis: with one, the automatic minimum resolves to 0 in
|
||||
* every row and flex-grow divides by count instead of by need — docs/css.md "Buttons:
|
||||
* `flex-basis: auto`, not the definite basis base gave every row action" has the
|
||||
* regression this fixed (task 0151) and the measured widths before/after. */
|
||||
flex-basis: auto;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.td .cbi-button, .cbi-section-actions .cbi-button, .cbi-section-table .cbi-button {
|
||||
background: transparent; background-image: none;
|
||||
background: transparent;
|
||||
border: var(--fs-hairline); color: var(--fs-dim);
|
||||
}
|
||||
.td .cbi-button:hover { border-color: var(--fs-dim); color: var(--fs-text); }
|
||||
@@ -236,24 +162,18 @@
|
||||
.cbi-section-actions .cbi-button-edit, .cbi-section-actions .cbi-button-action {
|
||||
color: var(--fs-accent); border-color: var(--fs-accent-line); background: transparent;
|
||||
}
|
||||
/* THE HOVER INK IS --fs-text, NOT THE ROLE COLOUR, and that is the rule this file already
|
||||
* states twice with measurements: text of colour C on a translucent tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, an AA failure; the alert `.label` chip: 3.4-4.4
|
||||
* across the palettes). The row actions were the last place it survived — the role still reads,
|
||||
* from the fill and the border, which is exactly how Save and Reset carry theirs. */
|
||||
/* hover ink is --fs-text, not the role colour: text of colour C on a tint of C eats its own
|
||||
* contrast (Save: accent on accent-soft = 4.25:1, AA fail) — role reads from fill + border */
|
||||
.td .cbi-button-action:hover, .td .cbi-button-edit:hover, .td .cbi-button-apply:hover,
|
||||
.cbi-section-actions .cbi-button-action:hover {
|
||||
background: var(--fs-accent-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-accent-line-hi);
|
||||
}
|
||||
/* An enabled/positive toggle in a table cell (System -> Startup) rendered as blue text on a
|
||||
* green fill = unreadable. Green outline instead, matching the red-outline negative button. */
|
||||
/* an enabled/positive toggle in a table cell (System -> Startup) rendered blue text on a green fill = unreadable; green outline instead */
|
||||
.td .cbi-button-positive, .cbi-section-actions .cbi-button-positive {
|
||||
color: var(--fs-good); border-color: var(--fs-good-line); background: transparent;
|
||||
}
|
||||
/* …and the border-colour the other two variants already had. Without it the green outline stayed
|
||||
* at -line while action and negative brightened to -line-hi, so hovering the positive toggle
|
||||
* read as less responsive than its neighbours in the same row. */
|
||||
/* without this border-colour the positive toggle read less responsive than its hover neighbours (which brighten to -line-hi) */
|
||||
.td .cbi-button-positive:hover, .cbi-section-actions .cbi-button-positive:hover {
|
||||
background: var(--fs-good-soft); color: var(--fs-text);
|
||||
border-color: var(--fs-good-line-hi);
|
||||
@@ -268,33 +188,23 @@
|
||||
border-color: var(--fs-danger-line-hi);
|
||||
}
|
||||
|
||||
/* Page actions: Save & Apply is THE primary action, Save is demoted so only one button reads
|
||||
* as primary. `--on-color` drives the spinner mask in styles/base. */
|
||||
/* Save & Apply is THE primary action, Save demoted so only one reads primary; --on-color drives the spinner mask in styles/base */
|
||||
.cbi-page-actions .cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown .cbi-button-apply {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--fs-on-accent);
|
||||
--on-color: var(--fs-on-accent);
|
||||
text-shadow: none;
|
||||
}
|
||||
/* Save carries a TINT at rest, not a bare outline: beside the solid Save & Apply a fully
|
||||
* transparent button reads as disabled rather than secondary. The steps come off the role ladder
|
||||
* (02-tokens.css) — -soft (12%) at rest, -fill (18%) on hover, one step up the same ladder.
|
||||
*
|
||||
* The label is --fs-text, not --fs-accent: never put text of colour C on a translucent tint of C
|
||||
* (docs/design-system.md). The tint drags the surface toward the text and eats its own contrast
|
||||
* — accent on accent-soft measures 4.25:1, an AA failure. The role is carried by the fill and
|
||||
* the border.
|
||||
*
|
||||
* `filter: none` cancels the shared hover's --fs-hover-lift: on a translucent tint a brightness
|
||||
* shift washes the fill out instead of deepening it. */
|
||||
/* a TINT at rest, not a bare outline, or Save reads disabled beside the solid Save & Apply;
|
||||
* -soft/-fill off the role ladder. Label --fs-text, not --fs-accent: accent on accent-soft
|
||||
* measures 4.25:1, an AA failure (docs/design-system.md). filter:none cancels the shared
|
||||
* hover-lift, which washes out a translucent tint instead of deepening it. */
|
||||
.cbi-page-actions .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-apply + .cbi-button-save,
|
||||
.cbi-page-actions .cbi-button-negative + .cbi-button-save {
|
||||
background: var(--fs-accent-soft);
|
||||
background-image: none;
|
||||
border: 1px solid var(--fs-accent-line);
|
||||
color: var(--fs-text);
|
||||
--on-color: var(--fs-on-accent);
|
||||
@@ -306,11 +216,10 @@
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Buttons that are part of a compound field widget. */
|
||||
/* buttons that are part of a compound field widget */
|
||||
.cbi-dynlist > .add-item > button,
|
||||
.cbi-dynlist > .add-item > .cbi-button-add {
|
||||
/* min-width was a bare 38px — the same number --fs-ctl-h already states. A restated value
|
||||
* is the drift this ladder exists to prevent, and it also sat out the Density axis. */
|
||||
/* min-width was a bare 38px, the number --fs-ctl-h already states — a restated value is the drift this ladder exists to prevent */
|
||||
flex: 0 0 auto; width: auto; min-width: var(--fs-ctl-h);
|
||||
min-height: var(--fs-ctl-h-sm); padding: 0 var(--fs-space-3); margin: 0;
|
||||
border: 0; border-inline-start: var(--fs-hairline);
|
||||
@@ -326,11 +235,8 @@
|
||||
border-inline-start: var(--fs-hairline);
|
||||
}
|
||||
.control-group:has(> .cbi-input-password) > button.cbi-button-neutral:hover { color: var(--fs-accent); }
|
||||
/* SQUARE, ON THE LADDER: these were the only buttons in the theme set in bare pixels
|
||||
* (40 wide x 34 high), and 34 was the one control height that appears in no scale — the ladder
|
||||
* is --fs-ctl-h-sm/-h/-h-lg = 30/38/44, plus the derived FIELD height (--fs-control-h, 32).
|
||||
* A literal also opts out of the Density axis: every other control shrinks with
|
||||
* --fs-density-box and the pager did not. Square because the glyph is an arrow, not a label. */
|
||||
/* SQUARE, ON THE LADDER: the only buttons once set in bare pixels (40x34, 34 being a height in
|
||||
* no scale) and opted out of the Density axis; square because the glyph is an arrow, not a label */
|
||||
.pager .btn.prev, .pager .btn.next {
|
||||
flex: 0 0 auto; width: var(--fs-ctl-h); min-width: var(--fs-ctl-h); height: var(--fs-ctl-h); padding: 0;
|
||||
border-radius: var(--fs-radius); border: var(--fs-hairline);
|
||||
|
||||
@@ -1,95 +1,46 @@
|
||||
@layer theme {
|
||||
/* Value rows.
|
||||
* display:flex + align-items:baseline come from base — do not restate, and do not switch to
|
||||
* center: a row's field can be taller than its control when a `.cbi-value-description` sits
|
||||
* under it, and center then pushes the label to the middle of control+description, visibly
|
||||
* below the control. baseline lines the label up with the field's first line for every
|
||||
* widget. */
|
||||
/* baseline, not center: a taller field (help sentence under the control) would push a
|
||||
* centered label off the control's first line */
|
||||
.cbi-value { justify-content: space-between; gap: var(--fs-space-4); padding: var(--fs-space-2) 0; margin: 0; border-bottom: var(--fs-hairline); }
|
||||
/* stacked controls in a field (Local Time: date input + Sync buttons) need breathing room —
|
||||
* the raw <br> collapses them. Exclude the password reveal group: it must stay seamless. */
|
||||
/* stacked controls need a gap, a raw <br> collapses it; password-reveal excluded, must stay seamless */
|
||||
.cbi-value-field > .control-group { margin-top: var(--fs-space-2); }
|
||||
.cbi-value-field > .control-group:not(:has(.cbi-input-password)) {
|
||||
display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-2); align-items: center;
|
||||
}
|
||||
/* The hairline separates one value row from the NEXT one, so it is dropped whenever no value row
|
||||
* follows — not only at the end of the section. A section ending with a table or an action bar
|
||||
* otherwise draws the line across the top of whatever comes next, cutting through a framed
|
||||
* table's rounded corner. `:last-child` was the special case of this. */
|
||||
/* hairline separates a row from the NEXT one, so it drops whenever none follows, not only at the section's end */
|
||||
.cbi-value:not(:has(+ .cbi-value)) { border-bottom: 0; }
|
||||
/* A toggle/checkbox field has no text baseline: `.cbi-checkbox` is inline-flex with no line box,
|
||||
* so its synthesised baseline is the box's BOTTOM edge and align-items:baseline floats the
|
||||
* toggle ~5px above the label. Centre those rows instead — but only where no help sentence sits
|
||||
* under the control, since centring then drops the label to the middle of control+description,
|
||||
* the regression the comment above warns about. */
|
||||
/* .cbi-checkbox has no text baseline (synthesised at its bottom edge, floats ~5px above the label) — centred, except with a help sentence under it (see the baseline note above) */
|
||||
.cbi-value:not(:has(.cbi-value-description)):has(> .cbi-value-field > .cbi-checkbox, > .cbi-value-field > div > .cbi-radio) {
|
||||
align-items: center;
|
||||
}
|
||||
/* The generic control transition — absorbed from base's transition group. ONLY the
|
||||
* transition: control typography stays in base on purpose (see the note there — as a
|
||||
* bare-element theme rule it out-layered `.zonebadge-empty`'s smaller font, measured
|
||||
* 10.8 -> 13px on the firewall). border-COLOR, not `border`: the shorthand animates
|
||||
* border-width too — a layout animation on every hover instead of a paint one. */
|
||||
/* absorbed from base; ONLY the transition — typography stays in base (theme/45-misc.css). border-COLOR not the shorthand, which also animates width */
|
||||
input, button {
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
|
||||
/* keep base's padding-top (--fs-space-1) — it is tuned for base's align-items: baseline. */
|
||||
.cbi-value-title {
|
||||
color: var(--fs-dim); font-size: var(--fs-type); font-weight: var(--fs-weight);
|
||||
/* stated, not inherited: the control rule above hands every label `line-height: normal`,
|
||||
* and this one is TEXT — dropping it silently hands the label the font's own default
|
||||
* (measured: 18px -> normal, off the scale entirely). Absorbed from base with the rule
|
||||
* that made it necessary. */
|
||||
/* stated not inherited: this is TEXT, the control rule hands labels line-height: normal (measured 18px -> normal without this) */
|
||||
line-height: var(--fs-leading);
|
||||
}
|
||||
/* Mono is for the VALUES a field holds. The exclusions:
|
||||
* buttons are chrome, not data — excluded here rather than relying on 55-buttons.css being
|
||||
* concatenated later. `:where()` keeps the exclusion at zero specificity, so the <select>
|
||||
* rule below still wins its own font.
|
||||
* .cbi-value-description is LuCI's inline HELP SENTENCE and lives inside .cbi-value-field.
|
||||
* `strong`/`b` — LuCI writes `<strong>MAC:</strong> ac:1f:6b:…`, so the strong is the LABEL
|
||||
* and the text after it the value. Feeding the label to the mono rule makes it mono AND
|
||||
* weight 700, a face of its own: 227 elements over seven pages in bold mono, and 20 KB of a
|
||||
* 600-weight mono face fetched to draw them.
|
||||
* The label rule itself is in theme/45-misc.css and must ASSIGN the sans face: a <strong> merely
|
||||
* excluded here still INHERITS mono from .cbi-value-field — excluding it alone was measured and
|
||||
* changed nothing. */
|
||||
/* mono is for VALUES: buttons, the help sentence and `<strong>` LABELS excluded — a label
|
||||
* fed to mono also goes bold, 227 elements/20 KB of face for nothing. theme/45-misc.css's
|
||||
* label rule must ASSIGN sans; excluding `<strong>` here alone still inherits mono */
|
||||
.cbi-value-field,
|
||||
.cbi-value-field *:where(:not(.btn, .cbi-button, .cbi-value-description)) { font-family: var(--fs-font-mono); }
|
||||
/* text-wrap: the help sentence is a <div>, so base's prose rule (which reaches `p` and the
|
||||
* section/map descriptions) never covered it — and this is the one that wraps most, since it
|
||||
* sits in the field column. Same reasoning as there. */
|
||||
.cbi-value-description { font-family: var(--fs-font-sans); text-wrap: pretty; }
|
||||
|
||||
/* A text field in a CBI form is not a fixed 210px: that width is base's, and the same 210px
|
||||
* holds far less here — the field is monospaced and padded --fs-space-3 a side instead of 4,
|
||||
* so a server URL was clipped mid-domain while the .cbi-dynlist directly under it is 440px
|
||||
* wide (issue #5). Elastic, with the dynlist's own
|
||||
* ceiling so the two line up. Number/date/time keep the fixed width: they hold short values, and
|
||||
* a 440px box for a port number reads as a mistake. */
|
||||
/* not base's fixed 210px: mono + padded --fs-space-3, 210px clipped a URL mid-domain (issue #5); elastic, capped to the dynlist's ceiling */
|
||||
.cbi-value-field input[type="text"],
|
||||
.cbi-value-field input[type="url"],
|
||||
.cbi-value-field input[type="search"] { width: 100%; max-width: var(--fs-field-max); }
|
||||
/* A .cbi-value is designed to hold ONE title+field pair, and base lays it out as a flex row. Some
|
||||
* apps cram several pairs into one .cbi-value, and the flex row then flows them side by side
|
||||
* until the fields overflow the card. Give each title and each field its own full-width row —
|
||||
* the stacked layout base already uses on a phone — scoped by `:has()` to the abnormal
|
||||
* multi-PAIR case only, so every normal single-field row keeps its label-beside-field layout.
|
||||
*
|
||||
* The test is the second TITLE, not the second field, and that is the whole of this rule's
|
||||
* safety: `:has()` is live, so keying on a second field means any app that appends one at
|
||||
* runtime — a status line, an output pane, a spinner holder — flips the row from "label beside
|
||||
* field" to "everything stacked" under the user's finger. Measured: pressing a button in such a
|
||||
* row moved that button 31px down. */
|
||||
/* base assumes ONE title+field pair; stack full-width on a SECOND title (not a second field — a runtime-appended field would re-stack under the user's finger, measured: a button moved 31px) */
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-title,
|
||||
.cbi-value:has(.cbi-value-title ~ .cbi-value-title) > .cbi-value-field {
|
||||
flex: 1 1 100%;
|
||||
margin-inline-start: 0;
|
||||
text-align: start;
|
||||
}
|
||||
/* fields inside a table cell fill it — moved from base with the generic 210px box below:
|
||||
* in base these beat the box in-layer, left behind they would lose to it by layer */
|
||||
.td > input[type="text"],
|
||||
.td > input[type="password"],
|
||||
.td > select,
|
||||
@@ -98,224 +49,95 @@
|
||||
.cbi-section-create > * > input { width: 100%; }
|
||||
|
||||
/* --- live components that bypassed the theme --- */
|
||||
/* A CBI row is a <div class="cbi-value"> on a form and a <td class="cbi-value-field"> in a
|
||||
* section table — same <var>, and the theme had claimed only the table half. */
|
||||
/* a CBI row is <div class="cbi-value"> on a form, <td class="cbi-value-field"> in a table — same <var>, theme had claimed only the table half */
|
||||
div.cbi-value var, .td.cbi-value-field var { color: var(--fs-accent); }
|
||||
.td.cbi-value-field var.cbi-tooltip-container { border-bottom-color: var(--fs-accent); }
|
||||
.cbi-filebrowser { border-color: var(--fs-border); background: var(--fs-panel); border-radius: var(--fs-radius-lg); }
|
||||
.cbi-filebrowser > ul > li:hover { background: var(--fs-panel2); }
|
||||
.cbi-filebrowser .upload { border-top-color: var(--fs-border); }
|
||||
|
||||
/* A file-browser row must hold its own buttons, and the row CLIPS what does not fit rather than
|
||||
* showing it. base sizes the row by proportion (name `flex: 10`, actions `flex: 3`), which is
|
||||
* sound for base's own ~20px buttons in a text-height row; this theme's button is 36px tall with
|
||||
* 14px of side padding and a file row carries up to three of them, so at 23% of the row they do
|
||||
* not fit — and `.cbi-filebrowser > *` is `overflow: hidden`, so a button touches the clip box
|
||||
* on both axes and loses its rounded corners, which reads as a broken button.
|
||||
*
|
||||
* So: size the action column to its CONTENT and let the name give way (a name can ellipsize, a
|
||||
* button cannot), give the children a real gutter, and space the rows. It wraps rather than
|
||||
* clips when even that is not enough. */
|
||||
/* base sizes a row by proportion for its own ~20px buttons; this theme's 36px button (3 per
|
||||
* row) does not fit at 23% and `overflow: hidden` clips the corners — size actions to content,
|
||||
* let the name ellipsize, wrap when that is not enough */
|
||||
.cbi-filebrowser > * { border-bottom-color: var(--fs-border); padding: var(--fs-space-1-5) var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul { display: flex; flex-direction: column; gap: var(--fs-space-1); }
|
||||
.cbi-filebrowser > ul > li { align-items: center; flex-wrap: wrap; gap: var(--fs-space-2); }
|
||||
.cbi-filebrowser > ul > li > div:first-child { flex: 1 1 auto; min-width: 0; }
|
||||
/* `0 1 auto`, not `0 0 auto`: the actions block is three buttons (Deselect / Download / Delete) and
|
||||
* it may WRAP but may not shrink, so on a 320px screen it kept its one-line width and stuck 23px
|
||||
* out of the browser box — measured in the gallery at 320px, where `Delete` ended past the card.
|
||||
* Shrinking is what lets its own `flex-wrap` fire; `min-width: 0` is what lets shrinking reach
|
||||
* below the buttons' min-content, which is the pair every flex item in this theme needs. */
|
||||
/* `0 1 auto`: may wrap but not shrink below min-content, or it sticks out (measured 23px past the box at 320px before min-width: 0) */
|
||||
.cbi-filebrowser > ul > li > div:last-child {
|
||||
flex: 0 1 auto; min-width: 0;
|
||||
display: flex; flex-wrap: wrap; gap: var(--fs-space-2); justify-content: flex-end;
|
||||
}
|
||||
.cbi-filebrowser > ul > li > div:last-child > button { margin: 0; }
|
||||
/* moved from base with the generic 210px box (same layer arithmetic as the .td rules) */
|
||||
.cbi-filebrowser .upload > div > input { width: 100%; }
|
||||
|
||||
/* Form widgets — toggles, file input. Applies to every CBI form. */
|
||||
|
||||
/* A raw checkbox inside a button-styled label sat flush against its text: base zeroes the
|
||||
* checkbox margin (30-forms.css) and only a literal space separated the ✓ from the words
|
||||
* (issue #5.1 — attendedsysupgrade's "Keep settings" confirm: label.btn > input[type=checkbox]
|
||||
* + text). Restore a gap. The toggle-switch .cbi-checkbox below is unaffected — its own
|
||||
* margin: 0 outranks this rule by specificity ((0,2,1) vs this rule's (0,2,1) on a DIFFERENT
|
||||
* ancestor class, so the two never both match one element: `.cbi-checkbox` is never `.btn`). */
|
||||
/* base zeroes checkbox margin; inside a .btn label only a literal space separated ✓ from text
|
||||
* (issue #5.1). toggle switch below unaffected, its own margin:0 wins on a different ancestor */
|
||||
.btn > input[type="checkbox"], .btn > input[type="radio"] { margin-inline-end: .5em; }
|
||||
|
||||
/* checkbox -> toggle switch, drawn on the INPUT itself.
|
||||
*
|
||||
* Was drawn on label[for] with the real input hidden (position:absolute; opacity:0; width/
|
||||
* height:0) — forum.openwrt.org/t/251930 post #141: luci-app-modemdata (and any third-party CSS
|
||||
* built the way bootstrap expects, styling the native input) injects an unlayered
|
||||
* `label[for] { display: none }` flagged important, scoped to its own markup, which left
|
||||
* NOTHING visible, since the input it left alone was the invisible one. Drawing on the input
|
||||
* means an app that hides label[for] hides an already-empty placeholder, not the switch.
|
||||
*
|
||||
* The pill, the knob and the knob's travel are all on --fs-density-box. As bare literals they
|
||||
* make LuCI's most common control the only one in the theme that ignores the Density axis, while
|
||||
* every button and field around it moves: measured, the switch held 40x22 at Compact and at
|
||||
* Large while the fields beside it went 38 -> 32.3 -> 43.7. The switch is a box that holds a
|
||||
* knob, which is the
|
||||
* box tier by definition. Local names rather than new global tokens: one component needs one
|
||||
* number and three things derived from it.
|
||||
*
|
||||
* flex-shrink: 0 below (task 0145): `.cbi-checkbox` is the flex row, and a caption after the
|
||||
* pill — `label.cbi-checkbox > input + label[for] + TEXT`, package-manager.js's and banip's real
|
||||
* markup — makes the input (now the real, sized flex item; label[for] no longer is one, below) a
|
||||
* SECOND, shrinkable flex item. Unshrunk it stays exactly --sw-w; measured shrunk on the live
|
||||
* Install dialog on both stands when this box was still label[for]'s (task 0145): RU/Normal/390px
|
||||
* gave a used width of 30.17px against 40 declared and a knob 6.83px past the pill's right edge,
|
||||
* EN/Large/360px 36.22 against 46 declared and a 6.47px overhang — flex-shrink: 0 alone took both
|
||||
* to -3.00px and -3.31px (the knob safely inside the pill, not flush with it). The flex row and
|
||||
* its sibling caption are unchanged by which element carries the pill, so the guard carries over.
|
||||
*
|
||||
* label[for] stays in the DOM (ui.js and every third-party app that builds this markup emit it)
|
||||
* but draws and occupies nothing: `[for]` still discriminates it from the SECOND label
|
||||
* Checkbox.render() appends when `tooltip` is set (form.Flag.tooltip — e.g. luci-app-firewall's
|
||||
* zones.js on `masq`), `label.cbi-tooltip-container` holding the hint icon, which must keep
|
||||
* rendering. */
|
||||
/* `overflow-wrap: anywhere`: the caption after the pill is a plain text flex item, which already
|
||||
* wraps at spaces on its own — this is only for the one WORD that does not fit even at the row's
|
||||
* own full width. Measured on this exact caption (see the pill's own comment below): pseudo-loc
|
||||
* grows "неиспользуемые" (14 letters) to 20, wide enough on its own to push the whole row 24.6px
|
||||
* past `.cbi-value-field` at 320px/Large with no scroller — a real Russian compound this length
|
||||
* is not implausible (the theme already ships an untranslated one), so the row should absorb it
|
||||
* by breaking the word, not by escaping its column. */
|
||||
/* checkbox -> toggle switch, drawn on the INPUT — docs/css.md "Inputs: the toggle switch,
|
||||
* drawn on the input" has the forum regression this replaced. density-scaled local tokens. */
|
||||
/* overflow-wrap: anywhere for the one caption word too long even at full width (pseudo-loc: a
|
||||
* 14-letter Russian compound grown to 20 pushed the row 24.6px past the field at 320px/Large) */
|
||||
.cbi-checkbox { display: inline-flex; align-items: center; gap: var(--fs-space-2); overflow-wrap: anywhere; }
|
||||
/* label[for] takes over the input's old trick (position:absolute, zero box, opacity:0): out of
|
||||
* flow and out of the flex item count, so `.cbi-checkbox`'s `gap` still opens on only the SAME
|
||||
* two neighbours it always did — see the tooltip-container note below. */
|
||||
/* label[for] stays in the DOM but draws nothing: out of flow, out of the flex item count, so gap opens only on the switch input + an optional caption */
|
||||
.cbi-checkbox > label[for] { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||
.cbi-checkbox > input[type="checkbox"] {
|
||||
--sw-w: calc(40px * var(--fs-density-box));
|
||||
--sw-h: calc(22px * var(--fs-density-box));
|
||||
--sw-knob: calc(16px * var(--fs-density-box));
|
||||
--sw-pad: calc(2px * var(--fs-density-box));
|
||||
/* `appearance: none` and this compound's other unstated properties (color, the invalid
|
||||
* --bd-color/--fg-color custom props) still come from the bare `input[type="checkbox"]`
|
||||
* rule further down, written for a third-party app's raw markup: harmless to share, since
|
||||
* this input never reads --bd-color/--fg-color. The parts of that rule that WOULD paint
|
||||
* over this switch (the drawn box, the mark, the focus ring) are scoped with
|
||||
* `:not(:where(.cbi-checkbox > input))` at their own selector instead of being undone here —
|
||||
* cheaper than restating every property this rule already states for the opposite reason,
|
||||
* and it is one property, not eight, that both rules would otherwise fight over. */
|
||||
/* appearance/--bd-color/--fg-color come from the shared input[type=checkbox] rule below;
|
||||
* flex-shrink: 0 keeps the pill at its width beside a caption (task 0145 has the numbers) */
|
||||
display: inline-block; position: relative; margin: 0; flex-shrink: 0;
|
||||
width: var(--sw-w); height: var(--sw-h); border-radius: var(--fs-radius-pill);
|
||||
background: var(--fs-track); border: var(--fs-hairline);
|
||||
cursor: pointer; transition: background var(--fs-dur), border-color var(--fs-dur); vertical-align: middle;
|
||||
}
|
||||
/* The invisible overlay stretching the hit target to 44x30 (WCAG 2.5.8: the pill is 22px tall,
|
||||
* under the 24px floor) — moved here from label[for] unchanged. position:absolute, so it adds
|
||||
* nothing to layout; the knob keeps ::after.
|
||||
*
|
||||
* `content`/`position` come from the raw checkbox's own shared `::before, ::after { position:
|
||||
* absolute; content: "" }` rule further down — left UNSCOPED and reused here rather than
|
||||
* restated, since both pseudo-elements need exactly those two values anyway. The two rules
|
||||
* that would otherwise paint a drawn box and a mark ON this pseudo (their own selectors,
|
||||
* further down) are scoped with `:not(:where(.cbi-checkbox > input))` instead, so nothing is left to
|
||||
* override here.
|
||||
*
|
||||
* The inset stays literal while the pill above scales: this is an accessibility floor, and a
|
||||
* floor that shrinks with a density setting is not one. At Compact the target is still 38x26.7,
|
||||
* over the minimum. */
|
||||
/* hit-target overlay to 44x30 (WCAG 2.5.8 floor 24px, pill is 22px); inset stays literal, not
|
||||
* density-scaled — a floor that shrinks with Density is not one */
|
||||
.cbi-checkbox > input[type="checkbox"]::before { inset: -4px -2px; }
|
||||
.cbi-checkbox > input[type="checkbox"]::after {
|
||||
top: var(--sw-pad); inset-inline-start: var(--sw-pad);
|
||||
width: var(--sw-knob); height: var(--sw-knob); border-radius: 50%;
|
||||
background: var(--fs-dim);
|
||||
/* transform, not `left`: `left` is a layout property, so the knob re-laid out the row on
|
||||
* every frame of the slide. translateX is composited. */
|
||||
/* transform not `left`: left re-lays out the row every frame, translateX is composited */
|
||||
transform: translateX(0);
|
||||
transition: transform var(--fs-dur-move), background var(--fs-dur);
|
||||
}
|
||||
/* The tooltip-container label (above) is unstyled by the switch rules and falls back to the
|
||||
* browser's plain inline label plus base's `cursor: help` — the icon glyph, no pill. It still
|
||||
* needs a gap from the switch, and `.cbi-checkbox`'s own `gap` above gives it one instead of a
|
||||
* margin declared here: label[for] inside that flex row is `position: absolute` (above), so it
|
||||
* is never a flex item, and `gap` only ever opens BETWEEN flex items — an ordinary `.cbi-checkbox`
|
||||
* with just the switch input has one flex item (the input itself) and gets no gap at all, while
|
||||
* the tooltip fixture's input+tooltip-label pair gets exactly one. --fs-space-2 (8px), same
|
||||
* number the removed margin used — the gap the theme already uses beside an inline checkbox
|
||||
* glyph (`.btn > input[type="checkbox"]` above). The flex-item COUNT and the gap token are the
|
||||
* same as before this rule moved from label[for] to the input; only which element is the
|
||||
* absolute one swapped. */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked { background: var(--fs-accent); border-color: transparent; }
|
||||
/* The travel is derived rather than hand-written, so it cannot drift out of step with the three
|
||||
* sizes above. The `- 2px` is the pill's two 1px BORDERS and is the whole subtlety: --sw-w is a
|
||||
* border-box width, while an absolutely-positioned ::after is placed against the PADDING box, so
|
||||
* the knob has 2px less room and deriving without that term lands it off-centre — the travel
|
||||
* comes out 20px where symmetry wants 18. The border does not scale with density, so the term
|
||||
* stays literal. */
|
||||
/* derived so it cannot drift from the sizes above; -2px is the pill's two 1px borders (the knob sits against the padding box, so it has 2px less room — travel is 18px not 20) */
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after {
|
||||
transform: translateX(calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2 - 2px));
|
||||
background: var(--fs-on-accent);
|
||||
}
|
||||
/* The bare fallback ring further down (`input:focus`, for every untyped/unlisted input, and its
|
||||
* `input[type="checkbox"]:focus::before` twin) is scoped with `:not(:where(.cbi-checkbox > input))` at
|
||||
* its own selector, so a plain click on this real, visible input rings nothing — as it never
|
||||
* did before this input was the drawn element, since only :focus-visible below is meant to. */
|
||||
/* The <input> IS the switch now, so the ring goes there directly, or the most common control in
|
||||
* LuCI shows nothing under keyboard focus.
|
||||
*
|
||||
* -solo, because on the CHECKED switch — half the switches on any page — neither other channel
|
||||
* exists: the rule above fills the track with --fs-accent and sets `border-color: transparent`,
|
||||
* so the border flip is accent on accent (measured: fill rgb(86,157,245) against a border of the
|
||||
* same accent at .97 alpha) and the tinted ring is accent at .15 alpha. */
|
||||
/* the input IS the switch, ring goes here; -solo: checked switch already spends its border channel on accent, only the tinted shadow shows */
|
||||
.cbi-checkbox > input[type="checkbox"]:focus-visible {
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring-solo);
|
||||
}
|
||||
.cbi-checkbox > input[type="checkbox"]:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Raw checkbox/radio — the drawing, absorbed from base whole: it undoes the generic `input` box
|
||||
* below, and left in base every one of those overrides would lose to that rule by layer. The box
|
||||
* is 16px on the grid and the tick or dot is inset FROM it rather than restating its own number,
|
||||
* so resizing the box cannot leave the mark off-centre. LuCI's own forms wrap checkboxes in the
|
||||
* .cbi-checkbox toggle above; the raw drawing is what a third-party app's bare markup gets.
|
||||
*
|
||||
* `.cbi-checkbox > input` matches the switch's own input too: this base rule stays unscoped for
|
||||
* it, since the switch's own rules above want its `appearance: none` and read neither
|
||||
* --bd-color nor --fg-color. But the drawn box, the mark and the focus ring below DO paint over
|
||||
* the switch once its input is a real, visible element rather than 0x0 — each of those three
|
||||
* selectors carries `:not(:where(.cbi-checkbox > input))` for that reason alone; undoing them instead,
|
||||
* property by property, on the switch's own rules costs more than the one exclusion clause
|
||||
* each needs. */
|
||||
/* raw checkbox/radio, absorbed from base: undoes the generic input box below. 16px box on the
|
||||
* grid, mark inset FROM it. `.cbi-checkbox > input` also matches the switch's own input
|
||||
* (unscoped, it wants this rule's appearance:none) — selectors that WOULD paint the switch
|
||||
* exclude it individually below instead. */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
--bd-color: var(--fs-border);
|
||||
--fg-color: var(--fs-text);
|
||||
/* 16px on the grid, but on the BOX tier, not the space tier. `var(--fs-space-4)` was the
|
||||
* same 16px at Normal and the wrong multiplier everywhere else: space moves to .65 at
|
||||
* Compact and stays 1 at Large, so the tick box went to 10.4px (smaller than any other
|
||||
* control on the page — measured) and then refused to grow at Large. A checkbox is a box
|
||||
* that has to HOLD an icon, which is what --fs-density-box is defined for. */
|
||||
/* box tier not space tier: --fs-space-4 gave 16px at Normal but 10.4px at Compact (smaller than any control) and never grew at Large */
|
||||
--box: calc(16px * var(--fs-density-box));
|
||||
--mark-inset: 3px;
|
||||
/* ---- the drawing is 16px; the target is 24px ----
|
||||
*
|
||||
* WCAG 2.2 SC 2.5.8 lets an undersized target pass on SPACING — 24px centre to centre — which
|
||||
* is what a 16px tick box has always relied on. That holds while the theme decides the layout
|
||||
* and stops the moment a third-party app lays out its own rows, putting this checkbox and a
|
||||
* link inside the 24px. A target that passes only because of somebody else's markup is not a
|
||||
* target that passes.
|
||||
*
|
||||
* So the ELEMENT is 24px and the drawing stays 16px, centred inside it: the two pseudo
|
||||
* elements are already absolutely positioned, so centring costs `inset: 0; margin: auto`.
|
||||
* Nothing moves visually — the box, the tick and the focus ring are all drawn by the pseudo
|
||||
* elements, and the element itself has neither background nor border.
|
||||
*
|
||||
* `max()`, not a flat 24px: the Large density scales `--box` to 18.4px, and a target must
|
||||
* never SHRINK when the user asks for a bigger UI. */
|
||||
/* WCAG 2.5.8's 24px spacing floor holds only while the theme owns the layout; ELEMENT is
|
||||
* 24px (drawing stays 16px, centred), max() so it never shrinks under --box at Large */
|
||||
--hit: max(24px, var(--box));
|
||||
appearance: none;
|
||||
width: var(--hit);
|
||||
height: var(--hit);
|
||||
/* …and the target does not cost a row its height: the element is 24px but occupies its old
|
||||
* `--box` in layout, the negative margin pulling the extra area back out of the flow so the
|
||||
* box overflows itself symmetrically. Measured on a third-party listing: rows went 42px ->
|
||||
* 51px with the target simply grown, and 43px with the margin, the target still answering to
|
||||
* a hit test at its centre. */
|
||||
/* negative margin pulls the extra hit area back out of flow (without it, rows measured 42px -> 51px) */
|
||||
margin: calc((var(--box) - var(--hit)) / 2);
|
||||
color: var(--fg-color);
|
||||
position: relative;
|
||||
@@ -323,9 +145,7 @@
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
/* padding/border-radius are NOT reset: the generic input box's values land on an
|
||||
* appearance-none 16px box whose pseudo-elements are absolute — invisible, and what the
|
||||
* widget has always computed */
|
||||
/* padding/border-radius not reset: land on an appearance-none box whose pseudo-elements are absolute, invisible either way */
|
||||
}
|
||||
input[type="checkbox"]::before,
|
||||
input[type="checkbox"]::after,
|
||||
@@ -336,10 +156,7 @@
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::before,
|
||||
input[type="radio"]::before {
|
||||
/* centred in the 24px target, and direction-agnostic: `inset: 0; margin: auto` on a box with
|
||||
* a fixed size is the one centring that needs no translate and therefore behaves the same in
|
||||
* RTL, where an `inset-inline-start: 50%` + `translate: -50%` pair would push the drawing the
|
||||
* wrong way. */
|
||||
/* inset:0; margin:auto centres a fixed box the same in RTL as LTR; a translate pair would not */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: var(--box);
|
||||
@@ -348,22 +165,15 @@
|
||||
border: 1px solid var(--bd-color);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* each box states its own radius — one shared 2px re-declared to 50% for the radio would
|
||||
* be a same-selector shadow inside this layer */
|
||||
input[type="checkbox"]::before { border-radius: 2px; }
|
||||
input[type="radio"],
|
||||
input[type="radio"]::before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* keyboard/click focus rings on the DRAWN box — the theme's one ring token, replacing
|
||||
* base's old generic glow; :focus (not :focus-visible) keeps the pre-absorption behaviour
|
||||
* of ringing a clicked box. Both states (plain and invalid) are declared once, further
|
||||
* down this file, in the selector lists every other same-body ring joins — see the two
|
||||
* `--fs-focus-ring`/`--fs-focus-ring-invalid` rules below the text-input group. */
|
||||
/* keyboard/click focus ring on the DRAWN box, the theme's one ring token */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input))::after,
|
||||
input[type="radio"]::after {
|
||||
/* the mark is inset from the DRAWING, and the drawing is centred in the target, so the mark
|
||||
* centres the same way rather than measuring from the target's own edge */
|
||||
/* mark inset from the DRAWING, which is centred in the target, not from the target's own edge */
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: calc(var(--box) - var(--mark-inset) * 2);
|
||||
@@ -371,19 +181,14 @@
|
||||
}
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):checked::after,
|
||||
input[type="radio"]:checked::after {
|
||||
/* OURS. Drawn as a STROKE, which is this theme's icon language (see --spinner-icon in
|
||||
* base/95-luci.css and --fs-icon-refresh in 02-tokens.css: fill:none, round caps, a 24 grid).
|
||||
* It replaces a solid filled path that was byte-identical to luci-theme-bootstrap's — and to
|
||||
* luci-theme-openwrt-2020's, which is the same project's artwork, not evidence the drawing is
|
||||
* generic. A mask keys on ALPHA, so a stroked path masks exactly as a filled one did. */
|
||||
/* OURS: stroked path (this theme's icon language), replacing a fill byte-identical to luci-theme-bootstrap's; mask keys on alpha so a stroke masks like a fill did */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'><path d='M4.5 12.5 9.5 17.5 19.5 6.5'/></svg>");
|
||||
-webkit-mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
mask: var(--checkmark-icon) center/cover no-repeat;
|
||||
background: var(--fg-color);
|
||||
}
|
||||
input[type="radio"]:checked:after {
|
||||
/* the radio's dot — filled, because a ring would read as "not selected". OURS: our grid,
|
||||
* our radius (the fill is the one shape a stroke cannot express). */
|
||||
/* radio dot filled, not ringed — a ring reads as "not selected" */
|
||||
--checkmark-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='6.5' fill='%23000'/></svg>");
|
||||
}
|
||||
input[type="checkbox"].cbi-input-invalid,
|
||||
@@ -392,12 +197,7 @@
|
||||
--fg-color: var(--fs-danger);
|
||||
}
|
||||
|
||||
/* The generic field box, absorbed from base's `input, textarea, select, …` group; each other
|
||||
* member lives with its component rule now. Bare `input` keeps the historical 210px box as the
|
||||
* fallback for every type the exhaustive text-input list below does not name — and for a
|
||||
* typeless <input>, which IS a text input. The widget types that must NOT wear this box carry
|
||||
* their own theme rules: a base override would lose to this rule by layer whatever its
|
||||
* specificity. */
|
||||
/* generic field box, absorbed from base; bare input keeps the historical 210px fallback for types the list below does not name */
|
||||
input {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
@@ -408,73 +208,43 @@
|
||||
border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius);
|
||||
}
|
||||
/* base's old `#localtime { width: 300px }` media rule was NOT moved here: the elastic
|
||||
* .cbi-value-field width above already out-layered it (measured on the live router: the
|
||||
* field rendered 440px, not 300) — the rule was dead, and a dead rule is deleted, not
|
||||
* relocated */
|
||||
/* base's old #localtime{width:300px} media rule not moved here: the elastic field width above already out-layers it (measured 440px, not 300) */
|
||||
|
||||
/* native file input — the `initial`/auto resets are absorbed from base: the generic box
|
||||
* above would otherwise dress the native chooser in a 210px bordered field */
|
||||
input[type="file"] {
|
||||
color: var(--fs-dim); font-size: var(--fs-type);
|
||||
padding: initial;
|
||||
border: initial;
|
||||
line-height: initial;
|
||||
box-shadow: none;
|
||||
/* no !important: this out-specifies the generic `input { width: 210px }` on its own.
|
||||
* height/background are NOT reset: the chooser keeps the generic box's control height
|
||||
* and panel tint, as it always has. */
|
||||
/* no !important: width:auto out-specifies the generic input{width:210px} alone */
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* native <select> — custom chevron + theme (matching the text inputs). The chevron data-URI
|
||||
* lives in 03-palettes.css per palette/mode block, because a URI cannot read var(); it
|
||||
* replaced a hardcoded grey that ignored palette and dark mode. */
|
||||
/* native <select> chevron + theme; the data-URI lives in 03-palettes.css per palette/mode block since a URI cannot read var() */
|
||||
select, select.cbi-input-select, .cbi-value-field select {
|
||||
/* unprefixed only: the floor (Chrome 108/Firefox 101/Safari 15.4) clears unprefixed
|
||||
* `appearance` everywhere (Chrome 84, Firefox 80, Safari 15.4 — docs/css.md). -webkit- was
|
||||
* 96 B across these four rules for nothing this sheet still has to run on. */
|
||||
/* unprefixed only, floor clears it everywhere — docs/css.md "The browser floor" */
|
||||
appearance: none;
|
||||
background-color: var(--fs-panel2);
|
||||
background-image: var(--fs-select-chevron);
|
||||
/* right inset from the scale, not a literal 12 — the .cbi-select shell below states the same
|
||||
* position and two spellings of one number is how they would drift apart */
|
||||
/* two 6px bands forming the V, the far one a band-width further in; --fs-space-3 stays the
|
||||
* single spelling of the inset, as the note above says */
|
||||
/* right inset is the scale step, not a literal 12 — the .cbi-select shell below repeats the same one spelling */
|
||||
background-repeat: no-repeat; background-size: 6px 6px;
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
padding: var(--fs-space-2) var(--fs-space-8) var(--fs-space-2) var(--fs-space-3);
|
||||
border: var(--fs-hairline); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); font-family: var(--fs-font-sans); font-size: var(--fs-type);
|
||||
line-height: 1.3; min-height: var(--fs-ctl-h); cursor: pointer;
|
||||
/* width/display absorbed from base's generic field box: a select's natural width is its
|
||||
* longest option, so without the fixed box two selects in one form column ragged-edge;
|
||||
* display stated because Chromium's base-select UA default is inline-flex, not the
|
||||
* inline-block the widget has always computed */
|
||||
width: 210px;
|
||||
display: inline-block;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* group-then-refine, directly under its group: the listbox variant must NOT take the closed
|
||||
* control's one-line height. A SURFACE token, not an ink one: this once read --fs-on-accent
|
||||
* (the ink meant to go ON an accent fill), which resolves to plain white and painted a white
|
||||
* listbox in dark mode. `height: inherit` absorbed from base with the width above — without
|
||||
* it the fixed box would cap a multi-row listbox at one line. */
|
||||
/* height:inherit so the multiple/listbox variant does not take the closed control's one-line height */
|
||||
select[multiple] { background-color: var(--fs-panel2); color: var(--fs-text); height: inherit; }
|
||||
/* :focus joins the shared accent ring in the text-input group below. */
|
||||
/* absorbed from base's reset: 100% (= inherit) keeps the option on its select's size, and
|
||||
* only <option> needs the 2em leading — the other controls carry line-height: normal above */
|
||||
option { font-size: 100%; line-height: 2em; }
|
||||
select option { background: var(--fs-panel); color: var(--fs-text); }
|
||||
|
||||
/* Native <select> drop list: the OS renders it, so the `select option` colours above are ignored
|
||||
* on most platforms. Chromium's customisable-select (`appearance: base-select`) renders the
|
||||
* picker in the DOM, so it can be themed like the .cbi-dropdown menu; @supports-gated, and
|
||||
* browsers without it keep the styled closed control plus the native list.
|
||||
*
|
||||
* Scoped to `.cbi-value-field select` only: a third-party app can mount a raw <select> outside
|
||||
* the CBI form and drive it its own way, and early Chrome builds mis-render such a select as a
|
||||
* single-item list. Those sit outside .cbi-value-field, so this scope leaves them native. */
|
||||
/* the OS renders the native list, so `select option` above is mostly ignored; Chromium's
|
||||
* base-select renders it in the DOM instead, themeable like .cbi-dropdown; @supports-gated,
|
||||
* scoped to .cbi-value-field so a third-party app's raw <select> stays native */
|
||||
@supports (appearance: base-select) {
|
||||
.cbi-value-field select { appearance: base-select; }
|
||||
.cbi-value-field select::picker-icon { display: none; }
|
||||
@@ -486,16 +256,12 @@
|
||||
}
|
||||
.cbi-value-field select option {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); border-radius: var(--fs-radius-sm); font-size: var(--fs-type);
|
||||
/* no `color` here: `select option` above already sets --fs-text, and this restated it
|
||||
* (switched off against 174 options, nothing moved). `background: transparent` is NOT the
|
||||
* same — it deliberately undoes that rule's --fs-panel so the row's own hover shows. */
|
||||
/* background:transparent undoes `select option`'s --fs-panel so the row's own hover shows */
|
||||
background: transparent; min-height: 0;
|
||||
}
|
||||
.cbi-value-field select option::checkmark { display: none; }
|
||||
.cbi-value-field select option:hover, .cbi-value-field select option:focus-visible { background: var(--fs-panel2); }
|
||||
/* Opaque surface + inset rail, same shape as the open .cbi-dropdown's selected row
|
||||
* (theme/65-dropdown.css): accent text on --fs-accent-soft measured 4.21:1 in dark,
|
||||
* under AA — the tint of the accent eats its own contrast. */
|
||||
/* same shape as the open .cbi-dropdown's selected row (theme/65-dropdown.css): accent on --fs-accent-soft measures 4.21:1 in dark, under AA */
|
||||
.cbi-value-field select option:checked {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
@@ -503,25 +269,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* .cbi-select — the legacy luci-compat shell (a div around a native <select>), absorbed from base
|
||||
* whole: the generic field box, the shell's own paint and a focus ring on the theme's token. The
|
||||
* shell is a DIV, so display and width must be stated. `:not(select)` because the class also
|
||||
* appears ON a bare <select>: a replaced element cannot draw the ::before chevron, so shell
|
||||
* paint there means a gradient with no glyph. */
|
||||
/* It paints like the themed <select>, being the same control in an older shell — never a
|
||||
* skeuomorphic surface of its own: a gradient from panel to border is lighter at the bottom in
|
||||
* the dark palettes, and nothing else in the theme has a gradient at all. The geometry below is
|
||||
* the `select` rule above, property for property, so a legacy shell and a modern select line up
|
||||
* in one form column. This is the non-JS fallback: the people who see it already have something
|
||||
* broken. */
|
||||
/* .cbi-select — legacy luci-compat shell around a native <select>, matching the themed select
|
||||
* property for property. :not(select): the class also appears on a bare <select>, which cannot draw the ::before chevron */
|
||||
.cbi-select:not(select) {
|
||||
display: inline-block;
|
||||
width: 210px;
|
||||
/* `height`, NOT `min-height` — the inner select fills the shell with `height: 100%`, and a
|
||||
* percentage against an auto-height parent has no definite containing block to resolve
|
||||
* against, so it falls back to content height: measured, the select collapsed 30px -> 15px
|
||||
* inside a 38px shell, text at the top and half the hit area gone. The themed `select` can
|
||||
* use a floor because it IS the control; a wrapper has to state a height for its child. */
|
||||
/* height not min-height: the child's height:100% falls back to content height against an auto-height parent (measured collapse 30px -> 15px in a 38px shell) */
|
||||
height: var(--fs-ctl-h);
|
||||
color: var(--fs-text);
|
||||
font-size: var(--fs-type);
|
||||
@@ -536,48 +289,31 @@
|
||||
background-position: right calc(var(--fs-space-3) - 1px) center, right calc(var(--fs-space-3) + 5px) center;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* The disabled cue used to live ENTIRELY on the ▾ plate: base/30-forms.css dims
|
||||
* `.cbi-select[disabled]::before` and never the shell itself. With the plate gone the state had
|
||||
* no visual at all, so the shell carries it now. */
|
||||
/* base/30-forms.css only dims the old ▾ plate; with the plate gone the disabled state had no visual, so the shell carries it now */
|
||||
.cbi-select[disabled]:not(select) {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* .focus and .cbi-input-invalid.focus join the shared accent/danger ring rules further down
|
||||
* this file — same two bodies as every other :focus/:focus-within ring on this token. */
|
||||
/* the native <select> inside the shell: invisible, filling it, clearing the ▾ plate on the
|
||||
* right. The inset is a scale step; the right margin is the plate's width, so it stays
|
||||
* em-relative to the glyph it clears. Every themed-select declaration above (chevron,
|
||||
* padding, min-height) must be answered here, or the inner select paints like a free-standing
|
||||
* one inside the 32px shell. */
|
||||
/* invisible, filling the shell, clearing the plate by the same --fs-space-8 inset the themed select uses */
|
||||
.cbi-select select,
|
||||
.cbi-select select:focus {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
/* clears the CHEVRON, which the shell now paints on its own background — the same
|
||||
* --fs-space-8 inset the themed select uses. `margin-right: .6em` used to clear the ▾ plate
|
||||
* instead, and an em-relative margin against a px-positioned glyph is how the two drifted. */
|
||||
padding: 0 var(--fs-space-8) 0 var(--fs-space-3);
|
||||
font-size: var(--fs-type);
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* spinner / loading */
|
||||
.spinning, .cbi-value em, em.spinning { color: var(--fs-dim); }
|
||||
|
||||
/* Text inputs and other field widgets, rounded and themed to match the selects and dropdowns
|
||||
* (base leaves them at radius 3 and white). Excludes checkbox/radio and file, handled above.
|
||||
*
|
||||
* The type list must be EXHAUSTIVE: an omission does not fall back to "unstyled", it falls back
|
||||
* to BASE, which still paints a stock white 3px-radius field beside a themed one. */
|
||||
/* type list must be EXHAUSTIVE: an omission falls back to BASE's stock field beside a themed one, not to "unstyled" */
|
||||
.cbi-input-text, .cbi-input-password, .cbi-input-textarea,
|
||||
.cbi-section-create-name, .cbi-value-field input.create-item-input,
|
||||
input[type="text"], input[type="password"], input[type="number"],
|
||||
@@ -595,25 +331,9 @@
|
||||
box-shadow: none;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* width/font-size absorbed from base's generic field box, same numbers — a textarea has
|
||||
* always sat at the fixed 210px (the elastic rule above names only text/url/search) */
|
||||
/* `max-width: 100%` beside the width, because a textarea is the one field whose intrinsic width
|
||||
* comes from an ATTRIBUTE (`cols`, default 20) rather than from the box: measured at a 300px
|
||||
* viewport, two textareas in the gallery stood 19px outside their card while every other control
|
||||
* in the same column fitted. The 210px is the designed width and still applies wherever there is
|
||||
* room for it. */
|
||||
/* max-width:100% beside the fixed 210px: a textarea's intrinsic width comes from `cols` (default 20), which put two textareas 19px outside their card at 300px before this */
|
||||
textarea { min-height: 84px; line-height: var(--fs-leading); width: 210px; max-width: 100%; font-size: var(--fs-type); }
|
||||
/* A textarea's native scrollbar is a square strip and a rounded corner does not clip it, so on a
|
||||
* scrolling config field the grey bar pokes past the radius as a square notch. Fix: a slim
|
||||
* self-rounded thumb floated off the edges (transparent border plus background-clip:
|
||||
* padding-box), a transparent track, and a track margin keeping the thumb clear of the corners.
|
||||
*
|
||||
* Deliberately no `scrollbar-width`/`scrollbar-color`: either makes Chromium switch to the
|
||||
* standard scrollbar and ignore this whole ::-webkit block — and that standard bar is the
|
||||
* square, unclipped one. Firefox has no ::-webkit and clips natively.
|
||||
*
|
||||
* 16px wide so ::-webkit-resizer, sized off the scrollbar, gets a bigger grab area; the 5px
|
||||
* transparent thumb border still leaves it 6px visible. */
|
||||
/* a square native scrollbar is not clipped by a rounded corner; self-rounded thumb + transparent track fixes it. no scrollbar-width/-color: either switches Chromium to the square standard bar */
|
||||
textarea::-webkit-scrollbar { width: 16px; height: 16px; }
|
||||
textarea::-webkit-scrollbar-track { background: transparent; margin: var(--fs-space-4) 0 var(--fs-space-2-5); }
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
@@ -621,19 +341,9 @@
|
||||
border: 5px solid transparent; background-clip: padding-box;
|
||||
}
|
||||
textarea::-webkit-scrollbar-thumb:hover { background: var(--fs-text); }
|
||||
/* The bottom-right square where the vertical bar meets the resize grip defaulted to white
|
||||
* and poked past the rounded corner — transparent, so the frame shows. */
|
||||
textarea::-webkit-scrollbar-corner { background: transparent; }
|
||||
/* The resize grip highlights the frame's bottom-right corner — an accent arc tracing the frame's
|
||||
* own radius, with no straight legs. Two borders plus the corner radius draw the arc, and the
|
||||
* legs vanish because the box is shrunk to exactly the radius: the resizer is the scrollbar's
|
||||
* 16px, so a `16px − radius` margin leaves a radius-sized box whose right and bottom border IS
|
||||
* the quarter curve. (A mask would give a shorter arc, but ::-webkit-resizer ignores mask, and a
|
||||
* baked-in SVG colour would not follow the palette.) */
|
||||
/* PHYSICAL on purpose, and mirrored by hand below: ::-webkit-resizer is a UA-drawn corner whose
|
||||
* side follows the writing direction, and it does not accept logical properties — with only the
|
||||
* LTR rule the arc drew on the wrong corner in an RTL document (a stray quarter-circle on the
|
||||
* left of every textarea). */
|
||||
/* accent arc traces the frame's own radius (two borders shrunk to 16px - radius); mirrored by
|
||||
* hand below, ::-webkit-resizer ignores logical properties */
|
||||
textarea::-webkit-resizer {
|
||||
background: transparent;
|
||||
border-right: 2px solid var(--fs-accent);
|
||||
@@ -652,14 +362,7 @@
|
||||
input[type="email"]:hover, input[type="url"]:hover, input[type="search"]:hover,
|
||||
textarea:hover { border-color: var(--fs-dim); }
|
||||
|
||||
/* bare `input:focus` absorbed from base's generic glow onto the same ring: it is the fallback
|
||||
* for the types the list does not name and for a typeless <input>; on a bare checkbox/radio it
|
||||
* lands on the drawn box UNDER the ::before ring below — coincident boxes, one visible ring, as
|
||||
* the old glow behaved. `:not(:where(.cbi-checkbox > input))` excludes the toggle switch, whose input is
|
||||
* a real, visible element with its own :focus-visible-only ring (above) — a plain click on it
|
||||
* must ring nothing. `select:focus` and the keyboard-removable dynlist chip (ui.js gives it a
|
||||
* tabindex) join here rather than repeating the same three declarations under their own
|
||||
* selector: one shared body instead of three, 149 B off cascade.css. */
|
||||
/* fallback ring for untyped inputs; excludes the toggle switch, whose :focus-visible-only ring must not double up */
|
||||
.cbi-input-text:focus, .cbi-input-password:focus, .cbi-input-textarea:focus,
|
||||
.cbi-section-create-name:focus,
|
||||
input:focus:not(:where(.cbi-checkbox > input)),
|
||||
@@ -673,14 +376,7 @@
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* The plain accent ring above, restated for four components whose selector cannot join that
|
||||
* list without changing what it matches (a pseudo-element, a legacy shell class, two
|
||||
* `:focus-within` containers) — same two declarations, one shared body instead of four, 186 B
|
||||
* off cascade.css. `:is()` around the `:has()` compound keeps the list forgiving: unwrapped, a
|
||||
* `:has()` compound sharing a selector list with plain ones fails the WHOLE rule below the
|
||||
* floor (Firefox < 121; docs/css.md "Two shapes fail it"), instead of just losing that one
|
||||
* selector. `:not(:where(.cbi-checkbox > input))` on the checkbox part for the same reason as the plain
|
||||
* ring above: the toggle switch's own :focus-visible rule is the only one allowed to ring it. */
|
||||
/* same ring, shared by 4 selectors that cannot join the list above; :is() around :has() keeps the list forgiving — unwrapped, a :has() compound sharing a list fails the WHOLE rule below the floor */
|
||||
input[type="checkbox"]:not(:where(.cbi-checkbox > input)):focus::before, input[type="radio"]:focus::before,
|
||||
.cbi-select.focus,
|
||||
.cbi-dynlist > .add-item:focus-within,
|
||||
@@ -689,19 +385,8 @@
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* Invalid state keeps the danger cue. `.cbi-value-error input` is the Lua-CBI path and belongs
|
||||
* HERE, not in base: base does declare a red border-color for it, but the rule above sets the
|
||||
* `border` SHORTHAND, and a shorthand in a later layer wipes the longhand out regardless of
|
||||
* specificity — so every third-party app on luci-compat rendered the field in --fs-border grey
|
||||
* and had lost its field-error cue while the modern .cbi-input-invalid path worked.
|
||||
*
|
||||
* `.cbi-input-invalid:focus` is not redundant with the rule it sits beside: the generic
|
||||
* `input:focus` above is (0,1,1) and would repaint a focused invalid field accent, and
|
||||
* `.cbi-input-invalid:focus` at (0,2,0) is what stops it — the TYPED `:focus` rules above are
|
||||
* (0,2,1) and still outrank this, so a typed invalid field focuses accent, exactly as before
|
||||
* the absorption. The checkbox/radio `::before` ring and the legacy `.cbi-select` shell need
|
||||
* the identical danger body for the same reason on their own elements — one shared rule
|
||||
* instead of four, 186 B off cascade.css. */
|
||||
/* .cbi-value-error input (Lua-CBI) must live HERE: base's red border-color is a longhand and
|
||||
* this rule's border shorthand wipes it by layer regardless of specificity */
|
||||
input[type="checkbox"].cbi-input-invalid:focus::before,
|
||||
input[type="radio"].cbi-input-invalid:focus::before,
|
||||
.cbi-select.cbi-input-invalid.focus,
|
||||
@@ -711,35 +396,22 @@
|
||||
border-color: var(--fs-danger);
|
||||
box-shadow: var(--fs-focus-ring-invalid);
|
||||
}
|
||||
/* The same rejected value shown by a ui.Dropdown rather than an <input>: the widget itself and,
|
||||
* when it is closed, the <li> that displays the value. base carried these two and the theme only
|
||||
* the input, so half of one state lived in a layer the theme cannot reach. */
|
||||
/* same rejected value shown by a ui.Dropdown: base carried the widget and its closed <li>, theme only the input half */
|
||||
.cbi-input-invalid.cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-input-invalid.cbi-dropdown:not([open]) > ul > li { border-color: var(--fs-danger); }
|
||||
|
||||
/* readonly keeps its faded border — moved from base with the generic box (its border-color
|
||||
* longhand would lose to the box's `border` shorthand by layer). The doubled [readonly] is
|
||||
* a deliberate specificity step: at (0,1,1) this ties the typed input rules above and the
|
||||
* cascade would rest on file order — in base the same tie made the fade silently LOSE to
|
||||
* the typed border, so a readonly text field never faded at all; (0,2,1) states the intent. */
|
||||
/* doubled [readonly] states intent at (0,2,1): at (0,1,1) it only tied the typed input rules above and silently lost */
|
||||
:is(input, select, textarea)[readonly][readonly] {
|
||||
border-color: color-mix(in srgb, var(--fs-border) calc(var(--fs-disabled-opacity) * 100%), transparent);
|
||||
pointer-events: auto;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* dynamic list: keep base's layout (inline-flex column of chips + one add-item row) — do NOT
|
||||
* set display/flex-wrap here or the add row breaks. Only colours, rounding, a compact +
|
||||
* button, and no wrapping box: capped narrow so the chip's × sits near the text instead of
|
||||
* floating across a full-width field. */
|
||||
/* keep base's layout (inline-flex chips + add-item row); only colours, rounding, a compact + button, narrow cap so a chip's × sits near its text */
|
||||
.cbi-dynlist {
|
||||
gap: var(--fs-space-1-5);
|
||||
background: transparent; border: 0; padding: 0;
|
||||
/* `min(240px, 100%)`, not a bare 240: the floor is there so a chip's × sits near its text
|
||||
* rather than floating across a full-width field, and it becomes a defect the moment the
|
||||
* column is narrower than the floor — measured in the gallery at a 320px viewport, the list
|
||||
* held 240px inside a 210px field and every chip, the add row and its input stood 29px
|
||||
* outside the card. A floor that exceeds the room is not a floor, it is an overflow. */
|
||||
/* min(240px,100%) not a bare 240: a floor that exceeds the room is an overflow (measured 29px outside a 210px field at 320px) */
|
||||
min-height: 0; min-width: min(240px, 100%); max-width: var(--fs-field-max); width: 100%;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -752,11 +424,6 @@
|
||||
margin: 0;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* a chip is keyboard-removable (ui.js gives it a tabindex): :focus joins the shared accent
|
||||
* ring in the text-input group above. */
|
||||
/* The chip's × plate is a BUTTON in base's paint group (`.item::after, .btn, .cbi-button`) —
|
||||
* absorbed with it, bare as base had it (coverage: an .item outside a dynlist keeps its
|
||||
* plate). The machinery — content/position/edges/align — stays in base with the widget. */
|
||||
.item::after {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
@@ -768,37 +435,28 @@
|
||||
border-radius: var(--fs-radius);
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* the hover cue is the lift, not a colour: the plate's fill is a gradient (see
|
||||
* --fs-hover-lift in 02-tokens.css) — replaces base's old focus-glow-on-hover */
|
||||
.item:hover::after { filter: var(--fs-hover-lift); }
|
||||
.cbi-dynlist > .item::after {
|
||||
/* display/padding absorbed from base's machinery rule: the bare plate above says
|
||||
* inline-block / space-3, and by layer it would beat base's refinement */
|
||||
display: inline-flex;
|
||||
padding: 0 var(--fs-space-2);
|
||||
color: var(--fs-danger);
|
||||
border-radius: 0 var(--fs-radius-sm) var(--fs-radius-sm) 0;
|
||||
}
|
||||
/* text add-item = one bordered input group (input + + button attached) */
|
||||
.cbi-dynlist > .add-item {
|
||||
align-items: stretch;
|
||||
background: var(--fs-panel2); border: var(--fs-hairline);
|
||||
border-radius: var(--fs-radius-sm); overflow: hidden;
|
||||
transition: var(--fs-field-transition);
|
||||
}
|
||||
/* :focus-within joins the shared accent ring in the text-input group above. */
|
||||
.cbi-dynlist > .add-item > input,
|
||||
/* `width: auto` keeps the flex basis in charge: this input sits INSIDE .cbi-value-field, so the
|
||||
* elastic width above would otherwise hand it 100% of the field on top of `flex: 1 1 auto`. */
|
||||
/* width:auto keeps the flex basis in charge — this input sits inside .cbi-value-field, the elastic width above would otherwise hand it 100% on top of flex:1 1 auto */
|
||||
.cbi-dynlist > .add-item > .cbi-input-text {
|
||||
flex: 1 1 auto; min-width: 0; width: auto;
|
||||
border: 0; background: transparent;
|
||||
box-shadow: none; min-height: var(--fs-ctl-h-sm); border-radius: 0;
|
||||
}
|
||||
|
||||
/* password reveal: input + ∗ button as ONE seamless rounded group.
|
||||
* `gap: 0` is load-bearing: base gives every .control-group a gap, and this is the one group
|
||||
* whose members must touch — the group draws the single border and clips them. */
|
||||
/* input + button as ONE seamless group; gap:0 is load-bearing — base gives every .control-group a gap, this is the one whose members must touch */
|
||||
.control-group:has(> .cbi-input-password) {
|
||||
display: inline-flex; align-items: stretch; gap: 0;
|
||||
background: var(--fs-panel2);
|
||||
@@ -812,54 +470,15 @@
|
||||
background: transparent; box-shadow: none;
|
||||
min-height: var(--fs-ctl-h);
|
||||
}
|
||||
/* :focus-within (wrapped in :is() for the shared list) joins the accent ring in the
|
||||
* text-input group above. */
|
||||
/* base pulls .cbi-section-create up 3px to hug a table border, which glues the "Add" button
|
||||
* to the last row — give it a real gap instead. */
|
||||
.cbi-section-create, .cbi-section-create.cbi-tblsection-create { margin-top: var(--fs-space-3); }
|
||||
|
||||
/* `.hidden` must win over every component rule in this layer. An enumerated list
|
||||
* (`.cbi-value.hidden`, `.cbi-section.hidden`, …) only ever covers the elements someone has
|
||||
* already been bitten by, and the one it misses is the Enter-key submit every legacy Lua page
|
||||
* carries: luci-compat opens each form with `<input type="submit" class="hidden">`, and an
|
||||
* attribute selector counts as a class, so `input[type="submit"]` at (0,1,1) beats a bare
|
||||
* `.hidden` at (0,1,0) and a stray "Save" button floats above the page title (issue #12).
|
||||
* Doubling the class states the invariant once at (0,2,0) instead of chasing elements. An
|
||||
* `!important` would do it too, and would invert the layer order for every future override.
|
||||
* `fs-sheets.js` (rehostIntoThemeLayer) re-hosts a foreign app's own sheet into `@layer theme`,
|
||||
* where it now competes on specificity instead of losing to an unlayered rule by layer order
|
||||
* alone: measured on owrt2512 with luci-app-splify2 26.9, `<aside class="hidden lg:flex …">`
|
||||
* (Rail.tsx) stayed `display: none` at 1440/1920px because `.hidden.hidden` at (0,3,0) — see
|
||||
* below — outranked `.lg\:flex` at (0,1,0). `:not([class*=":"])` excludes any class list that
|
||||
* carries a Tailwind/UnoCSS responsive variant (the colon in `lg:flex`, `md:block`, `sm:inline`)
|
||||
* from the invariant above; LuCI itself never emits a class containing `:`, so nothing stock
|
||||
* is affected. Specificity moves to (0,3,0) — `:not()` counts its argument's attribute
|
||||
* selector, (0,1,0) — still clear of `input[type="submit"]` at (0,1,1) (issue #12, unchanged). */
|
||||
/* `.hidden.hidden` outranks every component rule without !important (which would invert layer
|
||||
* order later) and covers the Enter-key submit legacy Lua pages carry (issue #12).
|
||||
* :not([class*=":"]) excludes a Tailwind/UnoCSS class fs-sheets.js re-hosts into this layer —
|
||||
* docs/css.md "Inputs: `.hidden.hidden`, and the Tailwind/UnoCSS exception". */
|
||||
.hidden.hidden:not([class*=":"]) { display: none; }
|
||||
/* RangeSlider (UIRangeSlider) — the one LuCI widget base leaves to native browser rendering.
|
||||
* 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. */
|
||||
/* the WCAG 2.5.8 target (24px min) split from the 6px track — docs/css.md "Inputs: the range
|
||||
* slider's hit target and its line-box" has the two-part fix and its numbers */
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
width: 100%; height: 24px; min-height: 0; padding: 0;
|
||||
@@ -880,41 +499,21 @@
|
||||
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. */
|
||||
/* WebKit centres on the INPUT's box not the track: (6-16)/2 = -5px */
|
||||
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;
|
||||
}
|
||||
/* -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. 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.
|
||||
*
|
||||
* `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. */
|
||||
/* same ceiling as the elastic text inputs; vertical-align:middle decouples the line-box from the input's own edge, which moved 6px -> 24px above */
|
||||
.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);
|
||||
/* NOT 600: no face is bundled, so 600 either drags a 20 KB file back in or is faked by
|
||||
* synthetic emboldening, which smears a monospace grid.
|
||||
* The chip already reads as emphatic from its surface, border and accent ink. */
|
||||
/* not 600: no bold mono face bundled, drags in 20 KB or fakes it with synthetic emboldening */
|
||||
font-weight: var(--fs-weight-normal); font-size: var(--fs-type); white-space: nowrap;
|
||||
}
|
||||
.cbi-range-slider-calc, .cbi-range-slider-calc-units { color: var(--fs-dim); font-size: var(--fs-type); }
|
||||
|
||||
@@ -1,50 +1,30 @@
|
||||
@layer theme {
|
||||
/* Generic .dropdown-menu popup. Nothing in luci-base emits it — the split "Save & Apply" button is
|
||||
* a `.cbi-dropdown`, a different widget — so this is coverage vocabulary: stock
|
||||
* luci-theme-bootstrap styles it, so a third-party luci-app-* may emit it and expect a themed
|
||||
* result — theme/10-chrome.css claims the same class for the same reason. Not the theme's nav
|
||||
* menu, which is a plain nested <ul>.
|
||||
* Hover text is accent to match the open cbi-dropdown list: this is a form control's menu. */
|
||||
/* generic .dropdown-menu popup: nothing in luci-base emits it — coverage vocabulary, since
|
||||
* stock luci-theme-bootstrap styles it and a third-party app may emit it */
|
||||
.dropdown-menu { background-color: var(--fs-panel); border-color: var(--fs-border); }
|
||||
.dropdown-menu a { color: var(--fs-text); text-shadow: none; }
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus-visible { background: var(--fs-panel2); background-image: none; color: var(--fs-accent); }
|
||||
.dropdown-menu a:focus-visible { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
|
||||
/* Split "Save & Apply": a plain dropdown in the action bar carries no frame of its own.
|
||||
*
|
||||
* The guard is the same `:not(.btn):not(.cbi-button)` the widget rules below use, and it is
|
||||
* load-bearing: the split control's outer div IS the button, and the class on it is the one
|
||||
* ui.js picked for the SELECTED mode — `cbi-button-apply`, or `cbi-button-negative` the moment
|
||||
* the user picks "Apply unchecked" (luci.js's ComboButton `classes` map). Unguarded, this rule
|
||||
* (0,2,0) erases that variant's fill while its ink survives from the variant rule that sets no
|
||||
* background: a button painted page-on-page, at 1.06-1.13:1. */
|
||||
/* the outer div IS the button, class = ui.js's SELECTED mode; unguarded this erased that
|
||||
* variant's fill while its ink survived — measured page-on-page at 1.06-1.13:1. */
|
||||
.cbi-page-actions .cbi-dropdown:not(.btn):not(.cbi-button) {
|
||||
border: 0; border-radius: var(--fs-radius); background: transparent;
|
||||
}
|
||||
/* Apply is the one variant with no fill of its own — `.cbi-button-action` and the rest carry
|
||||
* theirs from theme/55-buttons.css, and `.cbi-button-apply` is filled only where it is THE
|
||||
* primary action, which is this bar. Painted by SPECIFICITY — it used to be a rule written
|
||||
* afterwards, i.e. a fix that depended on source order. */
|
||||
/* Apply is the one variant with no fill of its own (theme/55-buttons.css gives the rest theirs); painted by specificity, not source order */
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button {
|
||||
background: var(--fs-accent);
|
||||
background-image: none;
|
||||
border: 0;
|
||||
color: var(--fs-on-accent);
|
||||
}
|
||||
.cbi-page-actions .cbi-dropdown.cbi-button-apply:hover,
|
||||
.cbi-page-actions .cbi-dropdown > .cbi-button:hover { filter: var(--fs-hover-lift); }
|
||||
|
||||
/* ---- cbi-dropdown widget: the JS select used across all forms ----
|
||||
* Restyled to the design dropdown (panel2, rounded, chevron, focus ring) with a themed open
|
||||
* list. A native <select> is styled separately.
|
||||
*
|
||||
* The widget's MACHINERY is here too, absorbed from base whole. ui.js drives it purely through
|
||||
* attributes ([open], [multiple], [empty], [more], [display], [selected]) and the rules below
|
||||
* turn those into a working combobox. The display state machine is UNFLAGGED: upstream resolved
|
||||
* the states with !important, but flag-vs-flag falls back to specificity-then-order anyway, so
|
||||
* the machine below states the same resolution openly. The flags came in with the absorbed
|
||||
* widget from a theme with no @layer, which needed them to beat its own bare `ul` rule. */
|
||||
/* ---- cbi-dropdown widget: the JS select used across all forms ---- */
|
||||
/* machinery absorbed from base, driven by ui.js attributes; the state machine below is
|
||||
* UNFLAGGED, stating openly what upstream's !important flags resolved — docs/css.md
|
||||
* "Dropdown: the display state machine, unflagged" has the specificity table. */
|
||||
.cbi-dropdown {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
@@ -66,9 +46,7 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
/* clears the button's own side inset so the label starts on its text, not on its border.
|
||||
* Unflagged: (0,3,1) over the rule above's (0,1,1) — it only ever needed a flag to beat that
|
||||
* rule's flag. */
|
||||
/* clears the button's own side inset so the label starts on its text, unflagged (0,3,1) over the rule above's (0,1,1) */
|
||||
.cbi-dropdown.btn > ul:not(.dropdown),
|
||||
.cbi-dropdown.cbi-button > ul:not(.dropdown) {
|
||||
margin: 0 0 0 var(--fs-space-3);
|
||||
@@ -80,9 +58,7 @@
|
||||
.cbi-dropdown > ul.preview {
|
||||
display: none;
|
||||
}
|
||||
/* no `display` here: `.open` states its own below, and `.more` is display: none until the
|
||||
* [multiple][more]/[multiple][empty] showers flip it — one declaration per state, or the
|
||||
* audit's same-selector shadow check fires on this very pair */
|
||||
/* no `display` here: `.open` states its own below, `.more` is none until the showers flip it — one declaration per state or the audit's same-selector shadow check fires */
|
||||
.cbi-dropdown > .open,
|
||||
.cbi-dropdown > .more {
|
||||
flex-grow: 0;
|
||||
@@ -91,15 +67,10 @@
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
/* margin only: base also gave the button's chevron a .5em padding and a border-left divider,
|
||||
* but both were DEAD on arrival — the theme's `.open` rule below zeroes padding and border in
|
||||
* a later layer. A dead declaration is deleted, not relocated. */
|
||||
.cbi-dropdown.btn > .open,
|
||||
.cbi-dropdown.cbi-button > .open {
|
||||
margin-inline-start: .5em;
|
||||
}
|
||||
/* display only: the placeholder's ink is the theme's own `li[placeholder]` --fs-placeholder
|
||||
* below — the same token base/30-forms.css gives the attribute */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder] {
|
||||
display: none;
|
||||
}
|
||||
@@ -108,15 +79,8 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* the ellipsis above did nothing: a flex item's automatic minimum floors it at its OWN
|
||||
* content width until `min-width` says otherwise, so `overflow: hidden` never engaged and a
|
||||
* value longer than the box ran past it instead of eliding — measured at 320px, RU cut
|
||||
* mid-glyph 132px (Compact) / 225px (Normal) / 280px (Large) on https-dns-proxy, 66px on
|
||||
* nlbw/config, 70px inside the network interface-edit modal. No `title`/`aria-label` here on
|
||||
* purpose: the full string stays in the DOM and the accessibility tree, so a screen reader
|
||||
* still gets it whole — a `title` is not announced on touch, and an `aria-label` carrying the
|
||||
* SHORTENED text would violate WCAG 2.5.3 (the accessible name must contain the visible
|
||||
* text). */
|
||||
/* ellipsis did nothing until min-width: 0 — a flex item's automatic minimum floors it at its
|
||||
* own content width — docs/css.md "Dropdown: ellipsis needs `min-width: 0` first". */
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
@@ -147,12 +111,7 @@
|
||||
margin: 0 var(--fs-space-1) 0 0;
|
||||
}
|
||||
|
||||
/* ---- the display state machine, on specificity ----
|
||||
* Every shower out-specifies the hider it overrides: li[display] (0,2,2) over the li hider
|
||||
* (0,1,2); [open] > ul.dropdown > li (0,3,2) likewise; the placeholder rules (0,5,2 and 0,6,2)
|
||||
* over the placeholder hider (0,4,2); the form rule (0,4,3) over its hider (0,1,3). [empty]'s
|
||||
* block (0,2,2) loses to [open]'s flex (0,3,2) deliberately — an open empty list still lays out
|
||||
* as a popup — which is the resolution the old flags produced among themselves. */
|
||||
/* ---- the display state machine, on specificity (docs/css.md has the per-selector table) ---- */
|
||||
.cbi-dropdown > ul > li[display],
|
||||
.cbi-dropdown[open] > ul.preview,
|
||||
.cbi-dropdown[open] > ul.dropdown > li,
|
||||
@@ -163,11 +122,8 @@
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
}
|
||||
/* The one state the old flags actually carried: a closed optional single dropdown with an empty
|
||||
* value. ui.js hands the [display] attribute to the placeholder row itself, and the placeholder
|
||||
* hider (0,4,2) out-specifies the generic li[display] shower (0,2,2), so unflagged the closed
|
||||
* control renders EMPTY. `:not([open])` keeps this out of the open list, where the
|
||||
* [optional][open] rule below owns the row. */
|
||||
/* a closed optional dropdown with an empty value renders EMPTY unflagged too: the placeholder
|
||||
* hider out-specifies the generic shower. :not([open]) keeps this out of the open list. */
|
||||
.cbi-dropdown:not([open]):not(.btn):not(.cbi-button) > ul > li[placeholder][display] {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
@@ -180,13 +136,8 @@
|
||||
display: flex;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-open { display: none; }
|
||||
/* `min-width` because the thing that overflows an open row is a CHILD with an inline width, which
|
||||
* wrapping the row cannot reach: form.js's RichListValue writes the description block with an
|
||||
* inline `min-width:25vw`, assuming the list grows to fit — true on a theme that leaves
|
||||
* ul.dropdown unbounded, false here (capped at min(92vw, 420px), 90-responsive.css). The inline
|
||||
* floor is a VIEWPORT fraction, so the wider the screen the worse it gets: at 2559px, 25vw is
|
||||
* 640px inside a 406px row and the row's own `overflow: hidden` cut the sentence mid-word
|
||||
* (issue #15). An author !important is the only thing that outranks an inline declaration. */
|
||||
/* min-width: a CHILD with an inline width overflows, wrapping the row cannot reach it —
|
||||
* form.js's RichListValue assumes an unbounded list (issue #15). */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .hide-close { display: initial; min-width: 0 !important; }
|
||||
.cbi-dropdown[open] > ul.dropdown > li:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -201,29 +152,20 @@
|
||||
border-radius: var(--fs-radius);
|
||||
color: var(--fs-text);
|
||||
min-height: var(--fs-ctl-h);
|
||||
/* absorbed from base's generic field box with the width below. The fixed height is
|
||||
* load-bearing next to the min-height: without it a closed dropdown grows to its
|
||||
* tallest child (the Save & Apply split control carries a 40px button — measured,
|
||||
* 38 -> 42px on the gallery). */
|
||||
/* fixed height is load-bearing next to min-height: without it a closed dropdown grows to
|
||||
* its tallest child (Save & Apply's 40px button, measured 38 -> 42px on the gallery) */
|
||||
height: var(--fs-control-h);
|
||||
font-size: var(--fs-type);
|
||||
transition: var(--fs-field-transition);
|
||||
/* Width absorbed from base/30-forms.css, where it was grouped with .cbi-dynlist's — so the
|
||||
* theme sized the dynlist and base still sized the dropdown, two halves of the same
|
||||
* decision in two layers. Same numbers, and they are NOT the dynlist's (240/440): a
|
||||
* dropdown holds one value, a dynlist a column of them. */
|
||||
/* absorbed from base/30-forms.css; NOT the dynlist's numbers (240/440) — one value vs a column of them */
|
||||
min-width: 210px;
|
||||
max-width: 400px;
|
||||
width: auto;
|
||||
/* The widget's own box. In base these two carried `!important` — not to beat an inline
|
||||
* style, but to beat base's OWN generic form-field rule, which sets `display: inline-block`
|
||||
* and `padding: 4px` on this very selector at a higher specificity (0,3,0 vs 0,1,0). A later
|
||||
* LAYER beats it for free, which is exactly what the layer split is for. */
|
||||
/* base carried `!important` here to beat its OWN generic form-field rule at higher
|
||||
* specificity (0,3,0 vs 0,1,0); a later LAYER beats it for free */
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
}
|
||||
/* …and the button dropdown (the Save & Apply split control), where the flag was beating the
|
||||
* theme's own `.cbi-button` padding. Specificity does it: 0,2,0 over 0,1,0. */
|
||||
.cbi-dropdown.btn, .cbi-dropdown.cbi-button { display: inline-flex; padding: 0; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button):hover { border-color: var(--fs-dim); }
|
||||
.cbi-dropdown:focus, .cbi-dropdown[open] {
|
||||
@@ -231,52 +173,30 @@
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
outline: none;
|
||||
}
|
||||
/* `.cbi-dropdown > ul.dropdown > li` is the same menu row on a BUTTON dropdown (the Save & Apply
|
||||
* split control), which the `:not(.btn):not(.cbi-button)` guard excludes — so that menu kept
|
||||
* base's .25em rows while every other dropdown got these. One menu, one row. */
|
||||
/* `.cbi-dropdown > ul.dropdown > li` is the same row on a BUTTON dropdown, excluded by
|
||||
* :not(.btn):not(.cbi-button) below — one menu, one row, but two selectors. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li,
|
||||
.cbi-dropdown > ul.dropdown > li {
|
||||
padding: var(--fs-space-2) var(--fs-space-3); color: var(--fs-text);
|
||||
font-family: var(--fs-font-sans);
|
||||
/* one menu row: a grid step, which is what a line of text at this scale rounds to. NOT
|
||||
* `calc(--fs-type * --fs-leading)` — that is 19.5px, and a half-pixel on a BOX edge is
|
||||
* the thing the scale exists to stop. Absorbed with the machinery above. */
|
||||
/* a grid step, not calc(--fs-type * --fs-leading) (19.5px) — a half-pixel box edge is what the scale exists to stop */
|
||||
min-height: var(--fs-space-5);
|
||||
}
|
||||
/* …and the row's CONTENTS, which is where that font is otherwise lost. A MULTI-select wraps every
|
||||
* item in `<form><label><input type=checkbox> text</label></form>`, and `.cbi-value-field *` in
|
||||
* theme/60-inputs.css hands mono to every descendant of a field — matching that form and label
|
||||
* directly, while the rule above only reaches the `<li>`. So the same widget draws two fonts
|
||||
* depending on one option: a single-select list sans, every multi-select list mono, in the open
|
||||
* menu and in the closed control alike.
|
||||
*
|
||||
* It is a LABEL, not a value — the distinction `<strong>` gets in theme/45-misc.css — and mono
|
||||
* spacing is what makes a wordy option list look stretched. `inherit`, not the token again: the
|
||||
* row above is the one place the menu's font is chosen. */
|
||||
/* a MULTI-select wraps items in <form><label>…</label>, which theme/60-inputs.css's `.cbi-value-field *` would hand mono — inherit keeps it the row's sans, a LABEL not a value */
|
||||
.cbi-dropdown > ul > li > form,
|
||||
.cbi-dropdown > ul > li label { font-family: inherit; }
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[display]:not([display="0"]) { border-inline-start: 0; }
|
||||
/* The same ink the `placeholder` attribute takes in base/30-forms.css — "-- Please choose --" is
|
||||
* the absence of a value, not one.
|
||||
*
|
||||
* It repeats the row rule's two selectors instead of writing `> ul > li[placeholder]` once,
|
||||
* because that shorter form (0,2,2) LOSES to the row's own `color: var(--fs-text)` (0,3,2) and
|
||||
* the placeholder row rendered in the value's ink on every form dropdown — dead from the day it
|
||||
* was written, and only visible once `placeholder-ink` measured the row. Each selector here
|
||||
* out-specifies its counterpart above: (0,4,2) over (0,3,2), (0,3,2) over (0,2,2). */
|
||||
/* same ink base/30-forms.css gives `placeholder`; repeats the row rule's two selectors rather
|
||||
* than a shorter form, which LOSES to the row's own color and rendered in the value's ink. */
|
||||
.cbi-dropdown:not(.btn):not(.cbi-button) > ul > li[placeholder],
|
||||
.cbi-dropdown > ul.dropdown > li[placeholder] { color: var(--fs-placeholder); }
|
||||
/* the multiselect's inline filter/create inputs — moved from base with the generic `input`
|
||||
* box in theme/60-inputs.css: in base these beat it in-layer, left behind they would lose
|
||||
* to it by layer and render as free-standing 210px fields inside the open list */
|
||||
/* the multiselect's inline filter/create inputs, moved from base with the generic input box (theme/60-inputs.css) */
|
||||
.cbi-dropdown > ul > li input[type="text"] { height: var(--fs-space-5); }
|
||||
.cbi-dropdown[open] > ul.dropdown > li > input.create-item-input:first-child:last-child { width: 100%; }
|
||||
|
||||
/* Custom chevron in the toggle (stock text/svg arrow hidden). `.open` is the only hit target that
|
||||
* opens the list — on a button dropdown (.cbi-dropdown.btn, e.g. Diagnostics) a click anywhere
|
||||
* else RUNS the button. `font-size: 0` collapsed the stock `line-height: 2em`, so the span
|
||||
* measured 28x0 and the chevron (an absolute ::after) sat outside its own box: aiming at it hit
|
||||
* the button. Hence a real box, chevron centred in it. */
|
||||
/* `.open` is the only hit target that opens the list (a click elsewhere on a button dropdown
|
||||
* RUNS the button). `font-size: 0` collapsed the stock line-height to 28x0, putting the
|
||||
* chevron outside its own box — a real box fixes that. */
|
||||
.cbi-dropdown > .open {
|
||||
display: flex;
|
||||
font-size: 0; line-height: 0; border: 0;
|
||||
@@ -286,11 +206,7 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* `.more` is the "…" a multi-value dropdown shows when its chips overflow. base wrote it in ONE
|
||||
* rule with `.open`; the theme took `.open` for the chevron above and left its twin behind, so
|
||||
* the two halves of the same toggle strip sat in different layers. Same geometry, absorbed —
|
||||
* and hidden by default here (the [multiple][more]/[multiple][empty] showers reveal it),
|
||||
* with base's muted ink. */
|
||||
/* `.more` was one base rule with `.open`, split when the theme took `.open` for the chevron */
|
||||
.cbi-dropdown > .more { line-height: 2em; padding: 0 .3em; color: var(--fs-dim); display: none; }
|
||||
.cbi-dropdown > .open > * { display: none; }
|
||||
.cbi-dropdown > .open::after {
|
||||
@@ -299,20 +215,13 @@
|
||||
border-right: 2px solid var(--fs-dim); border-bottom: 2px solid var(--fs-dim);
|
||||
transform: translateY(-2px) rotate(45deg); /* optical centre of a "v" */
|
||||
}
|
||||
/* A BUTTON dropdown draws that chevron on the button's own FILL, where --fs-dim is a grey nobody
|
||||
* measured against it — on the accent-filled Save & Apply it reads as a smudge rather than an
|
||||
* arrow. currentColor takes whatever ink the variant already set, so there is no second list of
|
||||
* variants to keep in sync. The form-control dropdown keeps the muted chevron on purpose: there
|
||||
* the arrow is an affordance beside the value, not the content. */
|
||||
/* --fs-dim reads as a smudge on the accent-filled Save & Apply; currentColor uses the variant's own ink */
|
||||
.cbi-dropdown.btn > .open::after,
|
||||
.cbi-dropdown.cbi-button > .open::after { border-color: currentColor; }
|
||||
|
||||
/* open list -> design popover (this was once stated four times over; these are the values
|
||||
* that were winning). */
|
||||
/* open list -> design popover */
|
||||
.cbi-dropdown[open] { z-index: var(--fs-z-dropdown); }
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
/* what makes it a popup at all — out of flow, stacked from the --fs-z-* scale;
|
||||
* absorbed with the machinery */
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: var(--fs-z-dropdown);
|
||||
@@ -323,82 +232,58 @@
|
||||
border-radius: var(--fs-radius-lg);
|
||||
box-shadow: var(--fs-shadow-pop);
|
||||
padding: var(--fs-space-1-5);
|
||||
/* the gap that detaches the popover from its control. It was DEAD until the `ul` margin
|
||||
* flags above came out (they out-ranked it from a lower specificity), so the list sat 1px
|
||||
* off the control — the one value that moved when they went. */
|
||||
margin-top: var(--fs-space-1-5);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li {
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2) var(--fs-space-3);
|
||||
border-inline-start: 0; color: var(--fs-text);
|
||||
position: relative;
|
||||
/* An OPEN row wraps; only the closed preview ellipsizes. `.cbi-dropdown > ul > li` sets nowrap
|
||||
* plus ellipsis, which is right for the collapsed control — one line showing the current
|
||||
* value — and wrong for the menu: the list is capped at min(92vw, 420px), so an option
|
||||
* carrying a description runs out of box and is cut mid-word, with no ellipsis because the
|
||||
* cut falls inside a nested element (issue #15). Raising the cap instead would let one
|
||||
* verbose option decide the width of every menu on the page. */
|
||||
/* an OPEN row wraps; only the closed preview ellipsizes — the menu is capped at
|
||||
* min(92vw, 420px), so a verbose option would otherwise decide the width of every menu */
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
/* no border-bottom divider: on a rounded box it curls up at the corners. The straight
|
||||
* inset strip below (::after) is the divider, so the hover pill stays rounded. */
|
||||
/* no border-bottom: it curls at the corners of a rounded box; the ::after inset strip below is the divider instead */
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* The row's own wrap above never reaches a `.zonebadge` (base/95-luci.css): `white-space: nowrap`
|
||||
* there, right for a one-line pill in a table cell, wrong here where a multi-homed interface
|
||||
* (RichListValue) nests two `.ifacebadge` chips in one badge with no space between them — an
|
||||
* unbreakable 254-258px at 320px (`R` past the 320px viewport, task audit-09), overflowing
|
||||
* regardless of the popup's own width. Scoped to the open list only: every other `.zonebadge`
|
||||
* keeps `nowrap`. */
|
||||
.cbi-dropdown[open] > ul.dropdown > li .zonebadge {
|
||||
white-space: normal; overflow-wrap: anywhere;
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:not(:last-child)::after {
|
||||
content: ""; position: absolute; inset-inline: 11px; bottom: 0;
|
||||
height: 1px; background: var(--fs-border);
|
||||
}
|
||||
.cbi-dropdown[open] > ul.dropdown > li:hover,
|
||||
.cbi-dropdown[open] > ul.dropdown > li.focus { background: var(--fs-panel2); color: var(--fs-accent); }
|
||||
/* After hover on purpose: hovering the selected row must still read as selected. The surface is
|
||||
* OPAQUE (--fs-panel2) and the accent is carried by the inset rail, not by a tint of itself:
|
||||
* accent text on --fs-accent-soft measures 4.21:1 in dark, under AA — the tint drags the
|
||||
* background toward the text and eats its own contrast. axe never saw it, no gallery case
|
||||
* rendering an OPEN dropdown with a chosen value. */
|
||||
/* after hover on purpose: hovering the selected row must still read as selected — docs/css.md
|
||||
* "Dropdown: the open list's rail and the selected-row's contrast". */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected] {
|
||||
/* @mirror selected-row/paint */
|
||||
background: var(--fs-panel2); color: var(--fs-accent); box-shadow: var(--fs-row-rail);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* The focused row wears the theme's ring, not the browser's: ui.js focuses a row as it opens the
|
||||
* menu, and the UA answers a programmatic focus with `outline: auto` — a system-blue capsule
|
||||
* around the top row of every dropdown that already has a value.
|
||||
*
|
||||
* Inset rather than the token's outer 3px, because the menu clips its own overflow and an outer
|
||||
* ring is cut on the first and last rows. The rail rides INSIDE this ring so a focused row that
|
||||
* is also selected keeps it — a shorthand cannot merge with the one below, so the two are listed
|
||||
* together and `--fs-row-rail` keeps that a reference rather than a third copy.
|
||||
*
|
||||
* Both selectors carry `[selected]`-level weight on purpose: the selected-row paint above is
|
||||
* (0,4,2) and a bare `li:focus-visible` would tie with it, leaving the focus ring to source
|
||||
* order. */
|
||||
/* theme's ring, not the UA's outline:auto; inset, since the menu clips overflow, rail rides
|
||||
* INSIDE it so a focused+selected row keeps both */
|
||||
.cbi-dropdown[open] > ul.dropdown > li[selected]:focus-visible,
|
||||
.cbi-dropdown[open] > ul.dropdown > li:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-row-rail), inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
/* A MULTI-select row says "chosen" with its CHECKBOX, so it does not also need the rail. The rail
|
||||
* is `inset 2px 0 0` on a row with an 8px radius, so it draws as an ARC rather than a bar —
|
||||
* fine as the single-select cue it was designed for, one row at a time, and wrong down a column
|
||||
* of them, where three chosen rows read as a broken frame. The checkbox is a non-colour cue in
|
||||
* its own right, which is what WCAG 2.2 SC 1.4.1 asks of "selected"; a single-select row has
|
||||
* none and keeps the rail.
|
||||
*
|
||||
* Written as an override rather than by narrowing the paint below, because those three
|
||||
* declarations are pinned byte-identical to `select option:checked` in theme/60-inputs.css
|
||||
* (tools/mirror.mjs) — a native `<select multiple>` option has no checkbox, so the rail is still
|
||||
* the only cue there. `:not(:focus-visible)` keeps this off the focused row. */
|
||||
/* MULTI-select shows "chosen" via its CHECKBOX, drops the rail; pinned byte-identical to
|
||||
* `select option:checked` in theme/60-inputs.css (tools/mirror.mjs), which has no checkbox */
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:not(:focus-visible) { box-shadow: none; }
|
||||
.cbi-dropdown[multiple][open] > ul.dropdown > li[selected]:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
.cbi-dropdown[open] > ul.dropdown > li.create-item { border-top: var(--fs-hairline); margin-top: var(--fs-space-1); }
|
||||
/* The dropdown's "add item" field is a text input and takes the same radius as every other
|
||||
* one (theme/60-inputs.css). It said 8px here, and won or lost on source order. */
|
||||
.cbi-dropdown .create-item-input { background: var(--fs-panel2); border: var(--fs-hairline); border-radius: var(--fs-radius); color: var(--fs-text); }
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, so the
|
||||
* wrapper stays transparent: no double frame. */
|
||||
/* dropdown add-item (e.g. Community Lists) — the .cbi-dropdown styles itself, wrapper stays transparent: no double frame */
|
||||
.cbi-dynlist > .add-item:has(> .cbi-dropdown) {
|
||||
background: none; border: 0; overflow: visible; box-shadow: none;
|
||||
}
|
||||
@@ -412,40 +297,20 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Below ~960px of available width a multi-dropdown row no longer fits, so each config row cards.
|
||||
* Same shape as the fs-dt stack (theme/30-tables.css): label above its cell, a read-only cell
|
||||
* half the row, a widget cell the whole width.
|
||||
*
|
||||
* A @container, not the measurement the data tables use, and it must stay one: these rows are
|
||||
* full of widgets that bake in a width from the layout they were rendered in, so un-collapsing
|
||||
* one to take a reading CHANGES what is read — the firewall zone table then claimed 1747px
|
||||
* where it needs 1190px. Do not "finish the job" by measuring it.
|
||||
*
|
||||
* 992, not 960: the query reads `.cbi-map` (theme/45-misc.css), one level above the `.cbi-section`
|
||||
* that actually holds the table, and never subtracts what that level spends — `--fs-card-pad` on
|
||||
* both sides, 32px at the density every page but Compact ships. Measured live (task 0163):
|
||||
* firewall/zones' `.cbi-map` sat at 968px, 936px once the section's own padding was out, and its
|
||||
* six RU headers needed 1017px — 968 cleared the un-adjusted 960px check and the table never
|
||||
* carded, clipping the header row 49-65px with no scroller anywhere near it (English's shorter
|
||||
* headers happened to fit the same 968px, which is why only RU showed it). 992 = 960 + 32 makes
|
||||
* the check ask what it always meant to: whether the SECTION's own content, not the map's, has
|
||||
* cleared 960px. */
|
||||
/* below ~960px a multi-dropdown row no longer fits, so each row cards, same shape as the
|
||||
* data-table stack (theme/30-tables.css); a @container, not the tables' own measurement, since
|
||||
* un-collapsing a row to measure it CHANGES what its widgets read. 992, not 960 — docs/css.md
|
||||
* "Dropdown: 992px, not 960" has the padding subtraction and the regression it fixed. */
|
||||
@container fs-content (max-width: 992px) {
|
||||
.table.cbi-section-table,
|
||||
.table.cbi-section-table .tbody,
|
||||
.table.cbi-section-table .td { display: block; width: auto; }
|
||||
/* Hide the header in BOTH markups — a card repeats every label, so the header is a duplicate that
|
||||
* only runs off the right edge. form.js wraps titles and descriptions in a `.thead`, while the
|
||||
* Lua CBI that many third-party apps still render through emits a bare
|
||||
* `tr.cbi-section-table-titles` with no .thead, which is why hiding only `.thead` left
|
||||
* luci-app-openvpn's header on screen. `.tr.table-titles` is deliberately not listed:
|
||||
* it comes from L.ui.Table, which never renders a .cbi-section-table, and is hidden by the
|
||||
* fs-dt stack in theme/30-tables.css. */
|
||||
/* hide the header in BOTH markups (form.js's .thead, the Lua CBI's bare
|
||||
* tr.cbi-section-table-titles) — a card repeats every label, the header is a duplicate */
|
||||
.table.cbi-section-table .thead,
|
||||
.table.cbi-section-table .tr.cbi-section-table-titles,
|
||||
.table.cbi-section-table .tr.cbi-section-table-descr { display: none; }
|
||||
/* no align-items: flex-start — stretch keeps a pair's cells (and their separators) the
|
||||
* same height when one wraps; see the data card's .tr in theme/30-tables.css. */
|
||||
/* stretch (not flex-start) keeps a pair's cells the same height when one wraps */
|
||||
.table.cbi-section-table .tr {
|
||||
display: flex; flex-wrap: wrap; width: auto;
|
||||
border: var(--fs-hairline);
|
||||
@@ -455,37 +320,24 @@
|
||||
margin-bottom: var(--fs-space-3);
|
||||
}
|
||||
.table.cbi-section-table .tr:hover { background: var(--fs-panel2); }
|
||||
/* `.table.cbi-section-table`, not `.cbi-section-table`: the desktop cell padding is written as
|
||||
* `.table.cbi-section-table .td` and outranks a class-lighter selector, so a plainer one here
|
||||
* silently keeps the desktop padding.
|
||||
*
|
||||
* margin: 0 is load-bearing. The Lua CBI gives a table CELL the same `.cbi-value-field` class
|
||||
* a FORM field carries, and base indents that class by 20px, so a stacked cell measures
|
||||
* 50% + 20px, two no longer fit on a line, and each pair-cell wraps to its own. */
|
||||
/* margin: 0 load-bearing: the Lua CBI gives a table CELL the .cbi-value-field class a form
|
||||
* field carries, base indents it 20px — without the reset two cells no longer fit a line */
|
||||
.table.cbi-section-table .td {
|
||||
flex: 1 1 100%; min-width: 0; margin: 0;
|
||||
padding-block: var(--fs-space-1-5); padding-inline: 0 var(--fs-space-2-5); border: 0; text-align: start;
|
||||
/* same as the data card's cells (theme/30-tables.css): a carded cell holds opaque
|
||||
* tokens (a real-length IPv6 GUA is 39 chars with no break point — colons are not
|
||||
* break opportunities), and without `anywhere` one ran 156px past a 360px viewport
|
||||
* on the Interfaces page. overflow-wrap inherits, so the inner spans get it too;
|
||||
* white-space beats the `.nowrap` class LuCI puts on some cells. */
|
||||
/* overflow-wrap: anywhere for opaque tokens (a real IPv6 GUA has no break point) — one
|
||||
* ran 156px past a 360px viewport before this */
|
||||
overflow-wrap: anywhere; white-space: normal;
|
||||
}
|
||||
/* A cell that only READS a value (dummy text, a flag, one button) is short, so two fit on a
|
||||
* line; anything with a real widget keeps the full width (the 100% basis above) — a dropdown
|
||||
* at half a phone's width is unusable. Both spellings of each widget: `CBI.DummyValue` from
|
||||
* form.js, `dvalue` from the Lua CBI's typename. */
|
||||
/* a cell that only READS a value is short, two fit a line; a real widget keeps full width (a dropdown at half a phone's width is unusable) */
|
||||
.table.cbi-section-table .td[data-widget="CBI.DummyValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.FlagValue"],
|
||||
.table.cbi-section-table .td[data-widget="CBI.Button"],
|
||||
.table.cbi-section-table .td[data-widget="dvalue"],
|
||||
.table.cbi-section-table .td[data-widget="fvalue"],
|
||||
.table.cbi-section-table .td[data-widget="button"] { flex-basis: 50%; }
|
||||
/* PINNED to the data table's copy in theme/30-tables.css: the same card, needed under a CLASS
|
||||
* there (.fs-stacked, measured) and under this @container here, and CSS cannot OR the two
|
||||
* guards. tools/mirror.mjs holds the copies byte-identical — a duplicate detector goes QUIET
|
||||
* once they differ, exactly when it should shout. */
|
||||
/* PINNED to the data table's copy (theme/30-tables.css, tools/mirror.mjs): needed under a
|
||||
* CLASS there and under this @container here, and CSS cannot OR the two guards. */
|
||||
.table.cbi-section-table .td[data-title]::before {
|
||||
/* @mirror table-card/label */
|
||||
content: attr(data-title); display: block; margin-bottom: var(--fs-space-1);
|
||||
@@ -493,9 +345,7 @@
|
||||
color: var(--fs-eyebrow-color);
|
||||
/* @endmirror */
|
||||
}
|
||||
/* The row's buttons are not a label/value pair: full width, each button stretching to
|
||||
* share the line (Edit | Delete). PINNED — see above. (These six declarations were the
|
||||
* data table's six in a DIFFERENT ORDER, so a grep could not tell they were one rule.) */
|
||||
/* the row's buttons are not a label/value pair: full width, sharing the line. PINNED — see above. */
|
||||
.table.cbi-section-table .td.cbi-section-actions {
|
||||
/* @mirror table-card/actions */
|
||||
display: flex; flex: 1 1 100%; justify-content: flex-start;
|
||||
@@ -503,10 +353,8 @@
|
||||
/* @endmirror */
|
||||
}
|
||||
.table.cbi-section-table .td.cbi-section-actions::before { content: none; }
|
||||
/* flex-wrap: LuCI groups the row's buttons in ONE inner <div>, so the td's own wrap
|
||||
* (above) never fires — the div is a single flex item. Four buttons are ~330px; on a
|
||||
* 320px phone the carded interfaces row overflowed its section by 17px. PINNED to the
|
||||
* data table's copy (theme/30-tables.css), which hit the same trap on Startup. */
|
||||
/* LuCI groups the row's buttons in ONE inner div, so the td's own wrap never fires — the
|
||||
* div is a single flex item. PINNED to the data table's copy, same trap on Startup. */
|
||||
.table.cbi-section-table .td.cbi-section-actions > * {
|
||||
/* @mirror table-card/actions-inner */
|
||||
flex: 1 1 auto; display: flex; flex-wrap: wrap; gap: var(--fs-space-2);
|
||||
@@ -518,15 +366,8 @@
|
||||
.table.cbi-section-table .td .zone-forwards { width: 100%; }
|
||||
}
|
||||
|
||||
/* ---- widget popups must escape the table card ----
|
||||
* The standalone-table card (theme/30-tables.css) clips row backgrounds to its rounded corners
|
||||
* with `overflow: hidden`, but a form table also holds .cbi-dropdown widgets whose open list is
|
||||
* absolutely positioned — the clip crops it at the table's edge. So drop the clip where a widget
|
||||
* lives; the `.cbi-dropdown[open]` z-index above lifts the list over the following cards.
|
||||
*
|
||||
* Dropping it squares the frame off, though: a real <table> inherits `border-collapse: collapse`,
|
||||
* which ignores border-radius — the card's corners came from the clip, not the border. The
|
||||
* separated model restores them, and at zero spacing it lays out identically. */
|
||||
/* the table card (theme/30-tables.css) clips with overflow: hidden, but a .cbi-dropdown's open
|
||||
* list is absolutely positioned — drop the clip, restore corners via separated borders instead */
|
||||
.table:has(.cbi-dropdown),
|
||||
.table:has(.cbi-dynlist) {
|
||||
overflow: visible;
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
@layer theme {
|
||||
/* base/60-modal.css draws the dialog's elevation in --fs-panel2, which in footstrap light is the
|
||||
* same hex as --fs-bg: the shadow under the top of the z-stack drawn in the exact colour of the
|
||||
* page behind it (1.00:1 light, 1.27 dark — a glow, never a shadow, panel2 being lighter than
|
||||
* the canvas). Every other floating surface in the theme takes --fs-shadow-pop. Unscoped
|
||||
* `.modal`, not `#modal_overlay .modal`: .modal is reachable outside the overlay, and the layer
|
||||
* beats base at equal specificity. */
|
||||
.modal { box-shadow: var(--fs-shadow-pop); }
|
||||
/* diff tints come from base's ladder alone */
|
||||
|
||||
/* The uci-changes diff takes its tints from the ladder in base (--fs-*-fill for the block,
|
||||
* --fs-*-line for its border) and nowhere else — a second set here paints the same widget at two
|
||||
* strengths depending on whether it is inside the modal, which it always is. Only geometry is
|
||||
* left here: small rounding on the diff boxes and legend swatches.
|
||||
*
|
||||
* The popup's own radius is not restated: base rounds a modal to `var(--fs-radius-lg)` with a
|
||||
* comment saying a modal is a card. */
|
||||
|
||||
/* moved from base with the heading ramp (45-misc.css): the `line-height: normal` here beat
|
||||
* the ramp's tight leading in-layer for a dialog's h4 title, and left behind it would lose
|
||||
* to the ramp by layer (measured: modal headings dropped normal -> 15.6px) */
|
||||
/* beats the heading ramp's tight leading in-layer for a dialog's h4 (45-misc.css) */
|
||||
.modal > * {
|
||||
line-height: normal;
|
||||
margin-bottom: .5em;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* an alert dropped into a dialog keeps the alert's own rhythm. In base this resolved by
|
||||
* FILE ORDER (90-widgets after 60-modal, both 0,1,0) — here it wins on specificity, so the
|
||||
* cascade no longer rests on position. */
|
||||
/* wins on specificity, not base's file-order tie */
|
||||
.modal > .alert-message { margin-bottom: var(--fs-space-2); }
|
||||
/* moved from base with the generic field box in theme/60-inputs.css: a dialog's log/output
|
||||
* textarea fills the dialog, and base's width:100% would lose to the 210px box by layer */
|
||||
/* moved from base: width:100% loses to the 210px generic box by layer */
|
||||
.modal > pre,
|
||||
.modal > textarea {
|
||||
white-space: pre-wrap;
|
||||
@@ -36,54 +17,24 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* A config-form modal (Bridge device options, firewall rule …) is only 600px wide by
|
||||
* default — cramped for a form with a table (Bridge VLAN filtering, whose .cbi-map container
|
||||
* then falls under the 992px card threshold and cards even on a big screen). Fluid via vw:
|
||||
* widens toward 1100 on desktop (the container clears 992 and tables stay tables), stays
|
||||
* narrow (cards) on tablet/phone. */
|
||||
/* 600px is cramped for a table form (.cbi-map cards on a big screen); vw widens toward 1100 on desktop only */
|
||||
.modal:has(.cbi-map) { max-width: min(1100px, 94vw); }
|
||||
|
||||
/* A modal must never gain a HORIZONTAL scroll: #modal_overlay is the scroll container
|
||||
* (base/60-modal.css, `overflow: auto`) and the dialog is centred in it with `margin: auto`, so
|
||||
* one over-wide child scrolls the modal's own
|
||||
* left edge off the screen — the dialog still there, just unreachable-looking. Measured at
|
||||
* 360px: the overlay wanted 634px of scroll against 530px of room.
|
||||
*
|
||||
* Two children do it, both stock LuCI markup:
|
||||
* - `<label class="btn">` — flash.js writes each checkbox row as one, and `.btn` is
|
||||
* `white-space: pre`, which is right for a button's own label and wrong for a sentence, so
|
||||
* it cannot wrap at any width. Scoped to `label`; a real button keeps `pre`.
|
||||
* - a `<li>` carrying a checksum, one unbreakable 64-character token. */
|
||||
/* …and `overflow-wrap` with it: `white-space: normal` lets the sentence wrap at its spaces, which
|
||||
* is not enough for a label ending in a ~200px path token — in any dialog narrower than that the
|
||||
* label still runs past the box. Measured in the gallery at a 320px viewport: the label was 277px
|
||||
* inside a 264px column, 13px out.
|
||||
*
|
||||
* `min-width: 0` is the other half, and without it the wrap never fires: `.btn` is `inline-flex`,
|
||||
* so the label is a flex CONTAINER whose automatic minimum size is its content's min-content.
|
||||
*
|
||||
* `anywhere` rather than `break-word`, which follows the tier doctrine rather than contradicting
|
||||
* it (base/40-tables.css states it for cells): `break-word` leaves min-content at the widest
|
||||
* token, and the widest token here is wider
|
||||
* than the dialog at 320px, while the anonymous text flex item cannot be given a `min-width` of
|
||||
* its own. A dialog label has no card to fold into. */
|
||||
/* no HORIZONTAL scroll ever (measured 634px scroll vs 530px room) — docs/css.md "Modal: no horizontal scroll, ever" */
|
||||
#modal_overlay .modal label.btn { white-space: normal; overflow-wrap: anywhere; min-width: 0; }
|
||||
#modal_overlay .modal li { overflow-wrap: anywhere; }
|
||||
/* a literal 4px sat outside the Rounding axis */
|
||||
#modal_overlay .uci-dialog div > ins,
|
||||
#modal_overlay .uci-dialog div > del,
|
||||
#modal_overlay .uci-dialog div > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-dialog div > var,
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
/* same token as the diff blocks above — a literal 4px sat outside the Rounding axis */
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); overflow: hidden; }
|
||||
#modal_overlay .uci-change-legend-label > var { border-radius: var(--fs-radius-sm); }
|
||||
#modal_overlay .uci-change-legend-label > ins,
|
||||
#modal_overlay .uci-change-legend-label > del,
|
||||
#modal_overlay .uci-change-legend-label > var { overflow: hidden; }
|
||||
|
||||
/* A changed option is `<var><ins>uci set …</ins></var>`: the outer plate is rounded above and the
|
||||
* coloured fill is the child, and `overflow: hidden` does not round it, because the child never
|
||||
* reaches the corner it would be clipped at — the parent holds it 3px in (1px border plus 2px
|
||||
* padding). So the plate has rounded corners with a square block inside them, and the legend's
|
||||
* swatches are squares inside circles (issue #14). The child takes the parent's radius minus
|
||||
* that inset, which makes the two curves concentric; `max()` keeps it at 0 rather than negative
|
||||
* when Rounding is turned down. */
|
||||
/* fill child sits 3px inset, overflow:hidden never reaches the corner — max() floors at 0 (issue #14) */
|
||||
#modal_overlay .uci-dialog var > ins,
|
||||
#modal_overlay .uci-dialog var > del {
|
||||
border-radius: max(0px, calc(var(--fs-radius-sm) - 3px));
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
@layer theme {
|
||||
/* ==================================================================
|
||||
* The page-search palette. Markup is built by fs-search.js and parented to <body>:
|
||||
* the vertical .fs-sidebar is `overflow: auto`, which would clip a child of the trigger,
|
||||
* and the overlay has to cover the whole viewport anyway.
|
||||
* ================================================================== */
|
||||
/* page-search palette. fs-search.js parents it to <body>: .fs-sidebar is overflow:auto, which
|
||||
* would clip a child of the trigger, and the overlay covers the whole viewport anyway. */
|
||||
|
||||
/* The scrim. --fs-scrim is the theme's one colour literal (plain black at .7) on purpose:
|
||||
* it is the absence of light behind a dialog, not a tint of any palette token. */
|
||||
/* --fs-scrim is the theme's one colour literal (plain black at .7) on purpose: absence of light behind a dialog, not a tint of any palette token */
|
||||
.fs-search-ov {
|
||||
position: fixed; inset: 0; z-index: var(--fs-z-popover);
|
||||
/* `display` here is what the `hidden` attribute has to beat — base/10-reset.css does that for
|
||||
* every element now, so this file no longer restates it for the overlay alone. */
|
||||
/* what the `hidden` attribute has to beat — base/10-reset.css covers every element now */
|
||||
display: flex; justify-content: center;
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, and a centred one
|
||||
* would slide the input out from under the user's eyes on every keystroke */
|
||||
/* not centred vertically: the box grows DOWNWARD as results come in, centred would slide the input out from under the user's eyes on every keystroke */
|
||||
align-items: flex-start;
|
||||
padding: max(8vh, var(--fs-space-6)) var(--fs-space-4) var(--fs-space-4);
|
||||
background: var(--fs-scrim);
|
||||
animation: fs-fade var(--fs-dur-fade);
|
||||
}
|
||||
|
||||
/* The dialog itself. It is a SCROLL CONTAINER, and that is not decoration: without a cap the
|
||||
* box grows past a short viewport (a phone in landscape is ~380px tall) and the rows at the
|
||||
* bottom become unreachable — a `position: fixed` popup cannot be scrolled by the page. */
|
||||
/* a SCROLL CONTAINER, not decoration: uncapped the box grows past a short viewport (phone
|
||||
* landscape ~380px), and a position:fixed popup cannot be scrolled by the page */
|
||||
.fs-search-box {
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
width: 100%; max-width: 560px; max-height: min(70dvh, 560px);
|
||||
@@ -39,14 +32,10 @@
|
||||
}
|
||||
.fs-search-ico { display: inline-flex; color: var(--fs-faint); }
|
||||
.fs-search-ico .fs-ico { width: 18px; height: 18px; }
|
||||
/* The input carries no frame of its own — the ROW is the field and the box around it is the
|
||||
* dialog. Two classes (0,2,0) on purpose: the generic field box in 60-inputs.css is
|
||||
* `input[type="text"], …` (0,1,1) and outranks a single class in the same layer whatever the
|
||||
* source order — measured, it painted this input with the page-field border, the panel2 fill,
|
||||
* 8px/11px of padding and a focus ring inside the dialog's own frame.
|
||||
*
|
||||
* 16px rather than the theme's body size, deliberately: a font-size below 16px makes iOS Safari
|
||||
* zoom the page when the field takes focus, and this one takes focus as the palette opens. */
|
||||
/* the ROW is the field, the input carries no frame of its own — two classes (0,2,0) needed to
|
||||
* outrank 60-inputs.css's generic field box (0,1,1) in the same layer regardless of source
|
||||
* order. 16px, not the theme's body size: below 16px iOS Safari zooms the page on focus, and
|
||||
* this field focuses as the palette opens. */
|
||||
.fs-search-row .fs-search-input {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
margin: 0; padding: 0; border: 0; min-height: 0; background: transparent;
|
||||
@@ -54,9 +43,7 @@
|
||||
font-family: inherit; font-size: var(--fs-type-lg); font-weight: var(--fs-weight); line-height: 1.4;
|
||||
}
|
||||
.fs-search-row .fs-search-input:focus { outline: none; box-shadow: none; }
|
||||
/* one placeholder ink for the whole theme (03-palettes.css) — this row is not measured by
|
||||
* `placeholder-ink`, the gallery having no search dialog, and must not drift from the fields
|
||||
* that are */
|
||||
/* one placeholder ink for the whole theme (03-palettes.css); not measured by placeholder-ink (no gallery fixture), must not drift from the fields that are */
|
||||
.fs-search-row .fs-search-input::placeholder { color: var(--fs-placeholder); font-weight: var(--fs-weight); }
|
||||
|
||||
.fs-search-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--fs-space-1-5); }
|
||||
@@ -66,12 +53,9 @@
|
||||
padding: var(--fs-space-2) var(--fs-space-2-5); border-radius: var(--fs-radius);
|
||||
color: var(--fs-text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
/* One highlight for both pointer and keyboard: `.active` is set by fs-search.js for the
|
||||
* aria-activedescendant row AND on pointermove, so the mouse and the arrow keys can never
|
||||
* disagree about which row Enter would open. */
|
||||
/* one highlight for pointer AND keyboard: .active is set on both aria-activedescendant and pointermove, so mouse and arrow keys can never disagree which row Enter opens */
|
||||
.fs-search-opt.active { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-search-opt-title { flex: 0 1 auto; font-weight: var(--fs-weight-bold); font-size: var(--fs-type); }
|
||||
/* the breadcrumb: which section owns the page — the whole point of searching for it */
|
||||
.fs-search-opt-path {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
color: var(--fs-faint); font-size: var(--fs-type); text-align: end;
|
||||
@@ -79,7 +63,6 @@
|
||||
}
|
||||
.fs-search-opt.active .fs-search-opt-path { color: inherit; }
|
||||
|
||||
/* "Recently visited" — the caption over the rows */
|
||||
.fs-search-note {
|
||||
flex: 0 0 auto;
|
||||
padding: var(--fs-space-2-5) var(--fs-space-3-5) var(--fs-space-0-5);
|
||||
@@ -87,9 +70,8 @@
|
||||
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight); letter-spacing: var(--fs-eyebrow-tracking); text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* …and the box's EMPTY STATE when there are no rows at all ("No pages found"), which reads as
|
||||
* a sentence, not as an eyebrow label. :has() rather than a sibling combinator: the note sits
|
||||
* ABOVE the list it describes, so `+` cannot reach back to it. */
|
||||
/* empty state ("No pages found") reads as a sentence, not an eyebrow label. :has(), not a
|
||||
* sibling combinator: the note sits ABOVE the list it describes, `+` cannot reach back to it. */
|
||||
.fs-search-box:has(.fs-search-list:empty) .fs-search-note {
|
||||
padding: var(--fs-space-6) var(--fs-space-3-5) var(--fs-space-7);
|
||||
text-align: center; text-transform: none; letter-spacing: 0;
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
@layer theme {
|
||||
/* Phone overflow safety net (<=767px). .fs-main uses overflow-x: clip, so anything wider than the
|
||||
* viewport is silently CUT on the right rather than scrolled to. A device sweep found the
|
||||
* offenders below; each fix lets the control or table shrink or wrap. Tablets were already
|
||||
* clean. */
|
||||
/* A pair of buttons an app packs into one `inline-flex` must be allowed to wrap its labels.
|
||||
*
|
||||
* `white-space: pre` on a button is stock — luci-theme-bootstrap sets it too — and it holds
|
||||
* while the label is short. Ours are wider than stock at the same job: measured on
|
||||
* ssclash's split button at 320, `Сохранить и перезагрузить конфигурацию` is 295px against
|
||||
* stock's 245, because this theme's face is wider than the system stack even at a smaller
|
||||
* size (13px vs 14px), and the 14px side padding adds 12 of the 50. The app's wrapper is
|
||||
* `inline-flex`, so it takes the buttons' max-content — 324px inside a 288px column — and
|
||||
* overflows by 36. Stock fits only because its buttons are narrower.
|
||||
*
|
||||
* Narrowing the buttons would be re-typesetting the theme to suit one app. Letting the label
|
||||
* wrap costs nothing where it already fits — a short label has no wrap point — and turns the
|
||||
* one case that does not into two lines. Scoped to the width where it happens: at 390 the
|
||||
* same page is clean.
|
||||
*
|
||||
* `min-width: 0` with it, or the automatic minimum keeps the button at its longest word and
|
||||
* the flex line cannot shrink it at all.
|
||||
*
|
||||
* `word-break: normal` is the third half of it. base/10-reset.css gives every button
|
||||
* `break-all` — carried over from the fork, and inert while the label cannot wrap at all. The
|
||||
* moment it can, `break-all` takes precedence over word boundaries and the label breaks mid-word
|
||||
* (`Сохранить и перезагрузить ко` / `нфиг`). `overflow-wrap: break-word` keeps the escape hatch
|
||||
* for a single word wider than the button, which is what `break-all` was there for. */
|
||||
/* phone overflow safety net (<=767px): .fs-main is overflow-x: clip, cutting anything wider silently; tablets were clean */
|
||||
/* split-button labels wrap under 360px — docs/css.md "Responsive: a split button's label must wrap under 360px" */
|
||||
@container fs-view (max-width: 360px) {
|
||||
#view .cbi-button, #view .btn,
|
||||
#view input[type="button"], #view input[type="submit"], #view input[type="reset"] {
|
||||
@@ -37,48 +12,21 @@
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* The foreign-<table> scroll fallback used to live here, guarded by this query. It does not
|
||||
* any more: whether a table fits is a property of its CONTENT and its COLUMN, never of the
|
||||
* viewport — the same argument the fit engine is built on — and a 400px-wide panel on a
|
||||
* 1600px desktop needs it exactly as much as a phone does. It is unguarded now, in
|
||||
* theme/30-tables.css, next to the card stack it is the fallback FOR. */
|
||||
/* foreign-<table> fallback unguarded now in theme/30-tables.css: fit is content/column, never viewport */
|
||||
|
||||
/* Must stay INSIDE this @media, for specificity within one layer: the 400px cap lives in
|
||||
* theme/65-dropdown.css on `.cbi-dropdown:not(.btn):not(.cbi-button)` — (0,3,0) against this
|
||||
* rule's (0,1,0) — so unscoped this would lose to it everywhere and cap nothing. Inside the
|
||||
* phone tier it is what the narrow width needs: a dropdown must never exceed its field. */
|
||||
/* must stay INSIDE this @media: theme/65-dropdown.css's cap is (0,3,0) vs this (0,1,0) */
|
||||
.cbi-dropdown { min-width: 0; max-width: 100%; }
|
||||
|
||||
/* Software page: stack each labelled control. base gives its labels a min-width: 250px that
|
||||
* overflows a ~360px screen, and `.controls` is a flex ROW, so its columns cram side by side
|
||||
* and overlap. Scoped by data-page, not by an id: on 24.10 the "Disk space:" label ships with
|
||||
* no id, so a `:has()` scope silently misses the whole page there. !important because
|
||||
* package-manager.js injects an unlayered inline style block, which outranks every layer.
|
||||
* Scoped by `#view[data-page]`, not `body[data-page]`: the attribute lives on the view
|
||||
* itself so the outgoing page keeps its own rules for the whole staging window (fs-router.js,
|
||||
* commitStage) — the same bug and the same fix as styles/pages/30-software.css, discovered
|
||||
* here separately because this narrow-viewport tier is invisible to a gate run at desktop
|
||||
* width (tools/spa-parity.mjs's narrow-viewport pass).
|
||||
* `:where(#view)`, not bare `#view`: zeroes the id's specificity while matching the exact
|
||||
* same element — 20-overview.css, top of `@layer page`, has the full reasoning and the
|
||||
* measured numbers. */
|
||||
/* base's 250px label overflows ~360px; !important beats package-manager.js's inline style */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls { display: block !important; }
|
||||
/* `:not(.pager)` — the page reuses `.controls` for the « / "Displaying 1-100 of 7677" / »
|
||||
* pager, and its .pager is a <div> too. Blocking it stacked those three onto three lines
|
||||
* (reported). Only the LABELLED control groups want stacking; the pager is a row. */
|
||||
/* :not(.pager): blocking it stacked « / text / » onto three lines */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div:not(.pager) { display: block !important; min-width: 0; margin-bottom: var(--fs-space-2); }
|
||||
/* …and the pager itself: a centred row that stays one. `.text` is the only part that may
|
||||
* give way, so the two buttons keep their size instead of being squeezed. */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager {
|
||||
display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: var(--fs-space-2);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .btn { flex: 0 0 auto; }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > .pager > .text { flex: 0 1 auto; min-width: 0; }
|
||||
/* the bar's ::after value ("63% used …") normally floats above the bar, but the "Disk
|
||||
* space:" label already sits there — on a phone the long value text overlapped label +
|
||||
* bar. Drop it BELOW. The bar is a fixed 10px box, so a static ::after cannot push the
|
||||
* next control: reserve space with margin-bottom and place the value absolutely in it.
|
||||
* The disk section is the only `label` immediately followed by a `.cbi-progressbar`. */
|
||||
/* disk gauge value moved BELOW the bar — docs/css.md "Responsive: the disk gauge value moves below the bar" */
|
||||
:where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
|
||||
width: 100%; margin-bottom: 3em;
|
||||
}
|
||||
@@ -86,113 +34,56 @@
|
||||
top: calc(100% + 5px); inset-inline: 0 auto; bottom: auto;
|
||||
text-align: start; white-space: normal; max-width: 100%;
|
||||
}
|
||||
/* `> div > label` — the group's TITLE label ("Filter:", "Actions:", "Display LuCI
|
||||
* translation packages:"), not every label on the page. Written as plain `.controls label`
|
||||
* it also blocked the three radio labels nested a level deeper, and `filtered / all / none`
|
||||
* came out one per line. They are a set of three short choices: they belong on one row. */
|
||||
/* `> div > label`: only the TITLE label, a bare `.controls label` also caught nested radio labels */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > label { display: block; min-width: 0; white-space: normal; margin-bottom: var(--fs-space-1); }
|
||||
/* …and the row they sit in. Stock separates them with text nodes, which is enough to
|
||||
* keep them apart but not to keep them from splitting mid-choice — a flex row with a real
|
||||
* gap wraps between labels instead of inside one. */
|
||||
/* radio row: stock's text nodes don't stop a mid-choice split, a real gap does */
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls > div > div:has(> label > input[type="radio"]) {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-1) var(--fs-space-5);
|
||||
}
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group { display: flex; flex-wrap: wrap; min-width: 0; gap: var(--fs-space-2); }
|
||||
:where(#view)[data-page="admin-system-package-manager"] .controls .control-group input { flex: 1 1 140px; min-width: 0; max-width: 100%; }
|
||||
|
||||
/* The Diagnostics stack is not here: a page name and a viewport are neither of the facts that
|
||||
* matter. Any app can put a field, a select and a button in one header-less
|
||||
* `<div class="table">`, and what decides whether that row fits is the ROOM — at 320px the
|
||||
* row needs 338px, and inside a narrow panel at 900px of viewport it needs the same.
|
||||
* fs-select.js writes `.fs-rowstack` on any such table that overflows, at any width, and
|
||||
* theme/30-tables.css carries the declarations. */
|
||||
/* Diagnostics stack not here: fs-select.js writes .fs-rowstack at any width, theme/30-tables.css carries it */
|
||||
|
||||
/* No `table.fs-dt { display: block; width: 100% }` here — it sat here to stop a data table
|
||||
* that still fitted as a table from exceeding its column, and its absence is what makes the
|
||||
* measured design work at phone widths at all: `display: block` on a <table> DISCARDS the
|
||||
* table formatting
|
||||
* context, so the rows are wrapped in an anonymous table sized to the block and the table can
|
||||
* never be wider than its parent — which means it can never overflow, which means
|
||||
* `fit.overflows()` can never see anything below 767px. Measured on Processes at 720px with
|
||||
* such a rule in place: the Command column sat at 126px against a widest token of 353px, and
|
||||
* forcing that column's `overflow-wrap` three different ways produced the same 126px and the
|
||||
* same "the table fits" every time. */
|
||||
/* no `table.fs-dt { display: block }`: discards the table context — docs/css.md "Responsive: no `display: block` on `table.fs-dt`" */
|
||||
|
||||
/* generic caps: an input/select must never exceed its field, and long key/value strings
|
||||
* in the Overview cards must break. */
|
||||
/* generic caps: a field never exceeds its column */
|
||||
.cbi-value-field input, .cbi-value-field select, .cbi-value-field .cbi-dropdown { max-width: 100%; }
|
||||
/* …and once the label is on its own line the control takes the line it was given — a
|
||||
* field sized to its content left half the row empty and made the form read as a ragged
|
||||
* column (stock does the same on a phone). checkbox/radio/file keep their intrinsic size:
|
||||
* a full-width checkbox is a hit target the size of the screen. */
|
||||
/* content-sized leaves half the row empty; checkbox/radio/file keep intrinsic size */
|
||||
.cbi-value-field select,
|
||||
.cbi-value-field input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
|
||||
.cbi-value-field .cbi-dropdown:not(.btn):not(.cbi-button),
|
||||
.cbi-value-field .cbi-dynlist,
|
||||
.cbi-value-field .cbi-select { width: 100%; }
|
||||
/* min-width only: breaking a long value is base/40-tables.css's default for every cell now,
|
||||
* and this copy moved nothing when switched off against 126 matching cells. `min-width: 0` is
|
||||
* NOT redundant — switching that off moved 12 — so it stays. */
|
||||
/* min-width: 0 only, base/40-tables.css already breaks a long value */
|
||||
#view .fs-ovl .table .td, #view .fs-ovl .table .th { min-width: 0; }
|
||||
|
||||
/* Stack each field: label on its own line, control and description full-width below. Side by side
|
||||
* on the desktop squeezes the field — and any .cbi-value-description inside it — into a
|
||||
* ~120px column on a phone, so one sentence of help wraps to 8 lines. The row already
|
||||
* flex-wraps; these two full-width children are what stack it.
|
||||
*
|
||||
* Not scoped to #view, and that is the point: ui.js appends a modal to <body>, so an
|
||||
* `#view .cbi-value` rule leaves every DIALOG in the desktop layout and on a phone the
|
||||
* Edit-interface form pushes its inputs past the dialog's right edge. */
|
||||
/* `start` undoes base's `text-align: end` (the label hugs its field when the two share a
|
||||
* row); stacked, it goes back to the reading edge. Logical for the same reason base's is. */
|
||||
/* side-by-side squeezes a field to ~120px; not #view-scoped, ui.js appends modals to <body> */
|
||||
.cbi-value > .cbi-value-title { flex: 1 1 100%; text-align: start; padding-top: 0; }
|
||||
.cbi-value > .cbi-value-field { flex: 1 1 100%; min-width: 0; }
|
||||
|
||||
/* The stock phone contract every luci-app-* is written against. These classes are emitted by
|
||||
* LuCI's own JS and by third-party apps that copied them, and they are styled nowhere but
|
||||
* bootstrap's mobile.css — so a theme that ignores them renders a phone layout its own app JS
|
||||
* believes it has already handled. */
|
||||
/* A column the emitting JS asks to DROP on a phone: the wireless Associated Stations table marks
|
||||
* its MAC column .hide-xs, and stock LuCI shows Network/Host/Signal/Rate and no MAC.
|
||||
*
|
||||
* The `.table .td.hide-xs` half is not redundant: the stacking rules select the cell as
|
||||
* `table.fs-dt .td` (0,2,1) and `.cbi-section-table .td` (0,2,0), both outranking a bare
|
||||
* `.hide-xs` (0,1,0), so the cell keeps their
|
||||
* `display: block` and stays visible. Stock LuCI wins this with !important; one class more
|
||||
* wins it without one. */
|
||||
/* stock phone contract; `.td.hide-xs` not redundant, stacking rules (0,2,1)/(0,2,0) outrank a bare `.hide-xs` (0,1,0) */
|
||||
.hide-sm, .hide-xs,
|
||||
.table .td.hide-sm, .table .td.hide-xs,
|
||||
.table .th.hide-sm, .table .th.hide-xs { display: none; }
|
||||
/* The `.col-N` weights used to live here, inside the phone tier. They are UNGUARDED now,
|
||||
* in theme/30-tables.css: `flex` is inert on anything that is not a flex item, and a `.td`
|
||||
* becomes one exactly when its table cards — so one copy covers the phone tier, the
|
||||
* measured stack at any width and the config table's @container, with no second copy to
|
||||
* keep in step. See the block there. */
|
||||
/* .col-N weights UNGUARDED now, theme/30-tables.css: flex is inert on a non-flex item */
|
||||
|
||||
/* iOS Safari zooms the page in when a focused control's text is under 16px and never zooms
|
||||
* back out — the theme's 13px inputs did that on every form. Stock LuCI needs !important
|
||||
* for it; here the theme layer is enough. */
|
||||
/* iOS Safari zooms in when focused text is under 16px and never zooms back out */
|
||||
input, textarea, select, .cbi-dropdown > ul > li { font-size: var(--fs-type-lg); }
|
||||
}
|
||||
|
||||
/* Dropdown list: anchored to its widget, never full-bleed.
|
||||
*
|
||||
* ui.js has a touch branch (`'ontouchstart' in window`) that stretches the open list across the
|
||||
* viewport with INLINE offsets — `ul.style.left = ${-rect.left}px`, `ul.style.right =
|
||||
* ${rect.right - vpWidth}px` — which assume the list's containing block is the
|
||||
* viewport. It is not: .cbi-dropdown is position: relative, so a negative `left` drags the list
|
||||
* off to the side and clips the labels. An author !important is the only thing that outranks an
|
||||
* inline declaration, so pin it back under the widget. Harmless on desktop, where ui.js sets
|
||||
* only top/bottom/max-height.
|
||||
*
|
||||
* Only left/right carry the flag — those are the two properties ui.js writes inline. The widths
|
||||
* never appear in an inline style (ui.js only CLEARS `ul.style.width`, on close), so they win
|
||||
* from this layer on their own. */
|
||||
/* anchored to its widget: ui.js's touch branch assumes the viewport as containing block; !important beats the inline style */
|
||||
.cbi-dropdown[open] > ul.dropdown {
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: auto !important;
|
||||
min-width: 100%;
|
||||
/* desktop only: min(92vw,420px) ignores trigger's left offset, grew past viewport under 767px (113 findings, task audit-09) */
|
||||
width: max-content;
|
||||
max-width: min(92vw, 420px);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* phone tier, after the rule above (later wins): trigger already capped to field width, `auto` keeps the popup on-screen */
|
||||
.cbi-dropdown[open] > ul.dropdown { width: auto; }
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
@layer theme {
|
||||
/* prefers-reduced-motion — a medical accommodation: vestibular disorders make sliding, fading and
|
||||
* spinning UI genuinely nauseating. Honoured globally rather than per component; every
|
||||
* transition and keyframe in this theme is decoration and none carries information.
|
||||
*
|
||||
* The !important here is the documented exception, and not the same kind as the ones that fight
|
||||
* an inline `style=`: a cascade layer cannot help, since styles/base sets transitions too and
|
||||
* !important INVERTS the layer order, so only an important declaration reaches all of them at
|
||||
* once. This is the canonical form as published by the WCAG working group. DO NOT COPY THE FLAG
|
||||
* OUT OF THIS BLOCK.
|
||||
*
|
||||
* .01ms rather than 0s: a zero duration means `transitionend`/`animationend` never fire, and JS
|
||||
* that waits for one (LuCI's modal teardown does) would hang. `scroll-behavior` is on the list
|
||||
* because smooth scrolling is motion too, and a jump is what this user asked for. */
|
||||
/* honoured globally, every transition here is decoration. !important is the one sanctioned
|
||||
* exception, a layer cannot reach base's own transitions — DO NOT COPY THE FLAG OUT OF THIS
|
||||
* BLOCK. .01ms not 0s: a zero duration never fires transitionend, LuCI's teardown waits for one. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
@@ -21,42 +11,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-reduced-transparency — the user asked the OS for opaque surfaces. Every surface in the
|
||||
* theme is opaque already: no blur, an opaque bar, opaque pop surfaces. The one thing left is the
|
||||
* wash over an uploaded photo. */
|
||||
/* every surface is already opaque except the wallpaper's wash */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:root {
|
||||
/* the uploaded background is a translucent wash over a photo — exactly the effect this
|
||||
* user asked the OS to drop. Make the scrim opaque so the canvas is flat, not a photo
|
||||
* ghosting through (16-login-bg.css lays it over the image as one background layer). */
|
||||
/* flattens the photo wash to opaque (16-login-bg.css) */
|
||||
--fs-photo-scrim: var(--fs-bg);
|
||||
}
|
||||
}
|
||||
|
||||
/* prefers-contrast — the user asked the OS for MORE contrast but, unlike forced-colors, left the
|
||||
* palette to us. Same mechanism as reduced-transparency: re-state the tokens and every rule that
|
||||
* reads them follows. What moves and why:
|
||||
* - the hairlines: --fs-border is the most-read line in the theme and sits at ~20% of the
|
||||
* text's contrast, so it is pulled toward --fs-text. The role hairlines (-line/-line-hi, a
|
||||
* 40/55% tint of their role) go to the FULL role colour — a tint exists to be quiet, which
|
||||
* is the opposite of what this user asked for.
|
||||
* - secondary text: --fs-dim toward --fs-text, and --fs-faint most of the way there too
|
||||
* (10-11px eyebrow labels are where AA erodes first). Not all the way ONTO --fs-dim: the two
|
||||
* are the export tier's --text-color-medium and -low, so collapsing them republishes one
|
||||
* colour twice to every app reading the gradation. 82% is a window tools/export-tier.mjs
|
||||
* measures rather than takes on trust: below 80 the fainter ink drops under AA on footstrap
|
||||
* dark's --fs-panel2 (4.51:1 at 80), above 82 the high-to-low ramp falls under the 0.10
|
||||
* spread the tier promises (0.098 at 84); 82 clears both, at 4.68:1 and 0.110.
|
||||
* - --fs-placeholder, in BOTH modes. It is the one token the theme ships deliberately under AA
|
||||
* (3.99-4.48:1 light, 3.43-6.05:1 dark): a hint has to move far enough not to read as a
|
||||
* typed value, and a dark palette has only 6.45:1 of ink to spend. A reader who asked the OS
|
||||
* for more contrast has said which of the two they want, so the mixes go back to the
|
||||
* AA-clearing 62% and 80% — 4.98:1 and 4.61:1, at 37% and 20% of the ink-to-field range
|
||||
* instead of 44% and 34%. Only --fs-placeholder-mix moves: the mix itself is spelt once, in
|
||||
* 03-palettes.css. `placeholder-ink` measures this pass as well.
|
||||
* - the focus ring: --fs-focus-ring is a tint of the accent (--fs-accent-soft, 10% light and
|
||||
* 15% dark), so at "more contrast" it carries the accent at 60% instead. Its sibling
|
||||
* --fs-focus-ring-solo is already the solid accent and needs no restatement here. */
|
||||
/* re-states the tokens so every rule reading them follows — docs/css.md "A11y: prefers-contrast token shifts, and the numbers behind them" has why each number */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--fs-border: color-mix(in srgb, var(--fs-text) 45%, var(--fs-bg));
|
||||
@@ -65,34 +28,18 @@
|
||||
--fs-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fs-accent) 60%, transparent);
|
||||
--fs-accent-line: var(--fs-accent); --fs-accent-line-hi: var(--fs-accent);
|
||||
--fs-good-line: var(--fs-good); --fs-good-line-hi: var(--fs-good);
|
||||
--fs-warn-line: var(--fs-warn); --fs-warn-line-hi: var(--fs-warn);
|
||||
--fs-danger-line: var(--fs-danger); --fs-danger-line-hi: var(--fs-danger);
|
||||
/* the placeholder is the one token shipped under AA (see the note above); this reader
|
||||
* asked for contrast, so both modes buy the AA ink back, at the percentages that clear
|
||||
* it. Light here, dark in the block below — per mode exactly as in 03-palettes.css. */
|
||||
/* the one token shipped under AA at rest; both modes buy the ink back here */
|
||||
--fs-placeholder-mix: 62%;
|
||||
}
|
||||
:root[data-darkmode="true"] { --fs-placeholder-mix: 80%; }
|
||||
}
|
||||
|
||||
/* forced-colors (Windows High Contrast) — the OS replaces every colour the theme picked, and
|
||||
* background-color on most elements is forced to the system canvas, so any state carried by
|
||||
* BACKGROUND alone vanishes: the active tab pill, the checked toggle and the selected dropdown
|
||||
* row all look exactly like their inactive siblings, which is a correctness bug rather than a
|
||||
* cosmetic one. Re-state those states with a border or outline, which forced-colors keeps, in
|
||||
* system colour keywords so they follow the user's scheme. */
|
||||
/* forced-colors: background-color is forced to the system canvas, so state carried by
|
||||
* BACKGROUND alone vanishes (active tab, checked toggle, selected row) — re-stated with a
|
||||
* border/outline in system keywords, which forced-colors keeps — docs/css.md "A11y:
|
||||
* forced-colors state repairs". */
|
||||
@media (forced-colors: active) {
|
||||
/* Active tab, selected dropdown row, checked toggle: outline the state.
|
||||
*
|
||||
* `.ifacebox-head.active` and Apply join the list rather than getting a rule of their own,
|
||||
* being the same KIND of state — carried by a background, with no word to fall back on — and
|
||||
* the same repair. "This port is up" was indistinguishable from a dead port, and Save & Apply
|
||||
* did not read as a BUTTON at all: it is a .cbi-dropdown wrapper, so with its fill forced to
|
||||
* Canvas only the label inside the <li> kept a highlight.
|
||||
*
|
||||
* Not extended to the alert and .label variants: encoding severity in border-style is not a
|
||||
* scale anyone can learn, and the honest repair is a word or an icon in the markup, which is
|
||||
* ui.js's. */
|
||||
#tabmenu ul.tabs > li.active > a,
|
||||
ul.cbi-tabmenu.mode > li.cbi-tab > a,
|
||||
.tabs > li.active > a,
|
||||
@@ -106,21 +53,12 @@
|
||||
background: Highlight;
|
||||
forced-color-adjust: none; /* keep the fill: it IS the state */
|
||||
}
|
||||
/* Which page am I on: the menu's active item carries its state in exactly the two things forced
|
||||
* colors drops, a 10%-alpha accent background and the box-shadow rail. Measured with the query
|
||||
* emulated, all twelve #topmenu links reported the same weight, shadow, border and colour, so
|
||||
* the current page was not marked at all. `aria-current="page"` is on the link, so this was a
|
||||
* sighted-user loss only. The rail comes back as a real border, which forced colors keeps. */
|
||||
/* active #topmenu state was only a 10%-accent background + box-shadow rail, both dropped */
|
||||
#topmenu li.active > a {
|
||||
border-inline-start: 3px solid Highlight;
|
||||
padding-inline-start: calc(var(--fs-space-3) - 3px);
|
||||
}
|
||||
/* the toggle switch is a bare rounded box in forced colors — give the knob and
|
||||
* the track an edge so on/off is distinguishable at all. Scoped via `input[type="checkbox"]`,
|
||||
* matching 60-inputs.css, which now draws the switch there: an `<input>` selector cannot
|
||||
* reach ui.Checkbox's `tooltip` label (the hint-icon one, unstyled otherwise, built with no
|
||||
* `for` attribute) by construction, so the old `[for]` discriminator is no longer needed to
|
||||
* keep that label from growing a border it does not own. */
|
||||
/* input[type=checkbox] (60-inputs.css draws the switch there) cannot reach ui.Checkbox's tooltip label */
|
||||
.cbi-checkbox > input[type="checkbox"] { border: 1px solid ButtonBorder; }
|
||||
.cbi-checkbox > input[type="checkbox"]:checked {
|
||||
background: Highlight;
|
||||
@@ -130,8 +68,7 @@
|
||||
.cbi-checkbox > input[type="checkbox"]:checked::after { background: HighlightText; }
|
||||
/* icon-only buttons (rail toggle, search/appearance, logout) lose their hover fill */
|
||||
.fs-railtoggle, .fs-themerow { border-color: ButtonBorder; }
|
||||
/* the focus ring is a box-shadow everywhere in this theme, and box-shadow is
|
||||
* dropped entirely in forced-colors — so keyboard focus would be invisible */
|
||||
/* box-shadow is dropped entirely in forced-colors */
|
||||
:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
@layer theme {
|
||||
/* ---- print ----
|
||||
* A LuCI page ends up on paper or in a PDF as documentation, and without this block it prints
|
||||
* the dark chrome, the sticky bar and — in the sidebar layout — ONE page, the shell being
|
||||
* height:100svh with overflow:hidden there.
|
||||
*
|
||||
* No `!important`: tools/audit.py allows the flag only in its listed files, and nothing here
|
||||
* fights an inline style. Where a rule must beat a same-layer
|
||||
* chrome rule it wins on SPECIFICITY — a repeated class at (0,4,0), one step over the layout
|
||||
* guards' (0,3,1) — never on this file sorting after 20-shell.css. */
|
||||
/* without this a page prints the dark chrome and only ONE page (shell is 100svh,
|
||||
* overflow:hidden). No !important: a chrome rule is beaten on SPECIFICITY, (0,4,0) over (0,3,1). */
|
||||
@media print {
|
||||
/* Ink-friendly surfaces whatever palette/mode is active: this block sits in the
|
||||
* theme layer, so it outranks every tokens-layer palette block without naming
|
||||
* data-darkmode or the palettes. Every rule reads the tokens, so one re-statement
|
||||
* re-paints the whole page. */
|
||||
/* theme layer outranks every tokens-layer palette block without naming data-darkmode */
|
||||
:root {
|
||||
--fs-bg: #fff; --fs-panel: #fff; --fs-panel2: #f4f4f4;
|
||||
--fs-text: #000; --fs-dim: #333; --fs-faint: #555;
|
||||
@@ -20,24 +10,21 @@
|
||||
--fs-shadow-pop: none;
|
||||
--fs-bar-bg: #fff;
|
||||
}
|
||||
/* Navigation, the search overlay and the action buttons cannot be used on paper. The
|
||||
* Appearance popover was on this list; it is a PAGE now, and a page prints — an admin
|
||||
* printing System -> Appearance wants what is on it. */
|
||||
/* nav/search/actions unusable on paper; Appearance is a PAGE now, stays and prints */
|
||||
.fs-sidebar.fs-sidebar.fs-sidebar.fs-sidebar,
|
||||
.fs-skip.fs-skip,
|
||||
.fs-search-ov.fs-search-ov,
|
||||
.cbi-page-actions.cbi-page-actions,
|
||||
.cbi-tabmenu.cbi-tabmenu,
|
||||
footer { display: none; }
|
||||
/* Undo the sidebar layout's scroll frame — the ONE-page bug above. */
|
||||
/* undoes the sidebar layout's scroll frame, the ONE-page bug above */
|
||||
.fs-shell.fs-shell.fs-shell.fs-shell { height: auto; overflow: visible; }
|
||||
.fs-main.fs-main.fs-main.fs-main { height: auto; overflow: visible; }
|
||||
.fs-content.fs-content { max-width: none; padding: 0; }
|
||||
/* same element as 15-wallpaper.css, later in the layer — the pattern stays off the paper */
|
||||
/* same element as 15-wallpaper.css, later in the layer */
|
||||
.fs-pattern.fs-pattern { display: none; }
|
||||
/* and the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
|
||||
/* the uploaded photo — ink, not decoration, on paper (16-login-bg.css) */
|
||||
:root[data-wallpaper="file"] :is(.fs-shell, body:not(:has(.fs-shell))) { background-image: none; }
|
||||
/* keep a card in one piece where the page allows it */
|
||||
.cbi-section { break-inside: avoid; }
|
||||
}
|
||||
}
|
||||
@@ -1 +1,35 @@
|
||||
{% include('themes/footstrap/partials/footer') %}
|
||||
{#
|
||||
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
|
||||
markup, morphed by :root[data-layout].
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (!blank_page): %}
|
||||
</div>{# /.fs-content #}
|
||||
|
||||
{#
|
||||
role="contentinfo" is explicit because a <footer> gets it implicitly only when
|
||||
its nearest ancestor is <body>, and this one must stay inside <main>: both
|
||||
layouts lay .fs-main out as a flex column and order the footer within it.
|
||||
The version strings are entityencode()d — /etc/openwrt_release is root-owned,
|
||||
but an unescaped `&` in a distname would still break the markup.
|
||||
#}
|
||||
<footer class="fs-footer" role="contentinfo">
|
||||
<span>
|
||||
Powered by
|
||||
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
|
||||
/
|
||||
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
|
||||
{% if (lua_active): %}
|
||||
/ {{ _('Lua compatibility mode active') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</footer>
|
||||
</main>{# /.fs-main #}
|
||||
</div>{# /.fs-shell #}
|
||||
<script>L.require('menu-footstrap')</script>
|
||||
<script>L.require('fs-select')</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -69,22 +69,10 @@
|
||||
it, so the CI gate cannot see the difference. */
|
||||
fs_defaults.layout = fsd.layout || config.main?.footstrap_layout || '';
|
||||
|
||||
/* Optional companion packages announce themselves in the theme's own config —
|
||||
`uci add_list footstrap.settings.plugin=<module>` from the package's uci-defaults — rather
|
||||
than being named in this tree. The chrome requires each name and knows nothing else about it,
|
||||
so the edge points inwards exactly as fs-router.onNavigate does.
|
||||
|
||||
Whitelisted to the shape of a LuCI module name before it is printed into a <script>: writing
|
||||
that file needs root, but a value that reaches an inline script is checked anyway. */
|
||||
let fs_plugins = fsd.plugin ?? [];
|
||||
if (type(fs_plugins) != 'array')
|
||||
fs_plugins = [ fs_plugins ];
|
||||
fs_plugins = filter(fs_plugins, (m) => type(m) == 'string' && match(m, /^[a-zA-Z0-9._-]{1,48}$/));
|
||||
|
||||
http.prepare_content('text/html; charset=UTF-8');
|
||||
-%}
|
||||
|
||||
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults, fs_plugins }) %}
|
||||
{% include('themes/footstrap/partials/head', { boardinfo, fs_defaults }) %}
|
||||
|
||||
{#
|
||||
data-page carries the DISPATCH path (ctx.path), not request_path: on a firstchild route
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{#
|
||||
Footer body. One layout template and one client menu renderer: sidebar and top bar are the same
|
||||
markup, morphed by :root[data-layout].
|
||||
Licensed to the public under the Apache License 2.0.
|
||||
-#}
|
||||
{% if (!blank_page): %}
|
||||
</div>{# /.fs-content #}
|
||||
|
||||
{#
|
||||
role="contentinfo" is explicit because a <footer> gets it implicitly only when
|
||||
its nearest ancestor is <body>, and this one must stay inside <main>: both
|
||||
layouts lay .fs-main out as a flex column and order the footer within it.
|
||||
The version strings are entityencode()d — /etc/openwrt_release is root-owned,
|
||||
but an unescaped `&` in a distname would still break the markup.
|
||||
#}
|
||||
<footer class="fs-footer" role="contentinfo">
|
||||
<span>
|
||||
Powered by
|
||||
<a href="https://github.com/openwrt/luci" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.luciname, true) }} ({{ entityencode(version.luciversion, true) }})</a>
|
||||
/
|
||||
<a href="{{ entityencode(version.disturl ?? '#', true) }}" target="_blank" rel="noreferrer">
|
||||
{{ entityencode(version.distname, true) }} {{ entityencode(version.distversion, true) }} ({{ entityencode(version.distrevision, true) }})</a>
|
||||
{% if (lua_active): %}
|
||||
/ {{ _('Lua compatibility mode active') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</footer>
|
||||
</main>{# /.fs-main #}
|
||||
</div>{# /.fs-shell #}
|
||||
<script>L.require('menu-footstrap')</script>
|
||||
<script>L.require('fs-select')</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,10 +21,21 @@
|
||||
let _sd_pink = index([ 'theme', 'original' ], fs_defaults.pattern_ink) >= 0 ? fs_defaults.pattern_ink : '';
|
||||
let _sd_ac = index([ 'on', 'off' ], fs_defaults.autocollapse) >= 0 ? fs_defaults.autocollapse : '';
|
||||
let _sd_dens = index([ 'compact', 'normal', 'large' ], fs_defaults.density) >= 0 ? fs_defaults.density : '';
|
||||
/* Six numeric axes share this shape: '' (unset) must map to -1, never to 0, because 0 is a real
|
||||
value for every one of them (sharp corners, a transparent scrim, no tile). */
|
||||
function _sd_range(raw, min, max) {
|
||||
let n = int(raw);
|
||||
return (raw != '' && n >= min && n <= max) ? n : -1;
|
||||
}
|
||||
/* a cache-bust TOKEN (the cgi-upload md5 for an uploaded background/pattern/font sheet), not a
|
||||
path — the served path is fixed. Whitelisted to hex so a hand-edited config cannot inject
|
||||
markup into an inline script that interpolates it. */
|
||||
function _sd_token(v) {
|
||||
return (type(v) == 'string' && match(v, /^[a-f0-9]{6,64}$/)) ? v : '';
|
||||
}
|
||||
/* issue #44: how far the content column may grow past --fs-content-max's 1280px default.
|
||||
header.ut's FS_AXES carries 'content_width', so a saved router default reaches this. */
|
||||
let _cw = int(fs_defaults.content_width);
|
||||
let _sd_cwidth = (fs_defaults.content_width != '' && _cw >= 1280 && _cw <= 3840) ? _cw : -1;
|
||||
let _sd_cwidth = _sd_range(fs_defaults.content_width, 1280, 3840);
|
||||
/* The five colour axes each hold one of three things: '' or '0' for off, '1'..'360' for a hue,
|
||||
or '#rrggbb'. Both live forms reach the client as a string, and the pre-paint re-reads which
|
||||
it is the way fs-prefs.js's normColor() does, so this passes through exactly those two shapes
|
||||
@@ -53,32 +64,14 @@
|
||||
let _sd_ctrl = _sd_hex(fs_defaults.control);
|
||||
let _sd_bar = _sd_hex(fs_defaults.bar);
|
||||
let _sd_line = _sd_hex(fs_defaults.line);
|
||||
let _rr = int(fs_defaults.rounding);
|
||||
/* rounding 0 is SHARP corners, a real choice, so '' (unset) must map to -1, not 0 */
|
||||
let _sd_round = (fs_defaults.rounding != '' && _rr >= 0 && _rr <= 20) ? _rr : -1;
|
||||
/* login_bg is the cache-bust TOKEN for the uploaded background (the cgi-upload md5), not the
|
||||
image and not a path — the served path is fixed. Whitelisted to hex so a hand-edited config
|
||||
cannot inject markup into the inline script that builds the url(). */
|
||||
let _lb = fs_defaults.login_bg;
|
||||
let _sd_lbg = (type(_lb) == 'string' && match(_lb, /^[a-f0-9]{6,64}$/)) ? _lb : '';
|
||||
/* the Tint's strength 0-200 (% of the designed chroma). Unset -> -1, like rounding, because 0
|
||||
is a real value. */
|
||||
let _ts = int(fs_defaults.tint_strength);
|
||||
let _sd_tstr = (fs_defaults.tint_strength != '' && _ts >= 0 && _ts <= 200) ? _ts : -1;
|
||||
/* the File photo's scrim opacity 0-100; unset -> -1, 0 being a real value (a transparent
|
||||
scrim) */
|
||||
let _pd = int(fs_defaults.photo_dim);
|
||||
let _sd_pdim = (fs_defaults.photo_dim != '' && _pd >= 0 && _pd <= 100) ? _pd : -1;
|
||||
/* the uploaded pattern's cache-bust token, whitelisted like login_bg and for the same reason;
|
||||
the served path is fixed */
|
||||
let _pt = fs_defaults.pattern;
|
||||
let _sd_pat = (type(_pt) == 'string' && match(_pt, /^[a-f0-9]{6,64}$/)) ? _pt : '';
|
||||
/* the pattern's tile edge in px and the layer's opacity 0-100; unset -> -1, since 0 is a real
|
||||
strength and 0 px is not a tile */
|
||||
let _ps = int(fs_defaults.pattern_size);
|
||||
let _sd_psize = (fs_defaults.pattern_size != '' && _ps >= 40 && _ps <= 1600) ? _ps : -1;
|
||||
let _pst = int(fs_defaults.pattern_strength);
|
||||
let _sd_pstr = (fs_defaults.pattern_strength != '' && _pst >= 0 && _pst <= 100) ? _pst : -1;
|
||||
let _sd_round = _sd_range(fs_defaults.rounding, 0, 20);
|
||||
let _sd_lbg = _sd_token(fs_defaults.login_bg);
|
||||
let _sd_tstr = _sd_range(fs_defaults.tint_strength, 0, 200);
|
||||
let _sd_pdim = _sd_range(fs_defaults.photo_dim, 0, 100);
|
||||
let _sd_pat = _sd_token(fs_defaults.pattern);
|
||||
/* the pattern's tile edge in px and the layer's opacity 0-100 */
|
||||
let _sd_psize = _sd_range(fs_defaults.pattern_size, 40, 1600);
|
||||
let _sd_pstr = _sd_range(fs_defaults.pattern_strength, 0, 100);
|
||||
/* ---- the fonts ----
|
||||
The theme carries no faces, so the two stacks in styles/02-tokens.css are what everyone gets
|
||||
unless the router names something else here. Two independent options, either usable alone:
|
||||
@@ -121,8 +114,7 @@
|
||||
}
|
||||
let _sd_fsans = _sd_family(fs_defaults.font_sans);
|
||||
let _sd_fmono = _sd_family(fs_defaults.font_mono);
|
||||
let _ft = fs_defaults.fonts;
|
||||
let _sd_ftok = (type(_ft) == 'string' && match(_ft, /^[a-f0-9]{6,64}$/)) ? _ft : '';
|
||||
let _sd_ftok = _sd_token(fs_defaults.fonts);
|
||||
/* ---- writing direction ----
|
||||
Nothing in LuCI emits `dir`: the dispatcher stamps `lang` and stops, so a router in Arabic,
|
||||
Persian or Hebrew renders right-to-left text inside a left-to-right document — paragraphs
|
||||
@@ -150,7 +142,6 @@
|
||||
{# The router-wide Appearance defaults, read by both the pre-paint below and fs-prefs.js's
|
||||
current*(), so the Appearance controls show the effective default when this browser has no
|
||||
localStorage. See the sanitiser above for the unset encodings. #}
|
||||
<script>window.__fsPlugins={{ sprintf('%J', fs_plugins) }};</script>
|
||||
<script>window.__fsSD={layout:"{{ _sd_lay }}",darkmode:"{{ _sd_dark }}",palette:"{{ _sd_pal }}",wallpaper:"{{ _sd_wall }}",tint:"{{ _sd_tint }}",accent:"{{ _sd_acc }}",good:"{{ _sd_good }}",warn:"{{ _sd_warn }}",danger:"{{ _sd_dang }}",card:"{{ _sd_card }}",control:"{{ _sd_ctrl }}",bar:"{{ _sd_bar }}",line:"{{ _sd_line }}",rounding:{{ _sd_round }},autocollapse:"{{ _sd_ac }}",login_bg:"{{ _sd_lbg }}",tint_strength:{{ _sd_tstr }},photo_dim:{{ _sd_pdim }},density:"{{ _sd_dens }}",pattern:"{{ _sd_pat }}",pattern_size:{{ _sd_psize }},pattern_strength:{{ _sd_pstr }},pattern_ink:"{{ _sd_pink }}",content_width:{{ _sd_cwidth }}};</script>
|
||||
<script>
|
||||
/* Each IIFE below pre-paints one Appearance axis before the first frame, duplicating a
|
||||
@@ -166,7 +157,7 @@
|
||||
Precedence for every axis: localStorage ?? router default (window.__fsSD) ?? built-in.
|
||||
Dark mode: a saved preference, else the router default, else the OS. */
|
||||
(() => {
|
||||
const root = document.querySelector(':root'),
|
||||
const root = document.documentElement,
|
||||
sd = (window.__fsSD || {}).darkmode;
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem('fs-darkmode'); } catch (e) {}
|
||||
@@ -210,7 +201,7 @@
|
||||
applier's lsGet means. The outer try is the last resort for everything else. */
|
||||
const lsGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
||||
try {
|
||||
const root = document.querySelector(':root'),
|
||||
const root = document.documentElement,
|
||||
sd = window.__fsSD || {};
|
||||
let p = lsGet('fs-palette');
|
||||
/* A stray or retired palette name needs no migration here: it fails the list
|
||||
@@ -351,7 +342,7 @@
|
||||
(() => {
|
||||
try {
|
||||
if (localStorage.getItem('fs-rail') === 'true')
|
||||
document.querySelector(':root').setAttribute('data-rail', 'true');
|
||||
document.documentElement.setAttribute('data-rail', 'true');
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
@@ -362,7 +353,7 @@
|
||||
try {
|
||||
const saved = localStorage.getItem('fs-layout');
|
||||
if (saved === 'top' || saved === 'sidebar')
|
||||
document.querySelector(':root').setAttribute('data-layout', saved);
|
||||
document.documentElement.setAttribute('data-layout', saved);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="cbi-value cbi-value-last">
|
||||
<label class="cbi-value-title" for="luci_password">{{ _('Password') }}</label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" />
|
||||
<input class="cbi-input-text" type="password" name="luci_password" id="luci_password" autocomplete="current-password" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
{#
|
||||
@@ -181,11 +181,6 @@
|
||||
<script>window.__fsHttps={ports:{{ https_ports }},resource:"{{ resource }}"};</script>
|
||||
<script>
|
||||
(() => {
|
||||
const input = document.getElementsByName('luci_password')[0];
|
||||
|
||||
if (input)
|
||||
input.focus();
|
||||
|
||||
/* if the router also serves HTTPS, hop over to it before the password goes over this
|
||||
* connection; the <img> probe is upstream's trick for proving the TLS port answers first
|
||||
*
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
# A missing translation cannot fail loudly — an uncompiled _() falls through to its English msgid
|
||||
# and nothing reports it — so --check is a gate rather than a suggestion.
|
||||
#
|
||||
# The directory is `po/`, which is what LUCI_LANGUAGES globs and what Weblate translates. Naming it
|
||||
# `i18n/` stops luci.mk emitting a per-language package, which mattered while a self-updater
|
||||
# resolved the theme by name and took head -1 (issue #6); that updater is retired and owfeed builds
|
||||
# one artifact per format either way, so the rename only cost the catalogue its visibility to the
|
||||
# translation platform.
|
||||
# The directory is `po/`, which is what LUCI_LANGUAGES globs and what Weblate translates
|
||||
# (docs/package.md, "The catalogue lives in po/" — do not rename it).
|
||||
#
|
||||
# Nothing here runs on the buildbot — luci.mk calls po2lmo itself. This needs perl and gettext,
|
||||
# which the OpenWrt build does not.
|
||||
@@ -42,11 +39,9 @@ done
|
||||
# Prefer a scanner from a local LuCI checkout ($LUCI_SRC) so the gate is not hostage to the
|
||||
# network; fall back to fetching it. jsmin.c is pinned the same way in CI.
|
||||
#
|
||||
# ONE trap, covering every temp and every exit. It used to be installed inside the fetch branch
|
||||
# only — so the LUCI_SRC branch had none at all — and the three mktemps below were cleaned by hand
|
||||
# at each success path. With `set -eu`, any failure in between (perl choking on a template, i.e.
|
||||
# exactly the stale-.pot session this script exists for) leaked them. `fetched` is what the trap
|
||||
# removes, so the LUCI_SRC path never deletes the user's own checkout.
|
||||
# ONE trap, covering every temp and every exit path, including LUCI_SRC's (docs/package.md,
|
||||
# "The catalogue lives in po/"). `fetched` is what it removes, so the LUCI_SRC path never deletes
|
||||
# the user's own checkout.
|
||||
fetched=''; fresh=''; old_ids=''; new_ids=''
|
||||
# shellcheck disable=SC2064 # expand nothing now: the names are assigned as the script proceeds
|
||||
trap 'rm -f "$fetched" "$fresh" "$old_ids" "$new_ids"' EXIT INT TERM
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=fastfetch
|
||||
PKG_VERSION:=2.68.1
|
||||
PKG_RELEASE:=15
|
||||
PKG_VERSION:=2.69.0
|
||||
PKG_RELEASE:=16
|
||||
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://codeload.github.com/fastfetch-cli/fastfetch/tar.gz/$(PKG_VERSION)?
|
||||
|
||||
Reference in new issue
Block a user