update 2026-09-20 01:22:33

This commit is contained in:
action committed 2026-09-20 01:22:33 +08:00
1 parent 42e0cfbec4
commit deb27aee9d
864 files changed
+180566 -42

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# Documentation
Developer documentation for `luci-theme-footstrap`. Installing and using the theme is in the
[repository README](../README.md).
Every page here stands on its own — start wherever your question is.
## Start here
| Page | Answers |
|---|---|
| [architecture.md](architecture.md) | What LuCI expects from a theme, and where footstrap plugs into it |
| [conventions.md](conventions.md) | The rules a patch must follow, and which gate holds each one |
| [development.md](development.md) | Bring up a dev router, push a change, prove it works |
| [crew.md](crew.md) | How a session is split between the lead thread and the role subagents |
## How it is built
| Page | Answers |
|---|---|
| [design-system.md](design-system.md) | Tokens, palettes, typography, scales, the appearance axes |
| [css.md](css.md) | The `styles/` tree, cascade layers, `build-css.sh`, proving a CSS change |
| [chrome.md](chrome.md) | Sidebar, icon rail, top bar, the menu renderer, the fit measurement |
| [spa-router.md](spa-router.md) | Client-side navigation: how it works and what it refuses to do |
| [third-party-apps.md](third-party-apps.md) | Coexisting with foreign `luci-app-*` |
## Shipping
| Page | Answers |
|---|---|
| [package.md](package.md) | Source tree, Makefile, uci-defaults, postinst/postrm |
| [ci.md](ci.md) | The CI job graph, packaging, installation and the trust chain |
| [releasing.md](releasing.md) | The pre-release checklist, the changelog contract, the runbook |
| [benchmark.md](benchmark.md) | The navigation benchmark: method and results |
## For authors of other packages
| Page | Answers |
|---|---|
| [luci-app-styling-guide.md](luci-app-styling-guide.md) | How to style a `luci-app-*` so it works under any theme |
| [luci-app-styling-guide_ru.md](luci-app-styling-guide_ru.md) | The same guide in Russian — the one page here that is mirrored |
There is also a self-contained developer portal with the colour-token grid, the component markup
and a style checker: <https://vizzletf.github.io/luci-theme-footstrap/>.
## Not documentation
| File | What it is |
|---|---|
| `gallery.html` | every widget LuCI can emit, with the real class names — what the a11y gate runs against |
| `devkit.src.html` | source of the developer portal |
| `devkit.html` | built from `devkit.src.html`, `gallery.html` and `02-tokens.css` by the `pages` job. Generated, gitignored, never committed |
| the playground | not a page here at all — recorded from a real router by `tools/playground/*` and CI's `playground` job, published as a release asset and fetched into Pages by `pages.yml`. [ci.md](ci.md) |
| `design/`, `screenshots/`, `img/` | reference mock-ups and README assets |
## Conventions of these pages
- One page, one job. If a page needs its own table of contents, it is probably two pages.
- One fact lives in one place. Pages link to each other rather than repeating.
- Every measurement carries the number it was made with, and every rule says what it protects
against — the alternative was usually tried and was worse.
- Documentation that has stopped being true is deleted, not annotated.
File diff suppressed because it is too large. Load diff
+336
View File
@@ -0,0 +1,336 @@
# Keeping the reader's place
A poll tick changes the height of something above the reader and the page under them moves. This
page is every mechanism the theme has for that: what each one is, the invariant it holds, and the
one number that justifies it. `fs-fit.js` cites a heading here.
**The findings are next door.** [anchoring-log.md](anchoring-log.md) carries every measurement, every
disproven diagnosis and every attempt that was measured and rejected, one heading per finding. This
page states the rule; that page says how it was learned. The chrome's half — which element a
correction may take, why data tables are excluded — is in [chrome.md](chrome.md).
The measurements are `tools/scroll-anchor.mjs` on the stands, `tools/floor-contract.mjs` for the
floor and `tools/fit-quiet.mjs` for the chrome.
## Who is responsible: `ENGINE_ANCHORS`
Chromium, Firefox and — since WebKit 26 — Safari implement **scroll anchoring**: the engine picks an
element the reader can see and moves the offset itself so that element stays put. The theme asks the
platform, `CSS.supports('overflow-anchor', 'auto')`, and never a browser name.
**The answer decides which half of `fs-fit.js` runs.** Where the engine anchors, growth above the
reader is its job and the theme only cleans up the residual (`lateDrift()`); where it does not, the
theme owns the whole correction (`anchorFor()` → `scheduleAnchor()` → `applyAnchor()`). Running both
is not a safety net: two corrections throw the page the other way, which is the fault the detection
exists to avoid.
The property no longer separates one engine from another — all three answer `true`. The 21-41px
WebKit was once read as mis-anchoring was the bar's own box walking 230 → 123px inside one
`fitChrome()` pass; with that pin held both ways the same probe reads 0px with no suppression at
all, and `ENGINE_MISANCHORS`/`data-fs-anchor-suppress` are gone from the tree
(anchoring-log.md, "Why WebKit looked like it was mis-anchoring").
## When the platform check is not enough: `_engineTrusted`
`ENGINE_ANCHORS` answers once, at load, whether the property EXISTS. An engine that has it and
declines to use it on a given refill is invisible to it, and that happens: `lateDrift()` had to
write the residual back on two passes of a real, trusting engine.
So the evidence accumulates instead of being guessed at. Each residual `lateDrift()` actually writes
increments `_lateMisses`; at `LATE_MISS_LIMIT` (2 — one is headroom for a one-off, a second is a
count) `_engineTrusted` goes false and every later refill takes the `anchorFor()`/`scheduleAnchor()`
path. `TRUST_RECOVERY_LIMIT` (2) refills where the remembered reference holds its own position put
it back. Never a browser name, only a count: Chromium and Firefox measure 0 residuals on the pages
the sweep covers, so the switch cannot trip for them.
| number | what it buys |
|---|---|
| `lateDrift()` on a page that was already still: 8-61ms; on one that was not: 419-420ms | the slow road is the price of telling a flick from a still page |
| `applyAnchor()`: 7-36ms | why distrust switches paths rather than waiting harder |
| `LATE_ROUND_TOLERANCE` 16px | the largest table-row rounding measured is 12.25px; 16 leaves 30% over it and stays far under half a 120px growth |
## How long to wait is a question about the reader
`lateDrift()` waits one frame and then decides. Where `scrolling()` and `_userUntil` both say nobody
is driving and nothing has moved, the only thing that can have touched the offset since the
reference was taken is the engine, whose window is 7-36ms — so it settles in the frame it already
has. Where the page is, or might be, in motion it waits `SCROLL_IDLE` instead.
**The fork decides HOW LONG to wait, never WHETHER to write.** The `scrollTop() !== seen` check runs
on both roads. Measured over nine `engine DECLINES` cells, three engines against three stands:
404-422ms → 8-61ms, with the engine-on (4-19ms) and engine-off (6-44ms) cells unmoved and `mid-flick
surprises` at 0 on all 27 (anchoring-log.md, "How long to wait: the fork that closed the 419 ms
road").
## Is the page moving: `scrolling()`
Asked of the scroll POSITION, one read per frame, never of the event stream: on iOS momentum carries
the page long after the finger has gone and events do not reliably arrive through it, so a timer off
the last event declares the reader still and drops a deferred pass into the middle of the glide.
`SCROLL_IDLE` is 400ms, and it is the fix for the shaking rather than a tuning knob: against an
imitated slow rock, 137-256px of roughness at 200ms and 59px — the floor, one pixel of rounding per
frame, the same as switching the fitters off — at 250ms and above.
Three markers keep this file from reading its own writes as the reader:
| marker | question it answers | who reads it |
|---|---|---|
| `_ownWrite` / `sawOwnWrite()` | is this pixel where my own last write landed? | the motion sampler and the `scroll` handler, both, for the same pixel |
| `_clampedTo` / `sawClamp()` | is the motion blocking this correction the clamp the correction is FOR? | `applyAnchor()` alone — `scrolling()` itself is untouched |
| `_userUntil` | is the reader DRIVING, as opposed to the page moving? | `lateDrift()` and `settleDeferredFloor()` |
`writeOffset()` is the one door either correction writes through, and it reads the offset back: a
write near either end of the document is clamped, and the pixel that lands is the one the next
`scroll` event reports.
## The document may not get shorter: `holdFloor()`
`dom.content()` — what every LuCI poll calls — empties a container before it refills it. A layout
taken while it is empty clamps the reader's offset into a document that was never really that short,
and nothing puts that back. So each container a poll empties carries a `min-height` at the height it
had at the last settled moment, written BEFORE the tick rather than during it.
Every floor is cleared, the box re-measured, and the floor written back where that answer is above
zero. Nine things are load-bearing, and each was a measured failure first:
- **On the containers, not on the column.** `min-height` on an ancestor of the engine's own anchor
suppresses its anchoring (css-scroll-anchoring-1 §2.2.2), so a floor on the column bought the
clamp back: 120px of growth moved the page all 120px, Chromium and Firefox alike.
- **Not on a table box.** `min-height` is undefined there (CSS 2.1 §10.7) and WebKit acts on it: a
`.table` wearing a 313px floor still collapsed to 30px and the document lost 284px. The floor
climbs to the first box that is not a table.
- **Written before the tick.** Pinning from inside the same statement sequence does nothing —
`dom.content()` performs no layout, so no layout ever sees the pin: 1882px still clamped away.
- **Cleared and re-measured, not read off the content.** The clear is what makes every other question
answer itself: a collapsed tab pane measures 0 and keeps no floor, where a content reader pinned
the collapse open (893px, issue #41). `tools/floor-contract.mjs` holds it: 175 floors, worst 0px
against the box.
- **Written at the box's own rect height, not `offsetHeight`.** An integer floor stands up to half a
pixel taller than its content; 22 such boxes made the document 2px taller with the floors than
without, and the next clear handed that back as a clamp — 6 of 12 refills corrected against 12 of
12 (anchoring-log.md, "The floor is written half a pixel too tall").
- **Found by its own mark.** A box qualifies through its CHILDREN and the climb can floor a box that
is not in `SHRINKS` at all, so emptying the table under it takes the section out of the sweep:
927px still standing 13 s later. Every floor carries `data-fs-floor` and the sweep looks for its
own mark.
- **Only the boxes a mutation touched are re-cleared.** 725 clears and 625 writes per 25 s of real
polling, of which 610 wrote back the value already standing; scoping to the mutation's own targets
reads 70/70 with `changed` unmoved at 15 (anchoring-log.md, "A box nothing touched must not be
re-cleared"). Every other caller passes no records and still gets the full sweep.
- **A tab switch, a fold and a `depends()` row wake the sweep too.** None of them moves a node, and
`min-height` beats the `height: 0` a hidden pane collapses with: 1299px of blank left standing on
Network → Interfaces, 258px on an unticked NTP client. `_moTabs` watches
`data-tab-active`/`hidden`/`aria-expanded` plus `class` where the element carries `data-field`.
- **A write that changed nothing is not a change.** The fitters re-apply their classes every pass by
design, so an unguarded `class` watch is a feedback loop: 391 callbacks in 432ms, 3910 records, the
tab never returning. `_moTabs` compares `oldValue` against what the attribute reads now — 2
callbacks, 20 records, 0 sweeps (anchoring-log.md, "A same-value class write is a feedback loop").
**The sweep puts back the offset its own clear pass took.** For the length of the measure pass the
document stands without its floors; 8 sweeps of 38 took it down one pixel and the offset with it,
and one pixel is enough for `lateDrift()` to read the page as moving and discard a 60px correction
whole. The restore is conditional on the scroller being as tall again as it was, which is what
separates the transient from a real shrink; a real shrink records the pixel the browser's clamp
landed on for `applyAnchor()` instead.
## A floor cleared late: `_deferredFloor` and `settleDeferredFloor()`
`holdFloor()` refuses while the reader is moving, so a floored box whose content really shrank loses
the shrink only until the next successful sweep — which used to run unwired to any correction. The
box is stashed by the mutation callback and consumed once the reader is still: the correction is the
OFFSET's own response to that one write, `wanted = offsetBefore - shrink` against the offset two
frames later, and not a reference element's drift — a reference re-established on an already-wrong
offset shows zero drift for ever. Without it the reader ended 58-60px off on WebKit @390, both
layouts (anchoring-log.md, "A floor that shrinks with nobody watching").
## The growth witness: `grew`
`lateDrift()`'s drift is read off whatever `anchorRef()` hit-tested at the fold, and that element
need not sit below the container this tick refilled. `grew` is the second witness: the refilled
box's height now against the `min-height` `holdFloor()` pinned it at, read in the mutation callback
where the height is already final.
- **The floored box the record sits IN**, through `closest(FLOORED)` — `dom.content()` refills a node
a level or two inside the pinned box on 1 of the 12 nodes a poll refills on the Overview.
- **Only growth ABOVE the reader's reference.** A box whose top is at or below the reference cannot
move it; counting it wrote the whole growth back and threw the reader 120px the other way, 8 cells
of 12 (anchoring-log.md, "Growth below the reader").
- **Not a number to write on its own.** Where the offset already moved (`compensated` non-zero) the
engine has done the work and the gap is this witness's own rounding on a many-row table; only a gap
over `LATE_ROUND_TOLERANCE` is a miss (anchoring-log.md, "The witness is not safe to write on its
own").
## What the reader was looking at: `anchorRef()` and the memo
`anchorRef()` is one hit test at the fold, below `[data-fs-chrome]` (the bar is sticky; a test at
y=1 returns the chrome and the page gets no anchor at all). It refuses `#view` itself — a host's own
top does not move when something inside it grows, so a drift measured against it is zero for ever —
and it refuses data tables, whose layout the fit pass deliberately falsifies mid-pass.
`rememberRest()` stores that element with its top, the offset it was taken at and the page stamp
(`_rest`, `_restAt`, `_restPage`), from the last moment the page was still. The section around it
travels too, as the fallback for the ordinary case where the tick replaces the element itself.
`rememberRest(true)` skips the `scrolling()` guard, and only the caller that has just written the
offset itself may use it: the engine's own compensation for the same mutation settles inside the
same `SCROLL_IDLE` window, so an ordinary call a millisecond after a write is refused and leaves the
memo describing the page from before the tick — the 47-60px of fabricated drift the next refill then
measures (anchoring-log.md, "The reference after this file's own write must be re-taken forced").
`forgetRest()` voids the memo: the router calls it on a client navigation, where the offset reset is
not a clamp to undo.
## The corrections
| | runs when | what it does |
|---|---|---|
| `applyAnchor()` via `scheduleAnchor()` | the engine does NOT anchor | the whole correction, one rAF after the mutation, against `anchorFor()`'s reference |
| `lateDrift()` | the engine anchors | only what the engine left behind, against the memo from before the tick — the frame it already has where the page was still, a frame plus `SCROLL_IDLE` where it was not |
| `settleDeferredFloor()` | a floored box shrank while the reader was moving | the part of that shrink the browser's clamp did not take |
Each ends in one write through `writeOffset()` and reads nothing back. A correction that repairs
another correction is what the 0.14.4-0.14.6 reports were about; two such mechanisms were measured
and removed (anchoring-log.md, "Two mechanisms measured and removed").
## What must never be corrected
- **A page the reader is moving.** `scrolling()` reads the offset over frames rather than trusting
the event stream. A correction landing inside a flick is itself a jump (161px, webkit/Overview).
- **A page the reader just acted on.** `_userUntil` holds the correction off after a deliberate act.
- **A page that is not the one the memo belongs to.** `_restPage` travels with the memo.
- **A batch that only took nodes away.** The empty half of a refill is a page about to stop
existing: arming on it wrote -834px for a refill that grew the page by 120. Superseding the armed
call with the later batch is worse — a real tick delivers around twenty records and every arm
cancels the last: 12 findings of ordinary drift (anchoring-log.md, "The later batch must not win").
- **A batch that emptied and refilled `#view` itself.** That is a page swap, not a refill — see "The
commit is not a refill" below.
## What the engine is told
`theme/30-tables.css` sets `overflow-anchor: none` on `.table.fs-dt` — the data tables the fit pass
re-lays. Without it the engine anchors inside a table whose layout the theme is about to falsify.
`theme/20-shell.css` carries no anchoring rule at all.
## The dev switches
Each is read at the point it gates and nowhere else, so a device that misbehaves can be asked which
half of this file is responsible.
| switch | effect |
|---|---|
| `localStorage.fsFit = 'off'` | no fitter runs, and no floor is written |
| `localStorage.fsAnchor = 'off'` | the theme writes the scroll offset nowhere |
| `localStorage.fsEngineAnchor = 'off'` | any engine takes the non-anchoring path — how the sweep reaches it, and how a Safari-only report is reproduced on a machine with no Safari |
## Navigation is a different question
`fs-router.js` keeps its own scroll memory (`_scrollMem`, `saveScroll`/`restoreScroll`) so Back
returns the reader where they were. That is per history entry, not per tick, and none of the above
applies to it — see [spa-router.md](spa-router.md).
### The scroll reset
A forward click resets both scrollers to the top — `window.scrollTo(0, 0)` and the same on
`#maincontent` — because a full load starts the new page there and the in-place swap has to match
it. That write runs in the same synchronous turn as `commitStage()`, at the swap, not at the click.
**Measured with the incoming module's fetch held open 1.2 s** (`owrt2512`): at the click, `y` reached
0 within 12 ms and stayed there for the whole staging window — the reader, still looking at the
OUTGOING page, found themselves at its top before they had any reason to be. Moved to the commit,
`y` holds the reader's own offset (1878px in the measured run) for the entire window and reaches 0 in
the same frame the incoming content replaces it, which is what a full load looks like.
**Why the corrections above are untouched by that move.** They all correct a scroller that is meant
to STAY PUT while the document under it changes. A forward navigation is the one case deliberately
moving the reader, to a page they chose. What does NOT move is `fit.forgetRest()`, still called at
the click: from that point the reader is committed to leaving, and a mutation the outgoing page's
poller makes during the staging window must not be read against a reference belonging to a page
about to go away.
### The commit is not a refill
`fit.forgetRest()` at the click is not enough: the commit arriving seconds later reaches `fs-fit.js`
as an ordinary content mutation and re-establishes a reference of its own. The browser delivers
`dom.content()` on the live `#view` as TWO batches; `run()`'s own `rememberRest()` fires between them
and adopts a reference measured mid-swap, which `lateDrift()` reads 431px out 420ms later and writes
back over a correct `restoreScroll()` (2723 → 2292.21875, chromium, Back to the Overview).
**The page stamp cannot see it, in any form.** `navigate()` restamps `body[data-page]` seconds
before the commit and `commitStage()` restamps `#view` and `.fs-content` before `dom.content()`, so
every stamp in the document already names the incoming page; carrying the stamp on the reference
instead is the same number twice.
**What holds.** A batch that both removes and adds children of the live `#view` is `dom.content()`'s
own shape on the one node only `commitStage()` calls it on. On such a batch the observer calls
`forgetRest()` and returns: the memo is void and the next tick re-takes one on a settled page. 3 of
3 red before, 3 of 3 green after, no `writeOffset()` logged at all. Testing the target alone does
not hold — `HOLD` grows the page by inserting a pad as `#view`'s own first child and matched too,
leaving 120px uncorrected on all three twins (anchoring-log.md, "What the sweep itself could not
see"; `spa-parity`'s `back-scroll` case is the gate).
## Is each one still needed
One mechanism disabled at a time, on the agent's own stand so nothing else moves, over the axes that
mechanism is supposed to hold. `tools/scroll-anchor.mjs`, `--width`/`--layout` to reach the cell and
`--full` to cross the rest; every number below is what the sweep printed. A cell is only counted
when it repeats — a lone finding on one pass is the parallel-stand noise `development.md` describes.
**The poll belongs to one case of the sweep, and that is part of the measurement.** `Poll.start()`
performs a tick synchronously (luci-base), so a case that restores the poll on its way out fires a
real tick into the case that runs next — HOLD doing that had SWAP measuring the floor under a live
tick, and CI reported 120px of unheld floor on a cell every other build calls green. HOLD and SWAP
therefore leave it stopped, and QUIET — whose subject IS ticks landing mid-flick — starts it before
it parks, with a tick's worth of time to land: started later, that first tick lands inside the very
flick being timed and the sweep reports the jump it came to look for (145.5px, firefox/owrt2410
@390 top compact). Both shapes are in [development.md](development.md), with what tells them apart
from a theme fault.
| mechanism | without it | needed |
|---|---|---|
| `holdFloor()` | reader moved 568px @390 top and 610px @1440 side; the clamp took 444px and 610px | yes — the largest effect of any of them |
| `holdFloor()` putting back the offset its own clear pass lost | `REPEAT`'s refill 2 or 3 on the same section left the reader -47px off, chromium `@390 top` on `/admin/network/dhcp`, `corrected never` and `engineTrusted` true throughout | yes — one pixel taken by the sweep is enough for `lateDrift()` to read the page as moving and discard a 60px correction whole |
| the floor written at the box's OWN height rather than `offsetHeight`'s rounding of it | 22 floors each stand up to half a pixel taller than their content, the document with them is 2px taller than without, and the next sweep's own clear hands that back: 6 of 12 refills corrected on `owrt2410b`/webkit `@390 top normal` against 12 of 12 | yes — it is what makes the clamp `holdFloor()`'s own restore is about, and the clamp with the theme out of the loop is harmless (12/12) |
| `applyAnchor()` seeing past the clamp's own scroll event | `REPEAT`'s refills 2 and 3 left the reader -60px off, `corrected never`, webkit `@1440 side compact` with the engine's anchoring ablated away — refused on `scrolling()` 11ms after the clamp that caused it, holding a correct -60px correction | yes — the shrink that is not fully clamped is invisible to every other mechanism here, and the reference is re-taken on top of it |
| `settleDeferredFloor()` | `REPEAT`'s refill 2 or 3 left the reader 58-60px off on WebKit @390, side and top, every other mechanism green throughout | yes, and narrowly: the ablation is `holdFloor()`'s own `scrolling()` guard being reached at all |
| `scheduleAnchor()` / `applyAnchor()` | 3 findings per scroller with the engine's anchoring off, every one the full 120px of growth: nobody corrects at all | yes, and it is the whole correction on Safari < 26 |
| `lateDrift()` | 120px on Overview and on Processes, both scrollers, with the engine anchoring | yes — the engine's residual is not small |
| `ENGINE_ANCHORS` | forcing "no engine anchors" on an engine that does: 120px on Processes | yes — the detection picks the path, and running both corrections is what throws the page the other way |
| `_engineTrusted` | an engine that anchors but declines on a given refill leaves every later correction on `lateDrift()`'s 419-420ms path instead of `applyAnchor()`'s 7-36ms one | yes, once `LATE_MISS_LIMIT` (2) residuals have been measured on the page — never trips where the engine keeps the reference itself (0 residuals on chromium/firefox) |
| `lateDrift()` settling in the frame it already has where the page was still | every refill a trusted engine declines is corrected 419ms after it happened instead of 8-61ms: 9 findings of 9 `engine DECLINES` cells, three engines, three twins | yes, and the ablation IS the third cell of that axis — nothing else can put the theme on `lateDrift()` as the only corrector on demand |
| the growth witness seeing past a blind reference (`closest(FLOORED)`, the positional guard) | a fold above the growing block leaves both witnesses blind and nothing is corrected (13 of 13 refills, 120px off); counting a box below the reader throws them 120px the other way (8 cells of 12) | yes — the two guards are one mechanism and each was a separate live failure |
| the guards on a page in motion (`scrollTop() !== seen`, `_userUntil`) | 6 findings per scroller, on BOTH engines and all three pages: the offset moved on its own mid-flick, worst 185-520px | yes, and it is the only mechanism here that fails on Chromium-class engines too |
| `anchorRef()` refusing to run while scrolling | nothing measurable | **not measurable here** — a cost guard, not a correctness one: every rect read there is a forced layout and this runs on every content mutation |
| `anchorRef()` refusing `#view` as the reference | nothing on the current pages | **not measurable here.** The hit test is retried across the viewport, so it finds real content where it used to land in a grid gap; the refusal keeps a future layout from anchoring on the host, whose own top never moves |
And four parts that carry the machinery rather than decide anything, so there is nothing to ablate:
| part | why it is not in the table above |
|---|---|
| `rememberRest()` and `_rest` / `_restAt` / `_restPage` | the memo itself. Removing it removes every correction at once, which is what the rows above already measure one at a time |
| `forgetRest()` | belongs to navigation, not to a tick: the router calls it when it resets both scrollers, and `spa-parity` is what covers that |
| `.table.fs-dt { overflow-anchor: none }` (`theme/30-tables.css`) | tells the ENGINE not to anchor inside a table whose layout the fit pass falsifies mid-pass. The sweep measures the theme's corrections, not the engine's choice of anchor |
| `_scrollMem` / `saveScroll` / `restoreScroll` (`fs-router.js`) | per history entry, not per tick — see [spa-router.md](spa-router.md) |
**Two of them the sweep cannot reach**, and that is a finding about the sweep. Both are held by the
measurement in their own comment rather than by a gate, so a change there is not caught by CI:
extend `tools/scroll-anchor.mjs` before touching one, or accept that the proof is historical.
## What the gate itself cannot answer
`lateWhy()`, `lateTrail()`, `anchorWhy()` and `anchorTrail()` exist because a correction that did
not write reads from outside as one symptom, `writes: []`, and `lateDrift()` has eight ways to reach
it. Each exit sets one short string with `performance.now()` beside it, the clock the sweep measures
the refill on, and the last eight are kept: a correction whose settle has not run yet and one that
ran and was re-armed both read `armed` at the end of the window otherwise (anchoring-log.md, "The
last eight exits, with the clock").
Nothing else can answer "has a reference been taken yet": `scrolling()` says no both before the
motion sampler starts and after it finishes, **1.5 s apart in WebKit**, and a flat wait instead made
`tools/scroll-anchor.mjs` report a jump on every WebKit run and none on the other two engines with
the theme identical on all three.
`restAt()` and `engineTrusted()` carry no `/* fs:probe */` marker on purpose: they are read by a
browser sweep against the INSTALLED package, and a probe marker is what packaging strips. Marked,
`restAt()` was stripped out and the sweep fell back to a flat wait — 14 findings on one router, every
one of them WebKit, and not a word about the missing method.
+216
View File
@@ -0,0 +1,216 @@
# How LuCI renders a theme
What LuCI expects from a theme on OpenWrt 24.10 and 25.12+, and where footstrap plugs into it.
Read this first if you are new to the codebase.
> Verified against `openwrt/luci` at the commit pinned in
> `luci-theme-footstrap/luci-upstream.pin`, against the `openwrt-24.10` branch, and on a live
> OpenWrt 25.12 router.
## A theme is a server shell plus client assets
LuCI runs on ucode, not Lua, since 23.05: dispatcher in `/usr/share/ucode/luci/dispatcher.uc`,
templates as `*.ut` under `/usr/share/ucode/luci/template/`.
**Page content is rendered on the client.** The server sends a shell — header, an empty
`<div id="view">`, footer — and `luci.js` instantiates the page's view module from
`/www/luci-static/resources/view/**`.
So a theme is **a server shell (header/footer in ucode) + CSS + client JS for the menu**. No Lua,
no server-side controllers.
```
view.ut
├─ include('header') → luci-base header.ut
│ ├─ include(`themes/${theme}/header`) ← the theme's header.ut
│ └─ <script src="…/luci.js"> + L = new LuCI({…env…})
├─ <div id="view"> + ui.instantiateView('<view>')
└─ include('footer') → luci-base footer.ut
└─ include(`themes/${theme}/footer`) ← the theme's footer.ut
```
The theme is never called directly: luci-base wraps it, and `luci.js` loads *after* the theme's
header.
## Both release lines, one template API
**24.10 and 25.12+ share the template API**, checked rather than assumed. 24.10 is already ucode
(`modules/luci-base/ucode/` exists there) and carries everything the theme uses: `ctx.path`,
`ctx.request_path`, `entityencode`, `striptags`, `dispatcher.build_url/lookup/lang`, `ubus.call`,
`pkgs_update_time`. The `L.env` blob luci-base's own `header.ut` prints is **byte-identical**
between branches, so `L.env.dispatchpath` — which the menu and the SPA router depend on — exists in
both.
**The one difference is the package manager**: apk on 25.12+, opkg/`.ipk` on 24.10. CI builds both
formats; `install.sh` detects which one the router runs.
## How LuCI picks a theme
From `runtime.uc`:
```ucode
let media = uci.get('luci', 'main', 'mediaurlbase'); // e.g. '/luci-static/footstrap'
let status = self.trycompile(`themes/${basename(media)}/header`);
if (status !== true) {
// fall back to the first theme in luci.themes whose header compiles
}
self.env.media = media; self.env.theme = basename(media);
```
Three things follow:
1. The theme name is `basename(mediaurlbase)`, and its templates must live in
`/usr/share/ucode/luci/template/themes/<name>/header.ut`.
2. **A header that does not compile does not brick the UI.** LuCI falls back to the first working
theme and shows a "Theme fallback" indicator carrying the error. Convenient while developing —
and the reason CI compiles every `.ut` with `ucode -T -c`: a stray brace would otherwise ship
green and quietly move every user to another theme.
3. `resource` is always `/luci-static/resources`, shared with luci-base. Themes must not touch it.
## What the header must do
1. `http.prepare_content('text/html; charset=UTF-8')`.
2. Emit `<!DOCTYPE html><html><head>…` with its own CSS.
3. Include `{{ resource }}/cbi.js` and the translations script.
4. Honour `node.css` and `css` — individual pages ask for extra stylesheets.
5. Open the content container and leave it **unclosed**; the footer closes it.
6. Honour `blank_page` — draw no chrome when it is true.
7. Emit the empty menu containers: `#topmenu`, `#tabmenu`, `#modemenu`, `#indicators`.
**`data-page` on `<body>` matters**: luci-base and app CSS target pages through it
(`[data-page="admin-status-overview"]`).
Variables a theme template gets: `theme`, `media`, `resource`, `ctx`, `dispatcher`, `dispatched`,
`node`, `version`, `config`, `blank_page`, `css`, `lua_active`, `http`, `ubus`.
### Where footstrap differs
- **One template directory**, `ucode/template/themes/footstrap/`. `header.ut` is thin; `<head>`,
brand, notices, logout and search live in `partials/*.ut`. Sidebar and top bar are **not a second
template** — they are `:root[data-layout]` plus CSS.
- **`data-page` is built from the dispatch path**, `join('-', length(ctx.path) ? ctx.path :
ctx.request_path)` — the reverse of bootstrap's order. On a firstchild route (`/admin/status`
renders overview) `request_path` is only `['admin','status']`, so `body[data-page=
'admin-status-overview']` rules and the overview include silently did not apply.
- The first element in `<body>` is a skip link; the content container is
`<main class="fs-main" id="maincontent" tabindex="-1">`; the menu is `<nav class="fs-sidebar">`.
Not `<aside>` — that carries the `complementary` role, which no landmark jump reaches.
- The document heading is `<h1 class="fs-title-main">` inside `.fs-title.fs-sr`, **clipped, not
`hidden`**: `hidden` is `display:none`, which drops the element out of the accessibility tree, so
the `<h1>` effectively did not exist while the SPA router dutifully updated it.
- `partials/head.ut` sends `cascade.css`, `node.css` and `cbi.js` with `?v={{ pkgs_update_time }}`
(uhttpd sends no `Cache-Control`) and passes every
interpolation through `entityencode(striptags(...))`.
## `sysauth.ut` is mandatory for a theme with its own chrome
luci-base's generic `sysauth.ut` opens with `{% include('header') %}` — **without `blank_page`** —
while the theme's header hides its whole shell behind `{% if (!blank_page) %}`. Without a
theme-local `sysauth.ut`, sidebar, menu and footer draw around the login form and every control in
them is dead: there is no session yet.
The theme's `sysauth.ut` exists for exactly one line:
```
{% include('header', { blank_page: true }) %}
```
**The form is rendered by the SERVER.** Bootstrap's pattern — a hidden `<section>` plus a client
view module — was tried here and produced a **blank page you could not log in from**: the view
bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
`render()` never runs. Server rendering works with JS off, cannot be broken by a rejected promise,
and needs no `luci-theme-bootstrap` installed. Do not go back.
Login strings deliberately carry **no `msgctxt`**, so luci-base translates them in the ~40 languages
this theme has no catalogue for — see [conventions.md](conventions.md).
## The menu is entirely client-side
The server header emits empty containers by id; client JS fills them. The footer loads the renderer
with `L.require('menu-footstrap')`, then `L.require('fs-select')`.
Menu JS lives in `htdocs/luci-static/resources/` — **not** in the theme directory — because
`L.require()` resolves against `resourcebase`. From there it pulls its dependencies through
`'require <module> as <name>'` pragmas:
```
menu-footstrap → ui, fs-fit, fs-prefs, fs-widgets, menu-footstrap-common
menu-footstrap-common → ui, fs-fit, fs-menutree, fs-chrome, fs-router,
fs-prefs, fs-sheets, fs-search
fs-select (no requirer — the footer loads it directly)
fs-overview (PAGE MODULE — required by menu-footstrap-common when
body[data-page] says the page is theirs, see below)
```
The graph is acyclic and the runtime enforces it: `require()` throws `DependencyError` on a cycle.
**One module is loaded by PAGE, not by pragma.** `fs-overview` reshapes Status → Overview, watches
`body[data-page]` and does nothing anywhere else, because that page is `luci-mod-status`'s own — a
theme may register a dispatcher node of its own (`fs-appearance` does, below), but that adds a
route, it does not hand over one another package already owns. A `'require'` pragma, though, is a
hard dependency — luci.js evaluates the module before the requirer's factory runs, on every admin
page — so `menu-footstrap-common.js`
carries a map from `data-page` to module name and requires the module when that page appears
(server-stamped or router-stamped, the same attribute either way). The module is unchanged, including
its own observer: `wire()` re-checks the page synchronously, so one that arrives after the stamp
still starts watching. The map repeats a page name the module also tests, and `npm run page-modules`
derives both sides and fails if they drift.
`fs-appearance` (the Appearance controls) used to be the map's other entry, stapled onto System →
System the same way. It now owns a route of its own, `/admin/system/footstrap`
(`root/usr/share/luci/menu.d/luci-theme-footstrap.json`, dispatched to
`view/footstrap/appearance.js`), which requires it by pragma like any ordinary view — an ordinary
dependency, not a page-watching module, because a theme with a route no longer needs the
`body[data-page]` workaround for the page it owns.
**There is no update checker, and nothing that loads one.** The installer adds the owfeed-packages
feed, so `apk upgrade` / `opkg upgrade` carries the theme forward, and a settings page has no
business reaching GitHub to reimplement that. `fs-version.js` reports the installed version with no
request at all. Nothing globs for an optional file at render time either: the wallpaper pattern is an
upload, so the server hands the client its cache-bust token like any other saved value.
Renderer and shell in detail: [chrome.md](chrome.md).
## Registration in UCI
```
config core 'main'
option mediaurlbase '/luci-static/footstrap' # the active theme
option resourcebase '/luci-static/resources'
config internal 'themes'
option Footstrap '/luci-static/footstrap' # the list the dropdown is built from
```
**Exactly one entry.** Bootstrap registers a symlinked theme per mode (`BootstrapDark`, …);
footstrap does not, because mode, palette and layout are client axes. Details:
[package.md](package.md).
## The runtime layout on the router
`luci.mk` maps `ucode/` → `/usr/share/ucode/luci`, `htdocs/` → `/www`, `root/` → `/`:
```
/usr/share/ucode/luci/template/themes/footstrap/
header.ut, footer.ut, sysauth.ut, partials/*.ut
/www/luci-static/footstrap/ cascade.css (generated), fonts/, logo.svg
(+ pattern.svg — a symlink to /etc/footstrap, uploaded)
/www/luci-static/resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
/usr/lib/lua/luci/i18n/footstrap-theme.<lang>.lmo catalogue, bundled INSIDE the theme package
/usr/share/rpcd/acl.d/luci-theme-footstrap.json ACL: uci footstrap + login-bg upload
/etc/uci-defaults/30_luci-theme-footstrap registration
```
## Where the boundary runs
The theme supplies the **chrome and the design language**. Page content is drawn by the view JS of
`luci-mod-*`, whose structure the app fixes.
The theme **can** style every stock widget through `cascade.css` and its tokens, own the sidebar,
top bar, logo and indicators, and re-order the stock overview sections.
The theme **cannot** turn the overview into a KPI dashboard with a memory ring and sparklines —
that is a view module in a separate `luci-app-*`.
The single exception is `fs-overview.js`: a layout-only, additive include that moves stock overview
sections (System left, Memory/Storage right) and draws no content of its own.
+379
View File
@@ -0,0 +1,379 @@
# Navigation benchmark: footstrap vs bootstrap vs proton2025
`luci-theme-footstrap` ships a client-side router, so clicking a menu item swaps the view in
place instead of reloading the whole page. This measures what that is worth, on real hardware,
against stock `luci-theme-bootstrap` and against a third-party theme.
Script: `bench/nav-benchmark.py`. Runs against any OpenWrt 25.12+ router.
## What it measures
For every page, wall-clock milliseconds from "I want to go there" to "the view is fully
rendered":
- footstrap: a real click on the menu link, which the theme's router handles in place;
- the others: a full navigation to the URL, because that is what a click does in them.
Both themes render into the same `#view` element, which the LuCI dispatcher emits regardless of
theme, so the finish line is identical and the comparison is fair. "Rendered" means `#view` has
children, nothing is spinning, no "Loading view…" text, and none of the content belongs to the
page you just left.
Pages come from `/admin/menu` at runtime: everything the menu can turn into a link whose
resolved target is a stock LuCI page. Third-party app pages are excluded by construction, so an
app that hangs itself under `admin/system` cannot skew the numbers. That gives 38 pages,
including the tab leaves (Realtime, Logs, Administration, Firewall) and the alias/firstchild
entries like Firewall and Realtime Graphs, which are the most-clicked items in the menu.
Per theme: activate it, clear the LuCI caches, log in with a fresh browser context, walk every
page once unmeasured to warm the HTTP and module caches, then walk it `--runs` more times and
take the median per page. Network requests per transition are counted too.
## CPU: two tables, because "load" is two questions
Time tells you how long you waited. It does not tell you what that cost, and the two can
disagree: a theme can be quick because it pushed the work onto the router, or easy on the
router while burning the client's battery. So both ends are measured and reported apart.
Router CPU is read from `/proc` on the router itself, at the edges of each theme's measured
passes. The attributable figure is `utime+stime+cutime+cstime` of `uhttpd`, `rpcd` and `ubusd`.
The `cutime/cstime` halves matter: uhttpd forks a CGI per request, and a reaped child's CPU
lands in its parent, so the ucode that renders the page shell is counted. There is also a
whole-box figure from `/proc/stat`, printed next to an idle baseline taken before and after,
since the router also routes traffic.
An open LuCI view polls the router once a second, which is CPU nobody navigated for. That rate
is measured separately, parked on one page, and subtracted before dividing by the navigation
count. Two things to keep in mind when reading the table: the percentage rows are rates over a
window whose length the theme changes, so a faster theme can show a higher percentage on less
CPU and only CPU-seconds compare; and `/proc/stat` inside a Docker container is the host's, so
the whole-box figure there would include the benchmark's own browser. That is why the published
numbers come from real hardware.
Client CPU comes from CDP `Performance.getMetrics` deltas per navigation: `TaskDuration` for
main-thread task time, with a script / style-recalc / layout / v8-compile breakdown.
## Why footstrap comes out ahead
Nothing exotic. A full page load throws away a working runtime and rebuilds it: the shell, the
menu, `luci.js` and `cbi.js`, the translation catalogue, the theme's CSS and JS. Then it renders
the page. The client router keeps all of that and renders only the page.
You can see it in the request counts. bootstrap fires 15 to 47 requests per navigation and
proton2025 27 to 72, against 0 to 7 for footstrap, which fetches only the RPC the view needs.
Zero on some pages, because the view is already in memory.
That is also why the win is uneven. It is biggest (8x and up) on light pages, where the page
itself has almost nothing to do and a reload spends its whole time restarting the runtime. It
is smallest (around 1.1x) on Startup, Software and Overview, where the time goes into rendering
content rather than navigating. The router cannot speed up a package list. That is a ceiling,
not a defect.
One more thing the numbers show: a third-party theme can be slower than the stock theme it
repaints. proton2025 loses to bootstrap on total time, with roughly twice the requests and four
times the CSS. Pretty is not fast, which is the argument for measuring.
## The remaining LAN time is not the theme's, and it is not the router's either
On a LAN a warm navigation still costs 90–140 ms while the router answers most of its own RPCs in
1–4 ms. That gap is worth writing down, because the obvious suspects are all innocent and the real
cause sits two layers below this theme.
**The theme is 4.9% of it.** CPU-profiling the main thread across 14 warm navigations on real
hardware (`Profiler.setSamplingInterval 100µs`, self time by script): every `fs-*.js` file together
is **6.8 ms per navigation** out of ~139 ms — `fs-chrome` 4.6, `fs-fit`'s `roomFor` 1.2, `fs-router`
0.4, `fs-select` 0.2 — against 95.9 ms in which the main thread is *idle* and 21.5 ms of engine work
(style, layout, paint). Another 3.7 ms/nav is `scrollTo`, which is ours. So zeroing the theme
entirely would buy under 8%, and the chrome re-render everyone suspects (`renderChrome`, which does
rebuild the menu on every navigation) measures **0.55 ms**.
**The router is not slow either.** Per-request timings from the CDP *network service* (immune to
renderer busyness) show server time of 1.0–4.0 ms for almost everything, including a 22 KB
`network.interface.dump` batch at 1.4 ms — but **42–44 ms, every single time, for `session.access`
(205 bytes) and for a lone `luci.getUnixtime` (211 bytes)**. Run the same call with the main thread
idle and it takes 2.8 ms; `ubus call session access` on the router itself takes 0 ms.
**It is Nagle plus delayed ACK, and uhttpd is where it lands.** uhttpd writes a response's headers
and body as separate `write()`s and never sets `TCP_NODELAY`, so on a *reused keep-alive* connection
a small body waits for the ACK of the previous response — which the client's kernel delays by ~40 ms.
Reproduced with curl, no browser involved, four sequential POSTs on one connection:
```
req1 uci.changes 52 B 7.2 ms
req2 session.access 53 B 44.0 ms <-- small body, previous response unacked
req3 getNetworkDevices 8913 B 9.6 ms <-- a full MSS goes out immediately
req4 session.access 53 B 42.8 ms
fresh connection each time: 1.4 / 1.5 / 1.4 ms
```
That also explains the shape: TTFB is 0.37 ms (the headers are prompt), the *body* is late; the first
request of a connection never stalls; large responses never stall.
**A workaround exists, it is NOT theme-specific, and over HTTPS it is a trap.**
`uci set uhttpd.main.http_keepalive=0` removes the stall (uhttpd then answers `Connection: close` on
both protocols). Measured on the production router, aarch64 25.12.2:
| | keep-alive 20 | keep-alive 0 | |
|---|--:|--:|---|
| footstrap, warm navigation, 6 pages, LAN | 754 ms | **453 ms** | **1.66×** |
| footstrap, cold full load, LAN | 292 ms | **155 ms** | **1.88×** |
| footstrap, warm navigation, 120 ms RTT | 1825 ms | 1834 ms | unchanged |
| footstrap, cold full load, 120 ms RTT | 1551 ms | 1551 ms | unchanged |
| **stock bootstrap**, full-load nav, 6 pages, LAN | 2126 ms | **1705 ms** | **1.25×** |
| **stock bootstrap**, full-load nav, 120 ms RTT | 4782 ms | 4814 ms | unchanged |
| **HTTPS**, full-load nav, 120 ms RTT | 9819 ms | **29 424 ms** | **3× WORSE** |
Stock bootstrap gains too, which is the point: this is uhttpd's, not the theme's. Plain HTTP loses
nothing even at 120 ms RTT, because the handshakes overlap across the six connections a browser opens
while the Nagle stall is serial. **But keep-alive is load-bearing for TLS**: without it every request
pays a TCP handshake plus a TLS handshake, and a page is ~49 requests — measured at 20 ms per fresh
TLS handshake even on the LAN, and 3× the total page time at 120 ms RTT. So never set this on a router
whose admin UI is reached over HTTPS or over a slow link.
What did *not* materialise, so do not repeat these as objections: socket churn cost nothing measurable
— uhttpd burned 3 jiffies per 100 GETs with keep-alive off against 4 with it on, TIME_WAIT sockets
stayed at 0 in both, and conntrack did not grow. The proper fix is `TCP_NODELAY` in uhttpd, which
keeps keep-alive *and* removes the stall, and helps every LuCI theme rather than this one.
**Dead ends, so nobody re-derives them:** HTTPS does not avoid it (56.8 / 44.3 / 44.3 ms over TLS);
`uhttpd -h` exposes no socket-option knob beyond `-k` (keep-alive timeout) and `-A` (TCP keepalive
probes); and **the theme cannot work around it from JS** — it issues no ubus call of its own during a
navigation (the calls are `form.js`'s `session.access` and the views' own data), `Connection: close`
is a forbidden request header, and the connection pool is keyed by origin rather than by URL, so no
cache-buster changes which socket a request lands on.
## Running it yourself
```sh
# once: environment
python3 -m venv .venv
.venv/bin/pip install playwright
.venv/bin/python -m playwright install chromium
# run (the original theme is restored at the end)
LUCI_PW=<router-root-password> .venv/bin/python bench/nav-benchmark.py \
--ssh-host router --runs 5 --out bench/results-25.12.json
```
Options: `--ssh-host` is a host from `ssh -G` (default `router`), `--runs N` sets the number of
measured passes, `--out FILE` writes JSON, `--headful` shows the browser. To add a theme,
install it, register it in `luci.themes`, and append it to `THEMES` in the script; `BASELINE`
decides which column the speedup is computed against.
Prepare the router or the numbers will lie:
- Every theme in `THEMES` must be installed and registered in `luci.themes`. The script only
writes `luci.main.mediaurlbase`, and nothing validates that path: if the theme's template is
missing, LuCI quietly falls back to another registered theme, and the column will honestly
measure the wrong theme. The run will not fail. It will lie.
- An app with invasive CSS changes what is being measured. If a `luci-app-*` injects a
`<style>` that paints stock widgets, footstrap contains it per app, but a sheet it cannot
attribute still forces a real page load (see [spa-router.md](spa-router.md)). Check `spa_pages`
in the JSON: if it is not 38/38, that is why.
- The script switches the active theme while it runs and restores it in a `finally`. Kill it
with `kill -9` and you may have to set `luci.main.mediaurlbase` back by hand.
A run takes `themes × (1 warm-up + runs) × pages` navigations. With five runs, three themes and
38 pages that is about 680 transitions, or 20 minutes.
## Results
Absolute numbers depend on the hardware. The ratios and the request counts are the point.
### Real hardware, with CPU (footstrap 0.12.0, 2026-08-01)
OpenWrt 25.12.2, mediatek/filogic, 4 cores, luci-base 26.209. Five runs, median, 38 pages, three
themes, **wallpaper off** on all of them. Full data in `bench/results-25.12.json`.
| | bootstrap | proton2025 | **footstrap** |
|---|--:|--:|--:|
| Time, sum of medians | 11 306 ms | 12 142 ms | **4933 ms** |
| vs bootstrap | | | **2.29×** |
| median per-page speedup | | | **3.03×** |
| pages navigated in place | 0 | 0 | **38 / 38** |
| Router CPU, web stack, whole tour | 37.3 s | 45.7 s | **18.4 s** |
| per navigation (polling removed) | 192 ms | 209 ms | **95 ms** |
| less router CPU | | | **2.02×** |
| tour duration | 62 s | 67 s | **29 s** |
| polling rate, parked on Overview | 12 ms/s | **88 ms/s** | 14 ms/s |
| load average, end of tour | 0.74 | 1.17 | 0.94 |
| idle baseline, box busy (before / after) | 2.9% / 0.9% | | |
The router CPU columns answer the obvious suspicion: the speed is not bought with the router's
processor. The same tour of 190 navigations costs it 18.4 s of web-stack CPU against 37.3 s.
**Client CPU is missing from this run, and the reason is worth knowing.** A sample is dropped when
the renderer process is replaced mid-navigation, and that does not fall evenly: footstrap kept 38
pages of 38, bootstrap 18 and proton2025 16, because a full page load restarts the renderer far
more often than an in-place swap does. What survives for a full-load theme is its *cheap*
navigations, so the cross-theme ratio computed over the 14-page intersection reads as "the SPA
theme burns more CPU" when it only means the other themes' expensive samples were thrown away. The
script prints that coverage now and says outright that the ratio must not be quoted below 90%. The
July run below had full coverage and measured **26.6 ms per navigation against bootstrap's
45.8 ms**; that is the figure to use until a run with full coverage replaces it.
Per page, sorted by how much the client router buys:
| page | bootstrap | proton2025 | **footstrap** | ×bootstrap | requests boot/prot/foot |
|---|--:|--:|--:|--:|--:|
| `system/admin/password` | 139 ms | 152 ms | **8 ms** | **17.15×** | 15/28/0 |
| `system/admin` | 134 ms | 149 ms | **10 ms** | **14.16×** | 15/28/0 |
| `status/realtime/load` | 139 ms | 146 ms | **10 ms** | **13.46×** | 16/28/1 |
| `network/diagnostics` | 254 ms | 305 ms | **21 ms** | **12.25×** | 27/38/1 |
| `status/realtime/bandwidth` | 207 ms | 286 ms | **20 ms** | **10.39×** | 29/39/3 |
| `status/realtime` | 106 ms | 142 ms | **11 ms** | **9.60×** | 17/28/1 |
| `status/logs/dmesg` | 151 ms | 162 ms | **20 ms** | **7.57×** | 16/28/1 |
| `system/admin/dropbear` | 322 ms | 319 ms | **58 ms** | **5.51×** | 32/46/1 |
| `status/realtime/wireless` | 271 ms | 304 ms | **54 ms** | **4.98×** | 29/41/3 |
| `status/logs` | 199 ms | 254 ms | **41 ms** | **4.88×** | 17/29/1 |
| `network/firewall/zones` | 311 ms | 333 ms | **79 ms** | **3.93×** | 32/48/2 |
| `network/firewall/ipsets` | 201 ms | 222 ms | **54 ms** | **3.70×** | 20/33/1 |
| `status/logs/syslog` | 184 ms | 222 ms | **52 ms** | **3.54×** | 17/30/1 |
| `network/routes` | 317 ms | 339 ms | **90 ms** | **3.54×** | 30/41/3 |
| `network/firewall/rules` | 336 ms | 362 ms | **96 ms** | **3.49×** | 30/42/2 |
| `network/network` | 374 ms | 386 ms | **111 ms** | **3.37×** | 38/56/4 |
| `system/crontab` | 178 ms | 168 ms | **54 ms** | **3.33×** | 15/28/1 |
| `network/firewall` | 290 ms | 386 ms | **90 ms** | **3.23×** | 32/48/2 |
| `network/dns` | 329 ms | 370 ms | **108 ms** | **3.05×** | 35/53/2 |
| `network/firewall/forwards` | 342 ms | 358 ms | **114 ms** | **3.00×** | 30/42/2 |
| `system/system` | 415 ms | 383 ms | **139 ms** | **2.98×** | 33/50/3 |
| `status/realtime/cpu` | 188 ms | 205 ms | **65 ms** | **2.89×** | 17/29/4 |
| `network/dhcp` | 361 ms | 367 ms | **131 ms** | **2.76×** | 35/52/3 |
| `status/realtime/connections` | 188 ms | 150 ms | **69 ms** | **2.73×** | 17/28/2 |
| `system/admin/sshkeys` | 143 ms | 167 ms | **53 ms** | **2.69×** | 15/27/1 |
| `system/admin/uhttpd` | 142 ms | 166 ms | **56 ms** | **2.52×** | 16/28/1 |
| `network/wireless` | 389 ms | 391 ms | **156 ms** | **2.49×** | 34/50/4 |
| `system/flash` | 238 ms | 212 ms | **104 ms** | **2.30×** | 16/28/2 |
| `status/overview` | 875 ms | 893 ms | **392 ms** | **2.23×** | 47/72/2 |
| `system/reboot` | 147 ms | 168 ms | **66 ms** | **2.22×** | 15/27/1 |
| `network/firewall/snats` | 202 ms | 256 ms | **97 ms** | **2.09×** | 21/34/2 |
| `system/leds` | 207 ms | 238 ms | **106 ms** | **1.97×** | 26/38/2 |
| `system/admin/repokeys` | 289 ms | 288 ms | **155 ms** | **1.87×** | 18/30/3 |
| `status/processes` | 291 ms | 314 ms | **157 ms** | **1.85×** | 15/27/1 |
| `status/nftables` | 214 ms | 245 ms | **128 ms** | **1.67×** | 18/30/7 |
| `status/routesj` | 232 ms | 266 ms | **174 ms** | **1.33×** | 20/33/3 |
| `system/package-manager` | 863 ms | 877 ms | **768 ms** | **1.12×** | 17/31/3 |
| `system/startup` | 1136 ms | 1188 ms | **1016 ms** | **1.12×** | 15/31/1 |
| **TOTAL (sum of medians)** | **11306 ms** | **12142 ms** | **4933 ms** | **2.29×** | |
| **median per-page speedup** | | | | **3.03×** | |
| **pages navigated in place** | 0 | 0 | **38 / 38** | | |
### The July 2026 run, and why the totals moved
Same hardware, five runs, footstrap 0.11.3, measured 2026-07-26: **11 814 / 12 644 / 4545 ms**,
2.60× total and 4.31× median page. So footstrap's total looks 388 ms worse now, while bootstrap's
looks 508 ms better.
**It is not the theme, and that was measured rather than argued.** Both versions were installed on
this router in turn, against the same LuCI, the same 38 pages and five runs each:
| | footstrap 0.11.7 | footstrap 0.12.0 |
|---|--:|--:|
| Time, sum of medians | 4932 ms | **4930 ms** |
| Client CPU per navigation | 21.8 ms | **22.5 ms** |
| Router CPU per navigation | 97.6 ms | **98.9 ms** |
| median per-page difference | | **2.8 ms** |
**What moved is the 40 ms uhttpd stall**, the one documented above. Turning it off on this router
(`http_keepalive=0`, uhttpd restarted, everything else identical) and re-running the same 38 pages
five times:
| footstrap 0.12.0 | sum of medians | client CPU per navigation |
|---|--:|--:|
| `http_keepalive=20` (stock) | 4930 ms | 22.5 ms |
| `http_keepalive=0` | **3886 ms** | **20.8 ms** |
| | **1.27×** | |
So a fifth of the theme's whole tour is a TCP stall that belongs to the web server. The growth since
July tracks it, and tracks the number of requests a navigation makes:
| requests during the navigation | pages | July | keep-alive 20 | keep-alive 0 |
|---|--:|--:|--:|--:|
| ≤ 1 | 16 | 1498 ms | 1680 ms | **1475 ms** |
| all 38 | 38 | 4545 ms | 4930 ms | **3886 ms** |
Read the first row: on the light pages the July run behaves like a router with no stall, and
today's behaves like one with it. On the heavier pages July already paid it — Wireless was 127 ms
then, 158 ms now and **58 ms** with the stall gone; Repository keys 132 / 156 / **58**. So July was
a mixed state, not a clean baseline, which is the honest reading of it.
`luci-base` was upgraded on this router on 2026-07-31 (26.134 → 26.209), which is the obvious
suspect and is not the cause: the compare between those two commits touches 300 files and **none of
them is `luci.js`, `form.js`, `uci.js`, `rpc.js` or `ui.js`**, so the set of calls a navigation makes
did not change. Measured during the run with CDP initiator stacks: `file.read` 42.7 ms on Crontab,
`uci.changes` 44.6 ms on Dropbear, `session.access` 44.2 ms on IP Sets — every one issued by
`luci.js`, none by the theme, against a router that answers such a call in 1–4 ms.
**What decides whether a given small call stalls is which socket it lands on**, and that is the
browser's connection pool, not anything either side of this comparison controls. Why a single-call
navigation landed on a fresh socket in July and on a reused one now is not established here; the
candidates are the Chromium build the harness downloads and the state of the LAN. It is not the
theme (4932 vs 4930 above) and not LuCI's JS (unchanged).
The remedy remains `TCP_NODELAY` in uhttpd, which keeps keep-alive and helps every LuCI theme.
`http_keepalive=0` buys the 1044 ms back today, and **must not be set on a router reachable over
HTTPS** — this one listens on 443, where the same setting measured 3× worse.
### Earlier baseline on the same hardware (footstrap 0.7.16)
Three runs, 38 pages. Kept for comparison: the theme has gained modules and CSS since, and the
ratio held. Full data was overwritten by the run above; this table is the record.
| page | bootstrap | proton2025 | footstrap | ×bootstrap |
|---|--:|--:|--:|--:|
| `status/realtime/wireless` | 288 ms | 254 ms | 16 ms | **17.48×** |
| `network/diagnostics` | 189 ms | 288 ms | 21 ms | **9.15×** |
| `status/realtime/load` | 138 ms | 154 ms | 15 ms | **8.96×** |
| `system/admin` | 136 ms | 153 ms | 16 ms | **8.82×** |
| `system/admin/sshkeys` | 155 ms | 155 ms | 19 ms | **8.15×** |
| `system/admin/password` | 108 ms | 155 ms | 14 ms | **7.74×** |
| `status/realtime` | 106 ms | 156 ms | 15 ms | **7.04×** |
| `status/realtime/cpu` | 190 ms | 168 ms | 28 ms | **6.69×** |
| `system/admin/dropbear` | 223 ms | 303 ms | 37 ms | **6.09×** |
| `network/network` | 367 ms | 370 ms | 63 ms | **5.81×** |
| `system/crontab` | 182 ms | 199 ms | 39 ms | **4.61×** |
| `network/firewall/zones` | 302 ms | 306 ms | 66 ms | **4.54×** |
| `network/routes` | 350 ms | 308 ms | 78 ms | **4.47×** |
| `system/admin/uhttpd` | 153 ms | 172 ms | 35 ms | **4.40×** |
| `status/realtime/bandwidth` | 238 ms | 204 ms | 58 ms | **4.12×** |
| `network/firewall/forwards` | 343 ms | 356 ms | 87 ms | **3.96×** |
| `network/dns` | 328 ms | 398 ms | 84 ms | **3.92×** |
| `status/logs/dmesg` | 101 ms | 156 ms | 26 ms | **3.82×** |
| `network/firewall/rules` | 300 ms | 353 ms | 87 ms | **3.45×** |
| `network/firewall` | 300 ms | 307 ms | 88 ms | **3.41×** |
| `system/system` | 348 ms | 393 ms | 113 ms | **3.09×** |
| `network/dhcp` | 279 ms | 363 ms | 91 ms | **3.07×** |
| `status/logs/syslog` | 189 ms | 221 ms | 64 ms | **2.97×** |
| `network/firewall/snats` | 225 ms | 222 ms | 80 ms | **2.81×** |
| `status/logs` | 186 ms | 277 ms | 70 ms | **2.64×** |
| `status/routesj` | 200 ms | 255 ms | 77 ms | **2.60×** |
| `status/realtime/connections` | 139 ms | 154 ms | 56 ms | **2.47×** |
| `network/firewall/ipsets` | 168 ms | 219 ms | 69 ms | **2.43×** |
| `network/wireless` | 325 ms | 433 ms | 137 ms | **2.36×** |
| `status/nftables` | 210 ms | 246 ms | 90 ms | **2.32×** |
| `system/reboot` | 140 ms | 154 ms | 63 ms | **2.21×** |
| `system/flash` | 244 ms | 221 ms | 113 ms | **2.15×** |
| `system/leds` | 237 ms | 269 ms | 117 ms | **2.04×** |
| `system/admin/repokeys` | 276 ms | 303 ms | 154 ms | **1.79×** |
| `status/processes` | 290 ms | 308 ms | 170 ms | **1.70×** |
| `status/overview` | 676 ms | 754 ms | 574 ms | **1.18×** |
| `system/startup` | 1076 ms | 1091 ms | 941 ms | **1.14×** |
| `system/package-manager` | 814 ms | 881 ms | 765 ms | **1.06×** |
| **TOTAL (sum of medians)** | **10518 ms** | **11680 ms** | **4638 ms** | **2.27×** |
| **median per-page speedup** | | | | **3.43×** |
| **pages navigated in place** | 0 | 0 | **38 / 38** | |
### Dev containers (footstrap 0.10.0, 2026-07-24)
Both dev containers (`owlab.yaml`), x86 under WSL, so absolute numbers are not
comparable with router hardware. Two themes only: proton2025 is not installed there. Three runs,
median. Data in `bench/results-container-2512.json` and `bench/results-container-2410.json`.
| container | release / package manager | pages | footstrap vs bootstrap (sum of medians) | median per page | in place |
|---|---|--:|--:|--:|--:|
| `router2512` | 25.12 / apk | 36 | **2.33×** (7458 → 3196 ms) | **3.04×** | 34/36 |
| `router2410` | 24.10 / opkg | 35 | **2.16×** (6753 → 3130 ms) | **2.40×** | 34/35 |
The page set differs from the hardware run: container fixtures produce different menu entries,
and `status/realtime/temperature` is skipped because there are no sensors. The ratios line up
with the hardware baseline anyway.
+482
View File
@@ -0,0 +1,482 @@
# The chrome: sidebar, rail, bar
The spec for what `header.ut` and `menu-footstrap.js` draw. Tokens and palettes:
[design-system.md](design-system.md). The stylesheet build: [css.md](css.md). Client navigation:
[spa-router.md](spa-router.md).
## Layout is a CLIENT axis, not a theme entry
`luci.themes` holds one entry, `Footstrap` → `/luci-static/footstrap`. The sidebar and the top
bar are the same markup and the same renderer; CSS morphs them by `:root[data-layout]`.
- The attribute **always carries an explicit value** (`sidebar` | `top`) and is **stamped by the
server** in `partials/head.ut`; an inline script overrides it from `localStorage` `fs-layout`
before first paint. The explicit value is not cosmetic: every layout rule matches
**positively**, so a future third layout must opt into rules rather than inherit the sidebar's
by merely not being `top` — which is what `:not([data-layout="top"])` would do, and why that
guard must not be written. It also keeps the chrome correct with JS disabled.
- Switching layout re-renders nothing: the DOM serves both, CSS changes the chrome, and a
`MutationObserver` on `data-layout` in `menu-footstrap.js` folds the accordion into dropdowns
and back.
- **The router may carry a default**: `luci.main.footstrap_layout=top` makes the bar the layout a
browser starts on, `head.ut` stamps it, and the reader's own choice overrides it forever. A
shell script cannot write `localStorage`, which is why the server side of the axis exists at
all. Layout is the axis this pattern started with; every axis now records its choice
explicitly, for the same reason — see the three layers in [design-system.md](design-system.md).
**The bar is the base; the vertical sidebar is the exception.** The bar is written once with no
guards (`styles/theme/20-shell.css`) and serves the top layout at any width *and* the sidebar
layout on a phone. The vertical column is the single guarded override, winning on specificity
(`0,4,0` against `0,1,0`) rather than on order. `50-toplayout.css` is a pure desktop-bar delta.
## Target layout
```
┌──────────────────────────────────────────────────┐
│ .fs-sidebar 224px │ .fs-main (flex:1) │
│ (rail: 68px) │ ┌──────────────────────────┐ │
│ [logo] hostname ◂│ │ .fs-content: │ │
│ #indicators │ │ warnings │ │
│ MENU │ │ #tabmenu │ │
│ ▸ Status ● │ │ #view │ │
│ ▸ System │ ├──────────────────────────┤ │
│ ▸ Network │ │ footer.fs-footer │ │
│ [spacer] │ └──────────────────────────┘ │
│ [search] │ │
│ ▸ Log out │ │
└──────────────────────────────────────────────────┘
```
The widths are quoted at normal density: `--fs-sidebar-w` and `--fs-rail-w` are `calc()` over
`--fs-density-box`, which the Density axis sets, so Compact and Large move them — and `fitShell()`
follows for free because it reads the computed token rather than a copy.
There is no separate topbar inside `main`: the page title is a visually hidden `<h1>` inside
`<div class="fs-title fs-sr">`, and `#indicators` (the "Refreshing" poll pill, unsaved changes)
lives in the sidebar. The `◂` button (`#fs-rail-toggle`) collapses the sidebar to a 68 px icon rail
with flyouts.
**`.fs-sr` clips, it does not hide.** This was `hidden` once — that is `display:none`, which drops
the element out of the accessibility tree: the `<h1>` effectively did not exist while the SPA
router dutifully updated the title inside it. Clipping (as `.fs-skip` does) keeps it in the tree.
Beside it sits `#fs-nav-status` (`role="status"`, `aria-live="polite"`), where the router writes
the page title after every SPA navigation — without it, a page change without a reload does not
happen at all for a screen reader.
## LuCI container mapping
| LuCI container | In bootstrap | In footstrap |
|---|---|---|
| `#topmenu` | horizontal top-level menu | `ul.nav` in `.fs-sidebar`: vertical list / bar / rail flyouts — **one markup** |
| `#modemenu` | admin/status breadcrumb switch | `.fs-modemenu` in the sidebar; empty or single mode → `display:none` |
| `#tabmenu` | tabs under the header | stays in main, above `#view` (emitted by `partials/notices.ut`) |
| `#indicators` | header's right corner | `.fs-indicators` in the sidebar under the brand; empty → hidden |
| `#view` | content | content (main column) |
The set of top-level sections comes from the menu tree and depends on the installed packages —
**do not hard-code it**.
## `data-narrow`: "the sidebar became a bar", and it is a MEASUREMENT
The sidebar gives way to the bar when the remaining content column has nothing left to show. A
viewport breakpoint cannot say that: the slice is not a constant (224 px expanded, 68 px rail), so
one breakpoint gave both states the same answer and the rail collapsed at the same width as the
full sidebar.
`fitShell()` (`fs-chrome.js`) subtracts the slice from the root's `clientWidth` and compares the
remainder with `--fs-content-min`; it **reads the widths from the CSS tokens** (`--fs-sidebar-w`,
`--fs-rail-w`, `--fs-content-min`, `--fs-content-pad`) through `getComputedStyle` rather than
keeping copies — otherwise narrowing the rail in the styles would leave the measurement subtracting
the old number, and no gate would notice. The result is `data-narrow` on `:root`, read by both the
CSS and `flyoutMode()` in the JS.
**The subtraction itself is `columnWidth()`, and there is exactly one of it.** Two callers need the
width of the content column — `fitShell()`, deciding whether the sidebar still fits beside it, and
`contentWidth()`, which answers `fitTables()` mid-scroll where reading layout is forbidden — and
while the arithmetic was written twice it drifted twice: `--fs-content-pad` is ONE side's gutter and
`shellGeometry()` already doubles it, so one copy subtracted it twice, and the top-BAR layout has no
sidebar to subtract although it carries no `data-narrow` either (`fitShell()` removes it on the way
out). Both errors are invisible in a screenshot and both are worth enough to push a table across
`fitTables()`'s cramped threshold. The gutter it subtracts is MEASURED off `.fs-content` rather than read from
`--fs-content-pad`: the same sheet re-paddings that element to 16px a side below 767px while the
token stays 28, and a media query has no `data-*` to key on — nor may its breakpoint be copied into
the JS. So the model asks the element what it actually got.
The arithmetic is unit-tested per combination (`tests/chrome-geometry.test.mjs`); whether its inputs
still describe the real page is checked on a stand, where `live-audit` compares `contentWidth()`
against the live `.fs-content` box. That check is what caught the gutter, on every page at 320, 390
and 568.
**`contentWidth()`'s width is compared fresh on every call, not just cached from the last fitter.**
`_shellOuter` (the window width `columnWidth()` subtracts from) is written only by `measureShell()`,
which only `fitShell()` calls, and `fitChrome()` steps aside for the whole `SCROLL_IDLE` window
(400 ms, `fs-fit.js`) whenever `fit.scrolling()` answers yes — a resize starts that window the same
way a flick does. A caller landing inside it — `fitTables()`'s, most of all, since it asks mid-scroll
by design — got the PREVIOUS viewport's width: a resize from 568 to 390 still answered 568 for up to
220 of the 400 ms, and the same shape held at every step of `live-audit`'s width list (−70 at
320→390, −178 at 568→768 read backwards, −256 at 1024→1440 — always exactly the gap between the two
adjacent widths). `contentWidth()` now compares `document.documentElement.clientWidth` against
`_shellOuter` on every call and re-runs `measureShell()` when they differ, rather than only when
`_shellOuter` was still unset. That comparison is the one layout read the function's "no layout
read" promise was already conditional on (the bootstrap branch made the same call); doing it on
every call costs nothing when nothing moved, since nothing invalidated layout between one read and
the next.
The measurement, the observer and the coalescing live in `fs-fit.js`, the theme's one "does it
still fit?" engine (also used by `fitTables` in `fs-select.js`). **Add fit logic there; do not grow
a second observer.**
The one literal is `@media (min-width: 521px)` around the vertical override in `20-shell.css`: the
floor below which no slice leaves a readable 500 px, and simultaneously the safety net with JS
disabled — no JS means no `data-narrow`, and a phone would otherwise draw the desktop sidebar.
The top bar measures too: it first squeezes the pills (`.fs-dense1/2`), and only if the menu still
wraps at the tightest step does it move to a second row (`.fs-bar-stack`). Whether it fits
depends on the number of sections on that particular router, not on the screen.
**And it pins its own height while it measures.** Rule 1 says take the class off and ask the engine,
but the bar is the one fitter whose element sits ABOVE the reader on every page: `fs-bar-stack` is
what gives the menu its own row, so for the widths where the menu does not need one the bar answers
a row shorter than it stands — 98px against 65px at 767px, 123px against 115px at 480px, on
owrt2512. That height is what the engine lays the rest of the page out against for the length of the
pass. Chromium and Firefox put the reader back afterwards; Safari implements no scroll anchoring on
any platform, and an iPhone reported the Overview creeping upward once per poll tick, bisected there
to this pass. So `fitChrome()` writes `min-height` at the settled height before it strips the
classes and takes it off after — the measurement is unchanged, and the box the page is laid out
against does not move. `tools/fit-quiet.mjs` (`npm run quiet`, in `live`) hands the bar a
`classList` of its own and reads the height back at the instant the classes come off: 0px of dip
with the pin, 8px and 33px without it. The symptom itself does not reproduce headless — the pass is
one synchronous task and both engines restore the offset once the bar is back, measured at half the
page and at its bottom — which is why the gate watches the cause.
## `header.ut`
- Shared parts live in `partials/` (`head`, `brand`, `logout`, `notices`, `notice`, `search`,
`icon`, `footer`). There is no second template directory.
- `<body>` → a `.fs-shell` flex wrapper. Before it, the skip link `.fs-skip`
("Skip to content" → `#maincontent`), the first tab stop on the page.
- **Sidebar** — `<nav class="fs-sidebar" aria-label="Menu">`, deliberately `<nav>`: `<aside>` gives
the `complementary` role, and no landmark jump reaches the menu through it.
- `.fs-brandrow`: brand (gradient square + wifi SVG on `currentColor` + hostname wordmark) and
the `#fs-rail-toggle` button;
- `<div id="indicators">`;
- `.fs-navlabel` + `<ul class="nav" id="topmenu">` (filled by the menu JS) + `<ul id="modemenu">`;
- `.fs-spacer` (`flex:1`);
- the search button (`#fs-search-btn`, the command palette below) and Log out. An Appearance
button used to sit between them; the axes are their own menu entry now
(`/admin/system/footstrap`, previously a tab stapled onto System → System), so the chrome
carries one control fewer — see [design-system.md](design-system.md).
- **Main** — `<main class="fs-main" id="maincontent" tabindex="-1">`: `.fs-title.fs-sr` with the
`<h1>`, `#fs-nav-status`, and `.fs-content` (warnings, `#tabmenu`, `#view`, footer).
- Must be preserved: `http.prepare_content`, `cbi.js`, the translations script, `node.css`, `css`,
`blank_page`, `noscript`, and the no-root-password / initramfs warnings.
`partials/footer.ut` emits `<footer class="fs-footer" role="contentinfo">` — the role is explicit
because `<footer>` only gets `contentinfo` implicitly when its nearest ancestor is `<body>`, and
this one sits inside `<main>`. It hard-loads `L.require('menu-footstrap')` and then
`L.require('fs-select')`; the `menu_module` parameter that used to pick a renderer went away with
the second renderer.
## `menu-footstrap.js` — the ONLY renderer
There is no `menu-footstrap-top.js`. **A second layout was never a second design**: the sidebar
renderer already produced markup its own CSS turns into a horizontal bar on a phone, and it already
had a flyout mode where a section behaves exactly like a top-menu dropdown. The top layout is
that mode at desktop width. The one piece of unique logic the deleted file carried, `clampDropdown`,
moved here.
- `renderMainMenu` fills `#topmenu`: an item is
`<li><a><icon><span class="fs-label">title</span><chevron></a></li>`, active by
`L.env.dispatchpath`. Icons are mapped by section name with a regex fallback and a generic SVG,
inlined as strings in the JS (`E()` cannot build SVG — see [conventions.md](conventions.md)).
Everything shared — tabs, modes, the rail, the SPA router, chrome
measurement — lives in the `fs-*.js` modules that `menu-footstrap-common.js` wires together. Only
`renderMainMenu` is layout-specific, and it is passed into `common.init()`: composition, not
inheritance, because LuCI makes a singleton of every baseclass.
- The top-level `admin/logout` node is dropped from the tree — the chrome draws its own Log out
(`partials/logout.ut`), otherwise it appears twice.
- **A section with children is a W3C APG disclosure pattern**, not a link: `role="button"`,
`aria-expanded`, `aria-controls`, Enter/Space, and Escape closes the flyout and returns focus.
Deliberately not `role="menu"` — APG explicitly says site navigation should not take menubar
semantics. `aria-current="page"` goes on the leaf only; a section header is a button, not a link
to the current page.
- **`.open` has two meanings**: in the expanded sidebar it is an accordion (several sections at
once, the set remembered in `localStorage` `fs-menu-open`); in the rail, in the bar, or on a
narrow screen it is an exclusive flyout. `flyoutMode()` decides, and it reads **exactly what the
stylesheet reads**: `data-rail` / `data-layout=top` / `data-narrow`. Leaving flyout mode restores
the accordion (`restoreAccordion()`) — a plain `closeFlyouts()` was not enough, because it
stripped `.open` from everything while the markup is not rebuilt on a rail toggle, so "Keep open"
stopped meaning anything.
- `clampDropdown` pushes a dropdown back into the viewport at the right edge. Whenever the chrome is
a BAR (`barDropdown()` — the top layout at any width, or a `data-narrow` sidebar), because that is
when a panel hangs off its own `<li>`; the rail is excluded, where a flyout is anchored sideways
instead. It keeps one scheduled `rAF` **per `<li>`** so it can cancel an
unfinished measurement when the pointer has moved on; the shared `fit.frame()` coalescer cannot
express that, and this is a documented exception.
- Hover opening is pure CSS.
## Dark mode and outward compatibility
Mode, palette and layout are all client settings; the pre-paint blocks in `partials/head.ut` stamp
`:root` before the first frame, one block per axis.
Dark mode: a stored value beats the OS, otherwise `prefers-color-scheme`. The `change` subscription
is registered always (it re-reads storage), so Auto keeps following the system if the user
switches to it after load.
**`set()` stamps THREE attributes:** `data-darkmode` (which this theme's CSS reads), plus
`data-theme` and `data-bs-theme` as outbound compatibility for third-party apps that sniff
them — `luci-app-justclash` hangs 21 rules on `data-theme`, `ssclash` checks `data-bs-theme`
first. Nothing inside `styles/` may read the latter two. `tools/axes.mjs` holds the set.
`<meta name="darkreader-lock">` stops the Dark Reader extension repainting the theme into mush.
More on how foreign apps detect dark mode: [third-party-apps.md](third-party-apps.md).
## The command palette
`fs-search.js` finds a page by name instead of by remembering which section owns it. A loaded
router carries ~200 reachable menu nodes across 11 sections, and some pages appear in no menu list
at all until you are already there — "Port Forwards" is a tab of Network → Firewall.
- **It costs no request.** The index is built from the same ACL-filtered `/admin/menu` blob the
chrome already loaded (`fs-menutree`), so the palette knows exactly the pages this session may
open: nothing to leak, nothing to 403 on. It is built on the first open, not at init — a user
who never searches pays nothing, and only a full load can change the tree.
- **It indexes tabs**, to `admin/<section>/<page>/<tab>` — four levels, every path the dispatcher
renders.
- **It does not call the router.** Every result is a real `<a href>`, so a click bubbles to the
router's own document-level handler and takes the SPA path (or falls back to a full load when the
node is not SPA-able) with no second copy of that decision. Enter synthesises the same click.
- **Recently visited rows are kept in `localStorage` `fs-recent`, one key each.** A page's key is
its menu path; a row from a companion source carries its own `key`, because what it points at has
no dispatcher node — the sections source keys one `admin/system/system#Footstrap`, the page plus
the section's own heading. Only the key is stored: the title is resolved through the pool on
every render, so it follows the UI language and a row nothing produces any more is dropped
instead of shown dead. `menu-footstrap-common` owns the writer (`remember()`, exported for a
source that has to record its own row) and `warmRecent()` prefetches the page half of each key —
see [spa-router.md](spa-router.md).
**Trap it was built around: do not index through `ui.menu.getChildren()`.** On an alias node it
returns a copy whose `children` are the alias *target's*. That is right for drawing a menu and
wrong for indexing: Network → Firewall is an alias onto the `firewall/zones` view, a leaf, so its
five tabs came back as an empty list and "port" found nothing on a router that plainly has a Port
Forwards page. Measured on the dev router: 78 indexed nodes through `getChildren()`, with every tab
of every aliased page missing.
## Keeping the reader's place when the engine will not
**Every mechanism, what each is for and what the sweep measures without it, is in
[anchoring.md](anchoring.md).** What follows here is the chrome's own half: which element a
correction may take, and why.
A poll tick changes the height of things above the reader — a station joins the associated list, a
lease expires, an interface box grows a line — and everything below it moves. Chromium and Firefox
hide that with **scroll anchoring**: they pick an element the reader can see and compensate the
scroll offset so it stays put. **WebKit did not implement it until recently**, so on an older Safari
and on every iPhone of that vintage the same tick moves the page under the reader's thumb. That is
what "the Overview jitters" was, and it is why it was reported from Safari and an iPhone while the
same router was still in Chrome. A current WebKit does anchor — and gets a DIFFERENT half of this
wrong; see "What an engine that anchors still gets wrong" below.
`fs-fit.js` therefore does the job **only where nobody else is doing it**: `ENGINE_ANCHORS` asks the
platform (`CSS.supports('overflow-anchor', 'auto')` — an engine that does not know the property does
not have the feature) rather than a browser name, because correcting an offset that the engine also
corrects means two corrections and a page that jumps the other way.
Three details carry it, and each one was a measured failure first:
- **The reference is what sits at the top of the CONTENT, not the frame the fold cuts through.** A
tick that grows something inside that frame leaves the frame's own top exactly where it was —
drift 0, measured — while everything after it moves. One hit test below `[data-fs-chrome]` (the
bar is sticky and owns the first rows of the viewport; a test at y=1 returns the chrome and the
page gets no anchor at all) gives the same element the engine's own algorithm would pick.
- **It is captured while the page is still, not in the mutation callback.** That callback runs after
the DOM changed, so a reference taken there has already moved with it: right for the fitters,
which have not run yet, and blind to the tick itself. The resting reference is refreshed after
every settled pass, after every correction, and when a scroll stops.
- **It carries the offset it was taken at.** The reader scrolling and the page growing look the same
from the element alone; correcting against a reference from another offset would drag the page
back to where the reader had scrolled from.
- **…and an offset that DROPPED with nobody scrolling, on the page it was taken on, is the engine.**
`dom.content()` — what every LuCI poll calls to refresh a section — empties the container before it
refills it, and for that moment the document is shorter than the offset the reader is at. The
engine clamps the offset into what is left, the section fills again and nothing puts it back. Three
conditions separate that from a reader who moved: a clamp only ever moves the offset **down**; a
reader who moved is one `scrolling()` still answers for (their scroll starts the sampler, while the
clamp's own scroll event arrives in the rendering step after the mutation callback); and the page
stamp has not changed, because the router resets both scrollers on a navigation and replays them on
a Back, and neither is a clamp to undo. The clamped amount is also what raises the one-viewport
ceiling on a correction — a drift that big normally means the view replaced its subtree, and a
clamp is the one that comes with a receipt.
- **The correction does not need the reference to have survived.** `dom.content()` replaces a
section's children with new nodes, so the element that happened to sit at the top of the content
area usually does not survive the tick that moved the reader — measured on a 24.10 router, where it
was disconnected on exactly the tick that mattered and the fallback was left measuring a fresh
reference against nothing. With no element there is no drift, but there is still a number known
exactly: the offset dropped by this much and nothing else happened, so giving that back **is** the
correction. It cannot run away with the page either — if the document really did get shorter, the
browser clamps the write back and the reader keeps the offset they already had.
**Two halves: the document does not shrink, and the reader is put back if it did.** Every container
a poll empties carries a floor — `min-height` at the height it had at the last settled moment, held
until the next one (`holdFloor()`, over `.cbi-section > div, .table > .tbody, .table`: what
`dom.content()` is called on and nothing wider). A container emptying takes nothing off the document,
so there is no shorter document for the engine to clamp into and the tick is invisible; the floors
are re-measured after every settled batch, so a page that genuinely got shorter is shorter one frame
later. Measured on a 25.12 stand with the correction switched off, a real poll tick clamped 1882px
away without the floor and 0px with it; on 24.10, where the theme cannot reach the poll at all, the
same park went from a 1206px clamp to no offset change at all.
**The floor is on the containers because it may not be on the column.** `min-height` on an ancestor
of the engine's own anchor is a scroll-anchoring suppression trigger — css-scroll-anchoring-1 §2.2.2
lists it, and Blink's list (`css_properties.json5`, `invalidate: [..., "scroll-anchor"]`) is wider
still — so a floor on `.fs-content`, where this used to live, turned the engine's anchoring off in
exchange for holding the document up. Measured with it there: 120px grew above the reader and the
page moved all 120px under them, on Chromium and Firefox alike; and the 15px this file used to quote
for the same configuration was taken before `47e636d` gave anchoring engines a live reference, and
was never re-taken. The suppression walks only the path from the anchor to the scroller, and a
container that empties is never on it — either the anchor was inside it, in which case the engine has
lost the anchor anyway, or the anchor is elsewhere and this container is its sibling. So the floor
now runs on every engine, and the engine's own anchoring keeps working beside it.
**A pin on the container itself does not work, and the shape that does was reported from the field.**
`fs-overview.js` briefly pinned each container across `dom.content()` and released it in the same
statement sequence — `dom.content()` performs no layout, so nothing ever observed the pin (measured:
1882px still clamped away with it in place). The report that named it wraps `dom.content()` itself
and releases two frames later, which does work — at the price of patching a luci-base API every app
on the router shares, and up to seven read/write pairs per call. One element the theme owns, measured
once per settled batch, is the same protection: on a live router with the correction off, the wrapper
and the floor both held the reader at 0px against a 337px drift without either, at 16 measurements
against 154 wrapped calls.
**The router owns the offset when it navigates.** `fs-router` resets both scrollers for an incoming
page and stamps `body[data-page]` a require later, so between the two there is a window in which a
poll tick from the OUTGOING page still fires — an offset of 0, a remembered offset from where the
reader was, nobody scrolling and the old stamp: every term of "the engine clamped this" is true. The
router calls `fit.forgetRest()` at the reset rather than leaving the memo to be inferred from a stamp
written afterwards.
**What an engine that anchors still gets wrong.** `ENGINE_ANCHORS` asks whether the platform supports
`overflow-anchor`, and every current engine answers yes, WebKit included — so the theme steps aside
from the growth case. Anchoring is not the same promise as "a section can vanish and come back",
which is what `dom.content()` does on every tick: the container empties, the offset is clamped into a
document that is briefly shorter, and what the engine does on the way back is its own business.
Chromium lands where it started. WebKit OVERSHOOTS — measured on both stands, both layouts and both
widths, a swap that grew a section by 120px moved the offset by 180, leaving the reader 60px up the
page **on every tick**.
**The memo is a pair, and a clamped correction splits it.** `putBack()` is the one write either
correction makes: it moves the offset by the drift it measured, and it used to update `_restAt`
alone, on the reasoning that the element is now back at the top it was remembered at. It is not,
whenever the write was clamped short — a page already at its end cannot scroll the last pixels — and
then `_rest.top` names a position nothing can reach while `_restAt` has already moved on. The next
tick corrects to that impossible top and takes the reader with it: measured on webkit/Overview at
390 wide in the top layout, `_rest` claiming 93.02 for an element standing at 105, and the reader
12px off after the tick (52px on the density beside it). Where the element actually landed is
re-read after the write — for the reference and for the section it falls back to — which is two
rects on a layout the write has already forced. `_rest.at` is left alone: it is read only by
`anchorFor()`, on the path where the engine does no anchoring, and the sweep is green on all 18
cells of the failing axis without touching it. **This only became visible when WebKit
gained anchoring**: on the engine-anchoring path the theme corrects from a memo it keeps across
ticks, where before it took a fresh reference on every mutation.
**And the pages where nothing held the reader at all.** Two faults sat behind the same symptom, both
of them in which ELEMENT the theme takes as its reference. `elementFromPoint` answers with `#view`
itself wherever the hit lands in a gap between two sections — and the host's own top does not move
when a poll changes something inside it, so the drift measured against it is zero on every tick, for
ever. And a page that is ONE TABLE (Processes, Routes, the realtime lists) has that table as a direct
child of `#view`: the climb out of the table — data tables are excluded as anchors, `30-tables.css`
explains why — landed on the host and gave up, leaving no reference at all. Since the stylesheet also
(correctly) tells the engine not to anchor inside those tables, nobody was holding the reader there on
ANY engine: measured at 390px, 120px of growth above the fold moved the page 120px on chromium,
firefox and webkit alike. The host is now refused as a reference and the hit is retried down the
viewport; where the climb would reach the host, the TABLE ITSELF is the reference — its height is
falsified by the fit pass, its top is not.
`lateDrift()` closes the overshoot without asking who the engine is. Two frames after a mutation — long enough
for the engine to have finished its own correction — the reference the theme was holding is asked
where it is now. An engine that got it right reports zero and nothing happens; what is left over is
what nobody put back, and that is what is given back. It answers GROWTH only: the collapse is the
floor's, and the page it still matters on is the one that is a single data table, where the
stylesheet has told the engine not to anchor and nobody else is holding the reader. Two things it is
deliberately NOT: a browser test (`CSS.supports` can no longer separate the two behaviours), and a
synthetic probe that performs the collapse itself (it calls Firefox broken, because a real page puts
layout and a frame between the collapse and the refill; gating on it cost Chromium and Firefox 15px
of drift they did not have).
The price is a resting reference on every engine — a hit test and a rect per settled pass, measured
on the stands at **0.2 ms typical and 6 ms worst** on a poll-dirtied WebKit layout, never during a
flick, and skipped entirely while the page is at offset 0 (nothing to be put back to). It fires only
where THE OFFSET HAS NOT MOVED since the reference was taken — `scrollTop() !== ref.at` and it sits
the tick out — plus a user gesture keeping it out, so a correction can never land inside a scroll.
Asking `scrolling()` instead does not work here and asking the sampler's step count is not enough:
the engine's own compensation moves the offset and starts the sampler, and in WebKit a programmatic
scroll's event arrives up to 1.2 s late, so the sampler is often not running at all while a flick is.
Both of those let a correction through mid-flick — 161px on `@390 side`, 320px on `@1440 side`, both
on webkit/Overview. `ref.at` and not `_restAt`, because `run()` re-remembers between the mutation and
this frame, and where the sampler has not started that re-take records the offset the reader has
already flicked to.
**The theme's own put-off pass moves the page too, and nothing was looking.** A tick landing while
the offset is in motion leaves its measurements to the pass that runs once the page holds still, and
that pass re-lays the tables and rewrites every floor. `min-height` is itself a suppression trigger
on the path to the anchor, so the engine's compensation can be off in exactly the frame that pass
needs it: on ImmortalWrt 24.10/WebKit the pass's 88 `min-height` writes and a 58px jump of the offset
land in the SAME frame, 429 ms after the mutation — `@390`, top layout, large density, Overview, one
cell out of 48, and the offset moved with no write of the theme's own (every programmatic scroll
write was intercepted and none came). `lateDrift()` is blind to it: scheduled from the mutation on
the same `SCROLL_IDLE`, it read a drift of zero three milliseconds before the page moved.
`settleDrift()` measures around that pass instead, and SYNCHRONOUSLY — the reader cannot scroll
between two statements, so what it sees is the pass's own doing, and `getBoundingClientRect()` is the
operation an engine must flush a pending adjustment before (css-scroll-anchoring-1 §2.2: the
suppression window ends at the end of the event loop iteration, or before the next operation whose
result would differ, whichever is sooner), so the read after the pass is a flush rather than a race.
Measured two frames later instead, it closed that cell and corrected INSIDE A FLICK on three others,
160px each.
`tools/scroll-anchor.mjs` holds all of it: it grows 120px above the reader and requires the page to
stay within two pixels, once with the engine's anchoring suppressed and once without, on both
scrollers; it refills a section the way a poll does — emptied, then filled again, with the router's own
poll held for the duration — and requires the same; and it flicks the page up and down to prove the
theme corrects nothing while the reader is moving. With the fallback removed the Safari case reports
exactly the reported symptom — 120px of page moved under the reader, 255px on the swap measured in
WebKit with the engine's own anchoring off, and 690px on a real Overview on the stands. It runs on
`chromium,firefox,webkit` in CI now, because the fault above lives on the path a stand-in engine does
not take, and it waits on `fit.restAt()` rather than on a stopwatch: WebKit starts its motion sampler
late enough that a flat wait measured the theme before it had a reference at all, which reported a
jump on every WebKit run with the theme identical on all three engines.
**Findings print as they are found, and one cell can be run alone.** A `--full` sweep is 144 cells
and tens of minutes, so a run that only speaks at the end costs a whole sweep per fix attempt — five
attempts at the fault above were measured that way before the flags existed. Each finding is printed
the moment it is made (`FINDING …`), `--bail` ends the run at the first one, and `--width 390
--layout top` narrows the sweep to the cell a finding names: the same measurement then takes a
minute. CI passes none of them — how WIDE a fault is is half of what the sweep says.
The sweep crosses three page shapes and two scrollers, and both sets are what a measurement left
standing rather than what looked thorough. The shapes: the Overview refills section BODIES, the
realtime Connections table has its ROWS replaced under a poll, and Processes is one table that is a
direct child of `#view` — the climb out of that one used to land on the host and give up. Processes
proves the climb and never the swap: its single block is 6397px against 6108px of scroll on
owrt2512, so it can never sit entirely above the reader, which is what a swap needs. Wireless was
measured and dropped (812px of scroll at 390px wide, 0 at 1440). The scrollers: of `1440 side`,
`1440 top`, `390 side` and `390 top`, only the first scrolls `#maincontent` and the other three
scroll the window, so the sweep keeps the first and the narrowest. Density is one value for the same
reason — across 72 Overview cells, normal, compact and large reported the same two outcomes and
nothing else. `--full` crosses all of them anyway, and CI does that on a push and a tag.
## `fs-select.js`
Turns every stock `<select>` into a styled `ui.Dropdown`, because a native `<select>` popup cannot
be styled with CSS. The native `<select>` remains the form field and must stay
`frameEl.firstChild` — `ui.Select.getValue()` returns `this.node.firstChild.value`.
It also owns `fitTables()`, which folds data tables into cards. Why config tables are handled
differently, and why that difference is not an unfinished job: [css.md](css.md).
## Translation
Every label on the Footstrap tab carries the `footstrap` `msgctxt`; the chrome (Menu, Logout,
Skip to content) and the login/warning strings deliberately do not. The reasoning — `msgid` is a
global name shared with every app on the router — is in [conventions.md](conventions.md).
+625
View File
@@ -0,0 +1,625 @@
# CI and packaging
How footstrap is turned into packages and shipped. The theme is noarch
(`LUCI_PKGARCH:=all`) and supports **OpenWrt 24.10** (`.ipk`/opkg) and **25.12+** (`.apk`/apk).
What the Makefile and the install scripts do: [package.md](package.md). The release runbook:
[releasing.md](releasing.md).
## The job graph
`.github/workflows/build.yml`. Triggers: push to `main`, a `v*` tag, any pull request, or a manual
dispatch.
```
check ─┐ ┌─→ verify ─────┐
├─→ build ─┼─→ live ───────┼─→ release ─→ playground-asset ─→ pages (release and after: tags only)
lint ──┘ └─→ playground ─┴─→ pages-manual (workflow_dispatch, publish-pages=true)
```
| Job | What it is |
|---|---|
| `check` | static gates that need no node |
| `lint` | the npm gates: eslint, stylelint, axe-core, the ratchets |
| `build` | both package formats, via owfeed |
| `verify` | installs this very build on real 25.12 and 24.10 userlands and renders its pages |
| `live` | opens every page of the menu on those userlands and measures it — layout, navigation parity, and the assumptions this theme makes about luci-base |
| `playground` | records a real router's pages into a static, replayable site and proves it offline |
| `release` | signs, generates the notes, attaches the assets |
| `playground-asset` | uploads `playground.tar.gz` to the tag's release, once one exists |
| `pages` | refreshes the GitHub Pages portal and the release mirror, from the latest release |
| `pages-manual` | same refresh, off a manual dispatch, from THIS run's own playground instead of a release — see `pages`, below |
`permissions: contents: read` at workflow level; only `release` and `playground-asset` declare
write, both gated to a `v*` tag. It used to be workflow-wide, which handed it to every
`pull_request` run — including `npm ci` in lint, and therefore to the lifecycle scripts of every
dev dependency.
**Every job has a `timeout-minutes`, and every network install runs under `tools/ci-retry.sh`.**
The default job timeout is six hours, and the two things these jobs fetch from somebody else's
server — apt and the Playwright CDN — stall rather than fail: on the 0.13.2 tag one apt step and two
playwright steps sat for 68, 68 and 17 minutes with GitHub reporting every system operational, and
nothing retried them because nothing had failed. Each attempt now gets its own deadline (six minutes
for apt, seven for playwright, three attempts), so a stall is a retry rather than a blocked release,
and the job timeout is the backstop behind that rather than the only clock in the job. Each attempt runs in a session of its own and is killed by process GROUP, because `timeout` signals only the child it started: the first version of the helper killed `playwright install --with-deps` and left the `apt-get` beneath it holding the lists lock, after which both retries died in seconds on that lock.
And apt is asked for as little as possible, because retrying a stalled apt is still waiting for a
stalled apt: a run spent its whole 20-minute budget doing exactly that. `gettext` is installed only
when `msgfmt`/`msgmerge`/`xgettext` are missing from the image, and `tools/ci-playwright.sh` fetches
Chromium from Playwright's CDN and then LAUNCHES it — the claim the gates actually need — going to
apt for the system libraries only if that launch fails. `--with-deps` went through apt every time,
and apt is the half that stalls.
## `check` — gates without node
Needs only `sh`, `awk`, `python3` and `perl`. Seconds to run, and it cannot break the OpenWrt
buildbot, where node does not exist and never will.
1. **`sh -n`** over `luci-theme-footstrap/*.sh`, `install.sh` and `tools/*.sh` — the scripts that
never reach a router. `tools/` is in the glob because `release-notes.sh` runs only in the
`release` job, so a syntax error there used to surface at the most expensive possible moment.
The *payload* scripts are parsed elsewhere: `owfeed doctor` (OWF213) parses everything under
`files:` in the `build` job, which covers `/etc/uci-defaults/*` once `tools/stage.sh` has staged
it.
2. **The scan marker.** `include/scan.mk` finds packages by grepping for `call BuildPackage`, which
this Makefile only reaches through `luci.mk`'s include — so the literal in its trailing comment
is what makes the SDK see the package at all. It has been deleted as boilerplate once.
3. **The ACL is valid JSON, and grants something.** rpcd skips an unreadable file in `acl.d` and
says nothing: a stray comma means the grant is issued to nobody, and nothing else notices. A
document that parses but is a list, or an entry with neither `read` nor `write`, is the same
silent outcome by another route, so `tools/check-acl.sh` checks the shape too.
4. **`build-css.sh`** into a temp file — the script brace-balances its own output and refuses to
write a suspiciously short file. That is a broken-build floor (80 KB), a correctness gate,
not a size budget. There is no upper CSS budget any more.
5. **`audit.py --strict`** — undefined `var()`, shadowed declarations, export-tier reads from
`styles/`, dead base declarations, stray `!important`, colour literals.
6. **i18n**: `update-po.sh --check` fails if the `.pot` is stale or any `msgstr` is empty. A string
in `_()` with no translation renders in English silently — which is how the whole Footstrap
tab stayed English on a Russian LuCI.
**Template compilation is not here** — it runs in `verify`, on the router, with the real `ucode`.
It used to clone the interpreter at a pinned commit and build it with cmake, with the router runtime
stubbed out through `-L`; the container has both for free and stubs nothing.
## `lint` — the npm gates
They live in CI only: the buildbot has no node and does not need it. Nothing in `package.json`
ships. Locally it is all one command, `npm run check`; the full table of what each gate holds is in
[conventions.md](conventions.md). The ones worth naming here:
| Step | What it catches |
|---|---|
| `eslint` | including `wrap-regex`, which forbids `return /re/…`, the form jsmin breaks on |
| `stylelint` | correctness and project invariants only — not a formatter |
| `a11y-gallery.mjs` | axe-core, WCAG 2.2 AA over `docs/gallery.html`, {light,dark} × {footstrap,hicontrast,bootstrap,2020,forum} × {untinted,60°,260°} — **30 combinations** |
| `export-tier.mjs` | the `--*-color-*` contract with foreign apps — axe cannot see it, their widgets are not in the gallery. 70 palette × mode × tint combinations, then the ten untinted ones again with `prefers-contrast: more` emulated, since that query re-states the ink tokens and so publishes a second tier |
| `css-metrics.mjs` | ratchet: `!important` ≤ 27, max specificity, empty rules |
| `css-floor.mjs` | the browser floor derived from the built sheet against the one stated in `docs/css.md`, plus the two shapes that break below it: a `:has()` compound sharing a selector list with one that has none, and a CSS feature nobody has classified |
| `fs-orphans.mjs` | dead `fs-*` selectors (safe only inside our namespace) |
| `css-dup.mjs` | identical declaration bodies under different guards — no linter calls this an error |
| `mirror.mjs` | `@mirror`-pinned copies still byte-identical (CSS **and** shell) |
| `axes.mjs` | the pre-paint in `head.ut` agrees with the live appearance appliers |
| `page-modules.mjs` | the page → module map agrees with the modules, and neither is `require`d at eval |
| `table-contract.mjs` | the break points, and the rule that hides an unanswered data table still names every root `fs-select` scans, guarded on `:root[data-fs-fit]` — the selector half of what `table-tick.mjs` then proves on a page |
| `chrome-fence.mjs` | the `[data-fs-chrome]` marker, fence and pin still match the chrome |
| `conffiles.mjs` | every shipped `/etc/config/*` is declared a conffile — else the manager replaces it on upgrade |
| `bang-ok.mjs` | the `!important` allowlist in `audit.py` and `.stylelintrc.json` still say the same thing |
| `changelog.mjs` | the changelog contract: sections, order, RU mirror, bold leads |
| `jsmin-verify.mjs` | the **only** check that catches jsmin's silent corruption (exit 0) |
| `npm test` | the unit suite — no browser, ~100 ms, and first in the job for that reason. It holds the branches a stand cannot enter: a luci-base missing a surface the router calls, an alias loop or a `firstchild` tie in a menu tree nobody ships |
| `size-budget.mjs` | ratchet on the **artefact**: `build-css.sh` + the token mangle for the sheet, terser over a copy of `htdocs/luci-static/resources` for the JS. `--show` prints the per-module table into the log, so a jump is attributable without re-running anything |
| `build-icons.mjs --check` | the committed app-icon rasters still match `logo.svg` — per channel with a tolerance, plus the maskable invariants (declared size, no alpha, nothing outside the safe zone, a mark in the middle). Not byte equality: that tested the renderer, and a Chromium bump reddened it on a commit that never touched the logo |
**About `jsmin-verify`:** jsmin corrupts a file silently and exits 0. The source shape it breaks on,
and why eslint's `wrap-regex` stands beside this gate, are in [conventions.md](conventions.md); the
two minification paths are in [package.md](package.md). What CI adds is the proof: it builds that
same jsmin from `luci-base/src/jsmin.c` at the pinned commit and compares the token stream of the
output against the source (acorn). The list of shipped JS is not written by hand — it is
`find htdocs -name '*.js'`, because luci.mk copies `htdocs/` wholesale, so that *is* the shipped set.
Both gates stay mandatory **for the source**, even though the release build minifies with terser
instead: a build without node still hands the untouched source to jsmin.
**About `size-budget`:** the numeric budgets were dropped once, and the reason they are back is what
they measure now. The old ones were counted on the SOURCE, which is not what anyone downloads — the
sheet is concatenated and token-mangled and the JS goes through terser, all of it inside the package
build, so a developer's edit-and-check loop never sees the artefact at all. That is the shape a
number drifts in: one feature at a time, each too small to argue with, none of them measured. This
gate reproduces the package build's asset half and weighs the result. uhttpd serves `/www` with no
compression, so identity bytes are wire bytes, read off flash by a core that is also routing packets.
Raising a limit is a decision and wants a comment saying what it bought; lowering one after a cleanup
is how the slack stops being spendable in silence.
Most of what the other jobs run lives in `tools/*.sh` rather than inline in the YAML —
`check-shell.sh`, `scan-marker.sh`, `check-acl.sh`, `build-jsmin.sh`,
`check-packages.sh`, `feed-key.sh`, `stage-release.sh`. A step that is a script can be run by hand
when it fails, and its reasoning lives beside the code instead of inside a workflow nobody reads.
## `build` — owfeed, not the SDK
Both formats are built by [owfeed](https://github.com/owfeed/owfeed) from one staged rootfs.
The SDK legs are gone: the theme is noarch (CSS, templates, browser JS, fonts — not one byte of
compiled code), and each leg downloaded, verified and unpacked a cross toolchain in order to run
`cp`. Twice, because 24.10 is opkg and 25.12 is apk.
| Format | Line | noarch spelling | What owfeed does |
|---|---|---|---|
| apk | 25.12+ | `noarch` (`all` is rejected as uninstallable) | `apk mkpkg` + `.conffiles` sidecars |
| ipk | 24.10 | `all` | an `ipkg-build` container + `CONTROL/conffiles` |
Three things the SDK really provided, owfeed does itself: it compiles `.po` → `.lmo` with its own
compiler (**byte-identical to `po2lmo`** — checked on release 0.11.6), emits the same sidecars as
`package-pack.mk`, and wraps the scripts in `default_postinst`/`default_prerm`, without which
`/etc/uci-defaults/*` never runs at all.
**The SDK check did not leave with the SDK.** owfeed extracts the host `apk` from a release SDK
tarball and keeps it on the same chain this workflow used to spell out: an ed25519 signature over
`sha256sums`, the branch key pinned from a *different* host (`github.com/openwrt/keyring`), and the
sha256 read only out of an already-authenticated file.
**owfeed itself is installed by its own action** — `owfeed/owfeed/setup`, pinned by commit — which
downloads the binary and **verifies it against GitHub's build attestation before it reaches PATH**.
That is why it is preferred to `go install …@<sha>`, which compiles the tool in every job and trusts
whatever the module proxy returns. A `SHA256SUMS` next to a release is not a check either — the same
host serves both the binary and the sum (the same argument this project makes about GitHub's asset
digest). An attestation is signed by GitHub's identity, sits in a public transparency log, and the
action pins the workflow that was allowed to issue it. Verified locally on a release binary:
genuine → exit 0; one byte appended → exit 1; against a foreign repository → exit 1.
The steps:
1. **Resolve the version** — from the tag (`v0.3.6` → `0.3.6`) or a fallback `0.<date>.<run>`. Plus
`SOURCE_DATE_EPOCH` = the commit timestamp: both containers write mtimes, and a package's
identity is a hash over its payload, so without a fixed epoch byte-identical content rebuilds
into a package that *claims* to be new.
2. **`./tools/stage.sh`** — the half owfeed deliberately does not do ("packages a directory; does
not build one"). It is `Build/Prepare` from the Makefile, step for step: build `cascade.css`,
mangle the private `--fs-*` names, minify the JS with terser, strip comments out of the templates
and the shell, stamp `FS_VERSION`. It also extracts postinst/postrm — by awk over the
Makefile's `define`s, not as a second copy: a script that runs on somebody else's router months
later is the worst possible place for two copies to diverge.
3. **`owfeed build`** — deliberately without `--frozen-lock`. That flag re-derives `owfeed.lock`
from `downloads.openwrt.org` and fails if it moved, which is right for a repository that
**publishes a feed**, where the covered architecture set is part of the contract with
subscribers. Here two noarch packages ride as release assets and expand into no architectures, so
the flag buys nothing and costs a red release on every OpenWrt point release. The committed lock
still pins which SDK the host `apk` comes from, so the toolchain does not float.
4. **Assert the catalogues.** A `.lmo` is compiled in and absent from git, and a silently missing
one means every `_()` renders the English msgid with nobody reporting anything. Each language
ships as its own `luci-i18n-footstrap-<lang>`, so the assertion is one catalogue named
`footstrap.<lang>.lmo` per language package, a uci-defaults line registering that language, and
**none in the theme** — two packages owning one path is an install apk refuses. Counted against
the languages in `po/`, so adding one and forgetting to declare its package is a red build
rather than quiet English for some users.
5. **Flatten the packages into `dist/`** (owfeed writes `dist/noarch` and `dist/all`; apk derives
the filename from name and version, so two architectures cannot share a directory). CI requires
that the theme resolve to exactly one asset per format under a name-anchored regex
(`luci-theme-footstrap[-_]…`). That is not hygiene, it is protection for old routers: see issue
#6 in [conventions.md](conventions.md).
## `verify` — the package works on a real userland
Downloads the artifact and installs this build on real 25.12 and 24.10 containers through the
owlab action, then renders its pages. On non-PR runs it additionally fetches the feed's public key
and asserts the published feed serves a working theme.
That the build "produced an ipk" proves nothing about the ipk; this job is what does.
**When the feed does not answer, the assertion is skipped and says so.** `tools/feed-key.sh` still
reports `reachable=false` rather than failing — the claim is about a channel this repository does not
own — but the run now ends with "Report whether the published feed was measured", which writes
*measured and passed*, *measured and failed* or *NOT MEASURED* into the run's annotations and job
summary. A green `verify` no longer looks the same whether the feed was tested or never asked;
`tools/ci-local.sh verify` prints the same sentence from the same script.
**Five assertions per leg**, and the fifth is the template gate:
```
package luci-theme-footstrap
file /www/luci-static/footstrap/cascade.css
http 200 /cgi-bin/luci/admin/status/overview
http 200 /cgi-bin/luci/admin/system/system
exec for f in …/themes/footstrap/*.ut; do ucode -T -c -o /dev/null "$f" || exit 1; done
```
`ucode -T -c` is LuCI's own trycompile, and it runs here against the installed templates with
the router's real `luci.core` and `uci` — no interpreter of ours, no stubs. It runs on both
legs: the templates are identical but the interpreters are not, and a construct 25.12's ucode
accepts is no proof that 24.10's does. Without it a stray brace in `header.ut` ships green and
silently moves every user's LuCI to another theme, because luci.mk copies `ucode/` verbatim and
nothing parses it on the way.
The same assertions are what `owlab test` runs locally — see [development.md](development.md), and
keep the two in step.
## `live` — the pages behave, not just the package
`verify` proves the package installs and two pages answer 200. That is not the same claim as "the
pages are right", and the difference is where every field report has lived: #11 a column shredded to
one character per line, #22 a clipped submenu title, #12 a doubled scrollbar in one engine, #8/#33/#36
a third-party app laid out wrong, and two pages that came back empty after a client navigation — all
green under every static gate at the time.
The job boots the same owlab containers a developer runs locally (`owfeed/owlab/setup` puts the CLI
on PATH; the binary is checked against its build attestation), installs the artifact `build` just
produced, and runs the live gates.
**It is three jobs, not one.** Once `anchors` moved out, this was the release's critical path, so it
became three slices (`parity`, `audit`, `motion`), each booting its own routers for 157s it does not
share — the wall clock is the longest slice and not the sum. `fail-fast` is off: a parity failure
says nothing about whether the reader stays put. Where two gates share a slice they also share the
page shapes they read, through `FS_SHAPES` — classifying is one load and a 1200ms settle per page of
the menu, and both gates used to do it back to back (measured: live-audit 421s alone, 303s after
spa-parity had already read the same router). **Splitting them onto separate runners gave that
saving back**: `FS_SHAPES` points inside each runner's own temp, so `parity` and `audit` each walk
the whole menu now — ~90s a router, and the price of the split.
Measured on the last run where every slice finished (`7ff9e56`, push, three routers): the gates are
**485s** (spa-parity), **450s** (live-audit) and **754s** (`motion`'s six). `motion` carried a
seventh until task liveslice — chromium's own scroll-anchor sweep, which grew from 145s to **1434s**
when `--full` started crossing its density axis (`e48434c`) and put the slice at 39 of its 45
minutes. That sweep is a shard of `anchors` now, beside firefox and webkit, where the cap is 75
minutes and the one-job-per-engine rule already lives; the cells are the same cells.
**A number here only holds while every page still answers.** `page.evaluate()` has no deadline in
Playwright, so a page that pins the browser's main thread does not cost a page's worth of seconds —
it costs the slice its whole budget. That is what happened for a day: `/admin/system/filemanager`
under `13e9864` (`fs-fit.js`), `parity` and `audit` both cancelled at 45 minutes on every run, with
nothing in either log. Both gates now print a line per page with the clock on it, so the next one
names the page instead. **Read which page a slice last named; never raise the cap.**
Printing a line is only half of it: a slice that names the page and is then killed at 45 minutes
still measured nothing about the pages behind it. Each shape probe in `classify()`
(`tools/lib/page-shapes.mjs`) is capped at `PROBE_DEADLINE_MS` (10s) — about 2000x the 4-5ms a live
page answers in, and wide enough that even a menu where every path froze costs 16 minutes rather
than the whole slice (96 paths, the widest measured, `owrt2512b` with the third-party fixtures). A
page that loaded and then stopped answering is printed as a FINDING naming the router and the path,
the walk continues on a fresh tab — a frozen page stays poisoned, every later `goto` on it burning
its own 20s, while `close()` + `newPage()` costs 0.5s and the login lives on the context — and
`parity` and `audit` exit non-zero. A page that never LOADED still says nothing, on purpose: that is
the runner or the stand, not the theme, and the two must not read alike.
Cheapest-first still holds inside `motion`, which opens with upstream-contract at 7s; it can no
longer come before the other two slices, which are on runners of their own.
1. `tools/upstream-contract.mjs` — the assumptions about luci-base. It runs first because if one of
them has moved, every finding below is downstream of it.
2. `tools/spa-parity.mjs` — every page opened by a click and by a full load, compared.
3. `tools/live-audit.mjs` — one page per shape at six widths (resized into) plus one ENTERED with a
load of its own — and the arrival records only what the resize at that width did not already
say, since a fault both passes see is one fault and a second copy of it is a baseline entry
that carries no information and only exists where that app is installed. Ratcheted against
`tools/baselines/live-audit.json`. `--update` UNIONS into that
file rather than replacing a router's set: the baseline is a union across platforms, and a
machine that does not install mwan3 must not delete mwan3's findings.
4. `tools/scroll-jank.mjs` — a real wheel, long enough to meet a poll tick, in both layouts: nothing
may re-decide or jump while the page moves.
5. `tools/table-tick.mjs` — the poll tick performed deliberately, with the layout forced inside the
window fs-select answers in: a replaced data table may not be laid out before it has an answer.
6. `tools/scroll-anchor.mjs` — content grows above the reader and the page must not move under them,
with and without the engine's own scroll anchoring. It runs in its OWN job, **one shard per
engine — chromium, firefox and webkit** (`anchors`), not here: at 52 minutes against this job's 17
it was the whole release's critical path, and the three engines share nothing, so the shards run
at once. Chromium stayed in `motion` while it was the cheap engine at 145s and left when `--full`
took it to 1434s (task liveslice); it is now the shortest of the three shards and costs the
pipeline no wall clock at all. Its sweep is narrow on a pull request and `--full` on a push — the
axes it drops were measured not to change its answer, and a push is where being wrong about that
must still be caught.
7. `tools/install-check.sh` — `install.sh` twice on each router, fresh and over its own result. It
goes last in its slice because it replaces the build under test with the published release; #16,
#28 and #30 were all this script, and all on the second run. That replacement is also why it may
not share a router with a gate still measuring the build — which a slice of its own guarantees.
**Three routers on a push, one on a pull request.** The push set is the OpenWrt lines the theme
supports — 25.12/apk, 24.10/opkg and the snapshot box, which tracks luci-base's master and so fails
on an upstream change before a user reports it. ImmortalWrt is not in it: same luci-base, different
brand and app set, never the leg that caught something first, and no gate measures it locally
either (`tools/lib/stands.mjs`). What each gate holds, and how
to run it by hand, is in [conventions.md](conventions.md) and [development.md](development.md).
**The snapshot box carries no third-party extras (`owlab.yaml`, `owrtsnap`).** openclash and
ssclash pull `kmod-tun`, which resolves through a kmods index keyed to the box's exact kernel git
hash, and the box's baked kernel is never the one `downloads.openwrt.org/snapshots` is currently
publishing kmods for — permanent drift, not a stale image. Measured 2026-09-07: `apk add` 404s the
kmods `packages.adb` for the running `6.18.33` hash and both apps fail; justclash (no kernel module)
installs cleanly on the same box, which is why it was never in the failure list. `owlab`'s own
`extra_packages` merge is additive only (`defaults:` plus what a router adds, no subtraction), so
the three apps are declared once, on `owrt2512`, and aliased onto the other release routers instead
of living in `defaults:` — `owrtsnap` gets none. The snapshot leg still runs every other gate; only
these three apps are untested there.
## `playground` — a real router, recorded and replayed
Turns `admin/status/overview` and seven other pages of an owlab stand into a static site any browser
can open with no router behind it, and proves that site offline. Three scripts, `tools/playground/`:
1. `capture.mjs` — boots nothing itself, talks to the `owrt2512` this job already installed the
build on: logs in, fetches every page in `pages.json`, and records the server's own document, the
`/luci-static/**` assets those pages fetched, every distinct ubus call and the ACL-filtered menu.
2. `build.mjs` — offline, deterministic: rewrites the recorded paths under `--base`, scrubs tokens
and the stand's hostname, splices in `replay.js` (patches `XMLHttpRequest`/`fetch` to answer from
the recording instead of a router), and tars the result.
3. `verify.mjs` — opens the built site in a real Chromium and fails it on a page error, a replay
miss, a 404, or an empty `#view`; also proves the one client navigation this theme has and that an
Appearance change survives a reload.
Runs on a tag, a pull request and `workflow_dispatch` — not only on release, because a regression in
`fs-*.js` against a real ubus answer is a real fault and the cheapest place to catch it is before a
tag exists to publish from. It replaces the 475 KB hand-written snapshot that used to live here,
which imitated `fs-*.js` inline and had already gone 13 releases stale (`CHANGELOG.md`): this build
runs the theme's own JS, so it cannot drift from it the same way.
Local run: `tools/ci-local.sh playground` (needs `--force`, same stand-safety rule as `live`); by
hand, `docs/development.md`.
## `release` — signing and publication
Tags only, and **it is a call into owfeed's own reusable workflow** rather than steps of ours:
```yaml
release:
needs: [build, verify, live, anchors, lint, playground]
if: startsWith(github.ref, 'refs/tags/v')
uses: owfeed/owfeed/.github/workflows/package.yml@v0.5.1
secrets: { sign-key: …OWFEED_AUTHOR_KEY, usign-key: …FOOTSTRAP_USIGN_KEY }
with: { owfeed-version: v0.5.1, pre-release: sh tools/stage-release.sh,
sign-also: install.sh, notes-file: release-notes.md, verify-with: release.pub }
```
**This is the only job that holds a key**, and this `needs` list is what stops a tag publishing a
package no router has installed, no page has behaved on, or no recording has proven runs the real
JS — `playground` is in it for the same reason as `live` and `anchors`: a fault it catches is the
theme's, not a docs nit, and it does not ship with one unfound.
`tools/stage-release.sh` is our half — the `pre-release` hook. It writes the release notes where the
workflow reads them and puts the one non-package asset into `dist/`, before the manifest is written,
because whatever is in `dist/` is signed with everything else:
- **the notes**, from `tools/release-notes.sh` — the tag's changelog section, one bold lead per
bullet, grouped by category. They fill the release page and are **not** an asset: the only reader
that ever fetched `notes.md` from a release was the self-update package, which is retired and its
repository archived;
- **the installer**, because `raw.githubusercontent.com` is rate-limited for unauthenticated callers
— so the user whose address has run out of budget (CGNAT, a shared exit) fails to download the
installer meant to rescue them (issue #17) — and because a signed copy is the only one that can be
checked before it runs as root. The README points at `main` and names this one as the fallback.
`sign-also: install.sh` gets it a signature of its own (it is not a package, so owfeed would not see
it otherwise), and `verify-with: release.pub` re-checks every signature against the key that is in
the repository and baked into the installer.
Result: one theme asset per format plus its `.sig`, the manifest plus its `.sig`, and the installer
plus its `.sig`. Each has a reader: the packages are what a router installs, the manifest is what
`install.sh` installs *from* when the feed cannot be read and where it reads the newest version for
its "the feed has not caught up yet" line, and the installer is the mirror the README names.
**About the manifest and readers in the field.** owfeed's format was copied from this project's own
manifest, with two differences, both safe by construction: the first line became
`owfeed-manifest 1` (nobody parses it), and the package architecture was appended to the end of
the `pkg` line. The order of the first six fields is untouchable: `install.sh` reads them positionally,
and a copy already on somebody's router cannot be fixed remotely — a field inserted before them
would make it fetch a URL that 404s. The workflow checks that order on every release rather than
relying on it.
## `playground-asset` — attaching the built playground to the release
Tags only, `needs: [release, playground]`. `dist/*` rides the manifest `release` signs, and
`playground.tar.gz` is a demo site rather than router payload — owfeed's `package.yml` has no input
for a foreign artifact, and putting one there would sign it as if it were one — so this is a plain
`gh release upload` against the tag `release` just published, in a job of its own with
`permissions: contents: write` and nothing else. `pages` waits on this job, not on `release` directly,
so its own fetch of `playground.tar.gz` (below) never races the upload that puts it there.
## `pages`
Publishes the developer portal to GitHub Pages: `docs/devkit.html` (generated by
`tools/devkit-build.mjs`, never committed) and `docs/gallery.html`, with a freshly built
`cascade.css` and `logo.svg` beside them. It also carries a **full mirror of the
latest release** — the manifest, its signature, the installer **and the packages** — on a different
host from github.com, so an outage or a block covering one need not cover the other. It is called by
`release` (by way of `playground-asset`) rather than triggered by `on: release`, because an event
raised by `GITHUB_TOKEN` does not trigger another workflow.
The devkit assembles itself from files that already exist — the real stylesheet, the export tier
parsed out of `02-tokens.css`, the widget markup from `gallery.html` — so nothing is hand-copied and
nothing can drift.
**The playground is not built here at all.** It is not source that lives in this repository; it is
fetched, already built, from `releases/latest/download/playground.tar.gz` — the same
`playground-asset` upload above — and unpacked into `_site/playground/`. Fails OPEN like the release
mirror above it: a repository with no playground-carrying release yet (or a `workflow_dispatch` run
on a branch, `docs/development.md`) publishes the rest of the portal and says so in the log rather
than failing the build. `_site/playground.html` is kept as a redirect to `playground/` for the links
the README and `devkit.src.html` already carry.
**A maintainer can publish Pages from a single run's own recording, without a tag:**
```sh
gh workflow run build.yml --ref <ref> -f publish-pages=true
```
This runs `build.yml`'s `pages-manual` job (`needs: playground`, off by default), which calls
`pages.yml` with `playground-source: artifact` — everything above is unchanged except the playground
fetch, which reads the `playground` artifact this same run uploaded instead of
`releases/latest/download/playground.tar.gz`, and FAILS CLOSED on a miss rather than publishing the
rest of the portal. Two things to know before using it:
- **The `github-pages` environment accepts only `main` or a `v*` tag as a deployment branch**
(repo Settings -> Environments -> github-pages; checked 2026-09-14 with
`gh api repos/…/environments/github-pages/deployment-branch-policies`:
`custom_branch_policies` = `[main, v*]`). `--ref` naming anything else reaches `deploy` — `build`
and the artifact fetch already ran — and is refused there.
- **The next push to `main` under the paths `pages.yml` watches overwrites a manual publish.** That
run fires from `pages.yml`'s own push trigger, `playground-source` defaults back to `release`
there, and Pages goes back to mirroring the latest tag.
## Installation and the trust chain
`install.sh` does one thing: it adds the owfeed-packages feed (key, repository entry, and a
`keep.d` entry so a sysupgrade does not lose the key) and installs the theme from there.
`apk upgrade` / `opkg upgrade` carries it forward afterwards, which is the whole reason to install
from a feed rather than from a file.
It then installs the catalogue for `luci.main.lang`, and **the feed not carrying that package is not
a refusal**: a release reaches owfeed-packages through a pull request, so a NEW package — which
`luci-i18n-footstrap-<lang>` was in 0.14.4 — is missing from the feed until that merges, while the
signed asset for it is already in the release. The fallback takes it from the release through the
chain below, pinned to the tag the installed theme came from, and `apk upgrade` picks the package up
from the feed once it lands there.
```sh
wget -qO- https://github.com/VizzleTF/luci-theme-footstrap/releases/latest/download/install.sh | sh
```
**The repository line goes into the manager's own customfeeds file**, `customfeeds.list` for apk and
`customfeeds.conf` for opkg — not into a file of the theme's own. apk reads every `*.list` under
`repositories.d/`, so a private file installs and upgrades just as well; what it cannot do is be
seen. LuCI's package manager reads exactly three apk paths — `repositories`,
`repositories.d/distfeeds.list`, `repositories.d/customfeeds.list` — in its rpcd ACL *and* hardcoded
in `package-manager.js`, so a feed anywhere else is absent from "Configure APK" and cannot be edited
or removed there. An installer from before this wrote `repositories.d/owfeed-packages.list`, which
is why the apk branch deletes that file after writing its own line — the same repository configured
twice, once where the admin can see it and once where they cannot, is worse than either. Neither
customfeeds file needs a `keep.d` entry: both are conffiles of their manager (`apk-mbedtls`, `opkg`),
sysupgrade backs up every conffile whose checksum has moved, and `build_list_of_backup_overlay_files`
was already dropping the duplicate entry the script used to add.
**The line is MOVED to be the first non-comment line, never merely appended or left wherever it
already was** — measured on real apk: its customfeeds reader stops at the first line it cannot
parse, so an admin's own unrelated line (or a stale one from an older run) sitting above where the
correct line used to land could cut the feed off with no error at all, and a router this project's
own OLDER installer had already configured has exactly that shape (the line appended, after
whatever else was there) — so "is the line present" has to become "is it first", checked and
repaired on every run, not only when the line is missing outright; the closing message distinguishes
the three outcomes ("Adding"/"Feed added" when the line was missing, "Moving … to the top" when it
only had to be repositioned, "already configured" when nothing changed). Every other ACTIVE line
naming the feed's host (case-insensitive) or, on opkg, its src name, is commented out and reported
rather than left to shadow the correct one; a line for any other host is untouched.
Both this and the stale-line cleanup write the result atomically and never `mv` a fresh file over the
customfeeds path directly: they resolve it with `readlink -f`, `cp -p` its mode and owner onto a temp
file in the SAME directory (so the rename stays on one filesystem), write the new content into that
copy, and only then `mv -f` it over the resolved target. A customfeeds file that is itself a symlink
keeps being one — the rename replaces what it points at, never the link — and a write that fails
partway (a full overlay, OOM, a `cp`/`cat`/`mv` a hostile PATH entry shadows) leaves the original
byte-identical, because nothing is truncated until the copy is complete and the rename is what
actually takes effect. **The failure itself is checked, not inferred**: `disable_other_lines`'s
"disabled:" lines wait until the write actually lands before printing, and `ensure_first` reports
its outcome through a real exit status the caller tests with `if`, never through `$(…)`, whose own
exit code `set -e` cannot see — a failure to write the feeds file now exits 1 with one error naming
the file, leaving it unchanged and no temporary files behind, instead of printing "Adding the
feed…"/"disabled: …" over a write that silently never happened (measured on both package managers
with `cp`, `mv`, and — on opkg — `cat` shimmed to fail; on apk a failing `cat` is intercepted
earlier, at reading `/etc/apk/arch`, and reported there instead).
**A snapshot router is served the newest release branch.** The feed publishes one branch per
OpenWrt minor and has no snapshot channel — owfeed-packages lists exactly two release lines and they
*are* the package-format split (apk from 25.12, ipk on 24.10), not a build of the theme per release.
So `SNAPSHOT`, which parses to no branch, gets the newest branch its own package manager can read:
`FALLBACK_BRANCHES_APK` / `_OPKG` in the script, probed newest-first against
`releases/<branch>/<arch>/<index>` rather than assumed, so a branch listed before it is published —
or one that does not carry this router's architecture — falls through instead of writing a
repository entry that 404s on every update. What makes it sound here and not in general: the theme
is noarch and `+luci-base` is its whole dependency list, so nothing in it was compiled against the
branch it comes from. The probe's bytes are discarded; the index it found is still verified below.
When no candidate answers, the script installs the signed release instead (below) rather than
refusing — measured in an `openwrt/rootfs` snapshot container (`apk add` from the 25.12 branch, no
`--allow-untrusted`, theme registered) and with the feed host pointed at a name that does not
resolve.
**Every downloader on the box is tried, not the first one that exists.** `uclient-fetch` needs
libustream-mbedtls to speak https at all, and an image that ships wget-ssl or curl instead carries
the binary without the library — so choosing by existence reported "the feed carries no branch for
this router" when the truth was "this one tool cannot do TLS here". Reproduced with a
`uclient-fetch` stub that always fails: the old script printed exactly the field report, the current
one falls through to `wget` and installs from the feed. None of the three is ever asked to skip
certificate verification; falling through to the next TOOL is not a downgrade.
**What verifies the bytes on the feed path is the package manager**, against the feed key pinned in
the script: apk checks the index against `owfeed-packages.pem`, opkg against usign key
`9040356b214084da`.
**The theme is now also carried by the official openwrt/luci feed, and a bare `apk add` picks that
build instead of owfeed's.** apk resolves a name it is given with no version constraint to the
HIGHEST version across every configured repository, not the one the repository line just added
serves — and luci.mk stamps a LuCI-carried package from git's commit date rather than this
project's own numbering. Measured on owrt2512 (OpenWrt 25.12.4, apk-tools 3.0.5):
```
# apk list luci-theme-footstrap
luci-theme-footstrap-0.14.10-r1 noarch {luci-theme-footstrap} ← owfeed
luci-theme-footstrap-26.246.70755~4fd72fd noarch {feeds/luci/…} ← official feed
```
`install.sh`'s `apk add --upgrade "$PKG"` moved a router onto the second line — reported as
"Upgraded luci-theme-footstrap 0.14.9-r1 -> 26.246.70755~4fd72fd", which is not an upgrade: that
build predates this release (no rpcd-reload fix, no checkbox-tooltip fix) and comes from a
different publisher. The fix is a version constraint the script now applies to both the theme and
its `luci-i18n-footstrap-*` catalogues, `apk add --upgrade "$PKG<26"`, which both picks owfeed's
build over the official one and REPAIRS a router that already has the official one (`--upgrade`
still applies, so it downgrades, measured: "Downgrading luci-theme-footstrap
(26.246.70755~4fd72fd -> 0.14.10-r1)"). `26` is a ceiling, not a version pin: `apk version -t`
confirms `26.246.70755~4fd72fd` and `27.1.1~abc` both compare `>` against `26` — the LuCI stamp
only grows with the calendar — while `0.14.10-r1`, `1.0.0-r1` and `25.99.99-r1` all compare `<`,
so the constraint excludes every LuCI-stamped build for good and leaves this project's own
numbering majors 1 through 25 to grow into. opkg has no `world` file and no version-constraint
syntax on `install`/`upgrade`, so this has no opkg equivalent; left alone because the official
24.10 feed does not carry the theme today (checked on owrt2410, `opkg info luci-theme-footstrap`
names only this project's own `0.14.9-r1`) — if that changes, the fallback is the signed-release
install path this script already has (`install_from_release`), which bypasses feed resolution
entirely.
**There is no pinned-tag install, and the release-asset fallback is automatic but never the first
choice.** A router that cannot read the feed index at all — an architecture owfeed does not publish,
a resolver that does not answer, a network that intercepts the host — is installed from the release
instead of being sent away with a URL, and told plainly that `apk upgrade` will not carry the theme
forward until the feed works.
That path picks the artifact **from the signed manifest**, never by guessing an asset's name: issue
#6 was a self-update script, shipped at the time and retired since, that resolved the theme by
name and took `head -1`, which on a release
carrying per-language packages installed a catalogue. `manifest.txt` names exactly one file per
format with its size and digest, and the chain fails closed in this order — verified TLS, then
`usign -V` against the release key pinned in the script (the same key as `release.pub`), then the
manifest's own sha256 over the downloaded artifact. A missing `usign`, a signature that does not
verify, or a digest that does not match is a refusal, and the script then prints the by-hand URL as
before. `apk`'s `--allow-untrusted` on this path says only that the `.apk` carries no APK signature
of its own; what is trusted is the usign signature over the manifest, checked before the file is
handed to the manager. Measured on 25.12/apk and 24.10/opkg containers, including the refusal: a
one-character change to the pinned key leaves nothing installed.
A router that wants an OLDER version still does it by hand — the feed carries one version per
branch — with the **raw file from a release**, never the zip artifact from Actions:
```sh
apk add --allow-untrusted luci-theme-footstrap-*.apk # 25.12+
opkg install luci-theme-footstrap_*.ipk # 24.10
```
`--allow-untrusted` means the package manager holds no key of ours. The release publishes a signed
`manifest.txt` and a detached `.sig` per asset, so a by-hand install can be checked with
`usign -V -m <pkg> -x <pkg>.sig -p release.pub` — which is what the installer's own fallback does
for you.
## Package formats, and the usual mistake
- **apk** (25.12+) — the Alpine apk-tools format, which OpenWrt adopted in 25.12.
- **ipk** (24.10) — gzip-tar: `./debian-binary` (2.0) + `./control.tar.gz` + `./data.tar.gz` with
ustar headers. Identical to native OpenWrt ipks.
**"Malformed package file" from opkg** is almost always the wrong file: a GitHub artifact's zip
wrapper, or an `.apk` where an `.ipk` belongs.
An `.ipk` can be checked with a real opkg by hand — there is no such run in CI, and there was no
real proof before either, since "an SDK 24.10 produced it" was the same non-proof:
```sh
docker run --rm -v /path/pkg.ipk:/tmp/p.ipk openwrt/rootfs:x86-64-24.10.4 \
sh -c 'mkdir -p /var/lock; opkg install --nodeps /tmp/p.ipk'
```
**All four lifecycle scripts are present in both containers.** The first version of owfeed emitted
only `postinst` and `prerm` into the ipk — the pair `package-pack.mk` generates, and easy to mistake
for the whole set — so `postrm` (unregistering the theme, putting `mediaurlbase` back) reached apk
and not opkg. Fixed before the first release built with it, and checked against the 0.11.6 SDK build:
all four agree in meaning, with only cosmetic differences.
+428
View File
@@ -0,0 +1,428 @@
# Conventions
The rules a change to this theme has to follow. Every one of them is a bug that was hit, and
most are enforced by a gate — the gate is named next to the rule so you can check yourself
before pushing.
Read this before your first patch. For what the theme *is*, start at
[architecture.md](architecture.md).
## The two commands
```sh
npm run check # every gate below, in one run; must exit 0 before you push
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' \
--assert 'package luci-theme-footstrap' \
--assert 'http 200 /cgi-bin/luci/admin/status/overview'
```
…and the same again with `--release 24.10.8 --install 'dist/all/*.ipk'`. One run per format:
`--install` is a host-side glob evaluated per router, so a pattern matching both hands the apk box
an ipk.
Nothing in `package.json` is shipped. `luci.mk` copies `htdocs/` and `ucode/` verbatim and
never invokes node, and the OpenWrt buildbot has no node at all.
## Keep owlab installed, and run the change on a router
**owlab is required, not a convenience.** Install it once and keep it:
```sh
go install owfeed.org/owlab/cmd/owlab@latest
owlab doctor
```
**A change that alters behaviour is not finished until it has run on a real OpenWrt userland**, on
**both** package managers — 25.12/apk and 24.10/opkg. Not "it should work", not "the gates are
green": run it. `owlab up` gives you four disposable routers from `owlab.yaml`, and
`owlab test` is the local form of the same assertions CI's `verify` job makes. How:
[development.md](development.md).
Why the rule is written down rather than assumed: **every gate in this repository is static.** They
read files. Not one of them opens a page. The classes of bug they structurally cannot see:
- a preference that writes to the wrong place (the appearance axes were gated on their *contract*
while one of them silently rewrote a router-wide default);
- anything that only appears once a session, a poller or a second browser exists;
- a template that compiles and renders the wrong thing;
- an install path — `postinst`, `uci-defaults`, the marker file, a conffile surviving an upgrade;
- any difference between apk and opkg.
A stubbed harness in node is worth running for what it does prove — that a module initialises, that
an axis stores where you think — but it proves nothing about the router. Say which of the two you
did; do not let one stand in for the other.
**If you genuinely cannot run it** (no Docker on the machine), say so plainly in the commit or the
PR rather than reporting the change as verified. An untested change described as tested is worse
than an untested change.
## Writing CSS
**Read only the private tier.** Every rule in `styles/` reads `--fs-*` and nothing else. The
export names (`--primary-color-high`, `--text-color-*`, …) are defined *from* the private tier
and are for third-party apps only.
```css
color: var(--fs-dim); /* yes */
color: var(--text-color-medium); /* no — audit.py --strict fails */
```
Why: `:root` is one shared scope, and every `luci-app-*` puts its CSS in the same document
**unlayered**, which outranks any `@layer`. A hostile `:root` recoloured **312 of 336** gallery
elements before the tiers were split, and 0 after — the measurement and the two tiers are in
[design-system.md](design-system.md).
**Put the rule in the right layer instead of reaching for `!important`.** Layer order is
`tokens, base, theme, page`, and a later layer beats an earlier one regardless of specificity —
so a `theme` rule never needs a flag to beat `base`.
**`!important` inverts layer order** — the mechanics and the numbers behind that are in
[css.md](css.md). Two consequences for a patch:
- If a rule needs a flag to beat **another footstrap rule**, the rule is in the wrong layer.
Move it or merge the two.
- A legitimate flag fights only what layers cannot outrank: inline `style=` and unlayered
rules injected by an app.
The one flag sanctioned against *our own* rules is `theme/95-a11y-media.css`:
`prefers-reduced-motion` has to kill animations declared in `base` too, and only an important
declaration reaches back a layer. Everything else on the allowlist fights something outside the
cascade — `theme/90-responsive` and `pages/20-overview` outrank a `style=` written by `29_ports.js`
and `ui.js`, `theme/45-misc` widens the box the realtime graphs size inline and repaints its inline
black border, `theme/65-dropdown` frees `.hide-close` from a RichListValue's inline `min-width:25vw`,
and `styles/base` keeps the six
`.left`/`.right`/… forcing utilities plus two inline-style fighters. `audit.py` keeps that list
(`BANG_OK`), `.stylelintrc.json` states the reason for each file, `npm run bang-ok` holds the two in
step, and `css-metrics` caps the total at **27**.
**Win on specificity, never on source order.** Two rules with the same specificity where the
later one is load-bearing is the same failure as 220 `!important`, only quieter. Cap:
`[1,7,0]`, held by `css-metrics`.
**Edit the rule that already styles the selector.** Do not append a second one. The shadowed-
declaration counter in `audit.py` is at **0** and stays there.
**No colour literals.** A `#hex` follows neither the palette nor dark mode. The single exception
is `--fs-scrim` — black at .7 is the absence of light behind a dialog, not a shade of any token.
**Stay inside your zone.** There are three: the theme's own chrome (`fs-*` names, `data-fs-chrome`
roots — ours alone, and fenced against foreign CSS); the shared LuCI widget surface (`cbi-*`,
`.table`, `.alert-message` — ours to style, nobody's to own); and another package's namespace,
which the theme never reaches into. `fs-overview.js` used to live in `luci-mod-status`'s global
include directory, where LuCI evaluates every `*.js` — so it was downloaded and run on the
overview of routers using a different theme. It is a chrome module now. The fence that keeps
foreign CSS out of zone one is described in [third-party-apps.md](third-party-apps.md).
**Coverage is a contract.** A selector no stock LuCI page renders still gets styled: some
third-party `luci-app-*` emits it. "Looks unused, delete it" un-styles somebody's app. PurgeCSS,
uncss and any coverage-based trimming are forbidden here. The one safe dead-CSS search is
`css-orphans`, and it is safe only because nothing outside the theme can emit an `fs-*` class.
**Pin a duplicate you cannot merge.** `css-dup` finds identical declaration bodies under
mutually exclusive guards — a shape no linter calls an error and the shape that drifts. Worse,
`css-dup` matches *identical* bodies, so it goes quiet exactly when the copies diverge. Every
duplicate body must be either merged or wrapped in `@mirror`, and `tools/mirror.mjs` then holds
the copies byte-identical. A `@mirror` group with one copy is a hard failure.
**A `display` rule on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` needs a role to match.**
A `display` other than `table`/`table-row`/`table-cell`/… drops the implicit `table`/`row`/`cell`
role HTML-AAM derives from it (WCAG 1.3.1), and `fs-select.js`'s role map is what puts it back.
`tools/table-contract.mjs` holds both directions: it fails if the role map shrinks and it fails if a
theme selector sets `display` on one of these classes with no entry in the map to pair with it.
**A flex item's automatic minimum size defaults to its CONTENT size, and `overflow` is what zeroes
it.** Any flex item with no explicit `min-width` (row axis) or `min-height` (column axis) still has
one: the *automatic minimum size*, which resolves to the item's min-content box unless the item's
own `overflow` is something other than `visible`, in which case the automatic minimum drops to zero
(CSS Flexbox §4.5). One cause, four symptoms in one session, in both directions:
- `.cbi-checkbox` is `inline-flex` and its pill had no `flex-shrink: 0` — the automatic minimum let
the pill compress below its declared `--sw-w` while the knob kept its literal size and a travel
distance derived from the width the pill no longer had.
- Under 767px a form label is `flex: 1 1 100%` with no `min-width` on ssclash — its automatic
minimum fell back to content width and it escaped its own section.
- A closed dropdown's `overflow: hidden; text-overflow: ellipsis` were already correct and inert:
the automatic minimum held the `li` at content width, so there was nothing left to clip.
- The opposite fault, same rule: `overflow: hidden` on a row-action button zeroed *its* automatic
minimum, so `flex-grow` divided the row by count instead of by need and every button converged on
one width at every viewport — a desktop regression shipped as the fix for a phone one.
Before adding a declaration to a flex item, name which of the two behaviours it needs: a button that
must actually shrink wants `overflow: hidden` (or an explicit `min-width: 0`) to reach zero; a label
or a pill that must clip while holding its own width wants the overflow property paired with an
explicit `min-width`/`max-width` that is not `auto`, so the automatic minimum never gets a vote.
**No gate holds this rule in general.** `pseudo-loc` catches the first shape after the fact — a
caption that grew and then overflowed its shrunk pill reads as a finding — but not the third: an
inert `text-overflow: ellipsis` produces no overflow at all, because the box it sits on was never
given the room to shrink in the first place, and nothing measures a clip rule that never fires.
## Writing JS
**Never put a regex literal straight after `return` or `=>`.** `jsmin` (which `luci.mk` runs on
the buildbot) decides whether `/` opens a regex or divides by looking at one preceding
character, and neither `n` (from `return`) nor `>` (from `=>`) is in its list:
```js
return /^https?:\/\//i.test(a); /* jsmin reads // as a comment, EATS THE REST OF THE FILE
and exits 0 */
return (/^https?:\/\//i.test(a)); /* `(` is in the list — safe */
```
Upstream bugs: openwrt/luci#8299, #8020, #8021, #8256. **A zero exit code proves nothing** —
the corruption is silent. Two gates: eslint's `wrap-regex` forbids the shape, and
`tools/jsmin-verify.mjs` builds the same jsmin and fails unless the token stream of the minified
output matches the source. A backtick inside `${…}` in a template string is out too (jsmin loses
the string, but that one fails loudly).
**Comments are free on the router.** They are ~60% of the JS source and none of them ship:
release CI pre-minifies with terser, and a node-less build runs jsmin. What a comment says is
governed by "Comments" below.
**A required module is a singleton; there is no inheritance between modules.** `'require X'`
hands you a constructed instance, not a class, so `base.extend` from another module throws
`base.extend is not a function`, and returning a plain object throws `factory yields invalid
constructor`. Compose instead: `menu-footstrap-common.js` exports `init(renderMainMenu)` and
`menu-footstrap.js` injects its renderer as a parameter.
**Require a *stock* class through `window.L`.** The `L` a module factory receives and
`window.L` are different objects, and `ui` hangs its helpers (`itemlist`, `showModal`, …) on
`window.L` only. Worse, `require()` caches by class name, so the *first* requirer fixes the
class↔`L` binding — a theme module that beats the router to a stock class makes the page die on
`L.itemlist is not a function`, intermittently. Use `const RT = window.L; RT.require(name)`.
**`E()` cannot build SVG.** It goes through `document.createElement`, which is the HTML
namespace. SVG in the theme is assembled as a string with fixed markup, and user data goes in
separately via `textContent`. This is a namespace fact, not a style choice.
**All "does it fit?" logic goes in `fs-fit.js`.** One engine, one `ResizeObserver`, one
per-frame coalescer for the whole theme. Do not grow a second observer. Its three rules — measure
uncollapsed, re-fit synchronously on a mutation, coalesce on resize — are each a bug that was hit;
they are documented in the file header.
**Never answer "does it fit?" with a viewport breakpoint.** `matchMedia('(max-width: 767px)')`
used to decide this, and between 768 and 779 px the chrome drew as a bar while the menu still
behaved as an accordion. The sidebar gives way when the *content column* would be unreadable,
which depends on the slice (224 px expanded, 68 px rail) — one breakpoint gives both states the
same answer. `data-narrow` on `:root` is the single source of truth, read by both CSS and
`flyoutMode()`.
**Observer hygiene.** Watch the narrowest node, use `attributeFilter`, guard against your own
writes, coalesce into one frame, `disconnect()` on teardown. LuCI's poll rewrites content once a
second, so a loose observer on `document.body` runs a full scan every tick.
**Listener lifecycle: `AbortController` + `{ signal }`**, `abort()` on teardown. It is the only
pattern that reliably removes anonymous handlers.
## Comments
The reader is a model with this file open and no session history. It needs, per line it is about
to change: what must stay true, why, and where the proof is. Everything else costs it context and
gives nothing back. `CLAUDE.md` carries the short form; this is the full rule, with the example
behind each line.
### What a comment carries
| part | example | required |
|---|---|---|
| the invariant | `min-height` AND `height` are pinned to the same value for the whole pass | always |
| the reason, as a number | the bar walked 230 -> 123 px inside one pass, 107 px of growth | when one was measured |
| one pointer | `docs/anchoring.md`, "The corrections"; openwrt/luci#8981; issue #41 | when the proof is longer than a line |
One of each, in that order, in one sentence where it fits. A comment with no invariant is
deleted, not reworded: restating the line it sits on is what it did.
### What a comment does not carry
| cut | goes to | why |
|---|---|---|
| attempts and their order ("first shape … second shape …") | `docs/<area>.md`, one paragraph per attempt | the code is the survivor; the losers are history |
| a task or session name (`task refill2`) | nothing — cite the docs heading instead | names nothing the reader can open |
| a `../tmp/…` path | `docs/<area>.md` restates the finding | the file is not in the repository; the pointer is dead the next day |
| a CI run id | the changelog entry, once | the run expires; the number it produced does not |
| the same fact the docs already hold | one line and the pointer | two copies drift |
| CAPS for emphasis, a rhetorical question, a warning to a future maintainer | plain prose | an LLM weights capitals as shouting, not as priority |
### Size
| comment | limit |
|---|---|
| inline, on or above a statement | 1 line; 2 when the reason is a number |
| above a function or a rule block | 8 lines |
| module or file header | 15 lines: purpose, invariants, what the file does not own |
| comment share of a shipped file | under 40 % of the bytes |
Over the limit means the text is an explanation, and an explanation is a `docs/` section pointed at
from one line. `fs-fit.js` at 85 % comment bytes is the measurement this table comes from: the
rules it documents are sound, and no reader finds them.
### Rules that hold regardless of size
- **A number or a name is part of the contract.** 15 comments once said the poll re-renders
"once a second" while `pollinterval` ships at 5 s. The comment changes in the edit that changes
the code, or git preserves the lie.
- **A negative result stays, in one line, present tense.** `display: none` on a zeroed tab pane
buys nothing: scrollHeight 1039 either way. Not how it was found, not which attempt it was.
- **References survive every compression pass**: issue numbers, spec text quoted verbatim, upstream
commits, file paths inside the repository.
- **Some comments are code**: `@mirror name/tag` / `@endmirror` (`npm run mirror`), `/* fs:probe */`
(`strip-probes.sh`), the eslint `'require …'` pragmas, the Makefile's buildroot signature line
(`npm run marker`), the literal `dataset.fsFit` write (`tools/table-contract.mjs`). Reword one
and a gate or the build breaks, silently in the Makefile's case.
- **Edit the comment that is there.** Never stack a second one on the first.
- **Formal English.** No exclamation, no addressing the reader.
- **A `#` line inside a quoted `ssh "$R" "…"` string is part of the string.** `sh -n` after the
edit.
### Where the narrative goes
The measurement that justifies a rule is written once, in `docs/<area>.md`, under a heading the
code can cite: what was seen, what it was, the command that tells the two apart. `docs/anchoring.md`
(the reference) and `docs/anchoring-log.md` (the findings) are those files for `fs-fit.js`. A docs section may be as long as the finding needs; the comment
citing it may not.
### After a bulk comment pass
Prove the code did not move: a token-stream compare against HEAD for every JS file, a
comment-stripped and whitespace-normalised diff for CSS, `.ut`, shell and yaml. That is what caught
a deleted Makefile marker and a lost shell escape. The full-tree compare is T2 (`tools/bg.sh`); the
pass is finished when its output has been read.
## Templates and translation
**`msgid` is a GLOBAL name shared with every `luci-app` on the router.** `load_catalog` merges
*all* `*.<lang>.lmo` into one dictionary and returns the first archive with a matching hash, so
**readdir order decides whose translation wins**. The layout switch rendered "Максимум" on a
Russian router because somebody's catalogue translates `Top` that way — correct in a bandwidth
dialog, nonsense on a layout switch.
- Strings on the Footstrap tab: **use a context** — `_(str, 'footstrap')`.
- Chrome (Menu, Logout, Skip to content), login and warning strings: **deliberately no context**,
so they inherit luci-base's translation in the ~40 languages the theme has no catalogue for.
Nothing here fails loudly: `_()` without a catalogue is just English text. `npm run i18n` fails
if the `.pot` is stale or any `msgstr` is empty.
**Set the custom property BEFORE the attribute.** Every appearance axis is implemented twice —
inline in `partials/head.ut` before first paint (no module loader exists yet) and live in
`fs-prefs.js` — and the order is load-bearing. Reversed, a reload paints exactly one frame in the
previous hue: a symptom nobody reports and no other test catches. `tools/axes.mjs` derives the
contract from the JS and checks the template against it.
## Package and registration
**One entry in `luci.themes`** — `Footstrap` → `/luci-static/footstrap`. Layout, mode, palette,
tint, accent and rounding are client axes (`localStorage` + attributes on `:root`), not theme
entries and not a server choice. Do not reintroduce `-dark`/`-light` symlink themes or
layout-specific entries.
**A fresh install may activate the theme; an upgrade must never change the active one.** What
tells the two apart is whether the registration was already there: `uci-defaults` writes
`mediaurlbase` only in the run that first added `luci.themes.Footstrap`, which is the idiom the
other themes in the tree use. `$PKG_UPGRADE` is checked as well and carries nothing on its own —
apk never exports it.
**`rpcd reload`, never `restart`.** rpcd holds sessions in memory; a restart logs out every LuCI
user including the admin who just clicked Update. `reload` re-reads `acl.d/*`, which is all this
package needs.
**One asset per package per format in a release.** Nothing on a router picks an asset any more —
the installer takes the feed — but a reader that does gets one candidate, not a guess: GitHub
returns assets **sorted by name**, and in v0.8.4 a `luci-i18n-…` package sorted ahead of
`luci-theme-…`, so the self-update script shipped at the time installed a 6 KB catalogue instead of
the theme,
reported success, and offered the same update forever (issue #6). Code already on somebody's router
cannot be fixed remotely; only the release can. CI fails unless each package resolves to exactly
one asset under its name-anchored regex.
**The translation catalogue lives in `po/`.** That is the directory `LUCI_LANGUAGES` globs, so
luci.mk bakes a `luci-i18n-footstrap-<lang>` package per language exactly as it does for every
luci-app — and it is the only directory Weblate, which CONTRIBUTING names as the way to translate
LuCI, can see. It was `i18n/` while a fielded self-update script resolved the theme by name and took
`head -1` (issue #6); that script is retired and owfeed builds the release as one artifact per
format regardless, so the rename no longer bought anything.
**No runtime dependency beyond `+luci-base`.** `curl` is not in OpenWrt's default set (the base
image ships `uclient-fetch`); fall back, do not depend. `jsonfilter`, `sha256sum` and `usign` are
in the base image.
## The trust chain
`install.sh` installs from the owfeed-packages feed, so **the package manager is what verifies the
bytes**: apk checks the index against `owfeed-packages.pem`, opkg against usign key
`9040356b214084da`, and both keys are pinned in the script itself — it runs from `wget | sh` before
any package of ours exists. The script's own fetch of those keys uses a verified TLS channel:
never `-k` / `--no-check-certificate`, and never as a retry, because a failed verification *is* the
MITM case.
The installer no longer downloads release assets, so it carries no sha256, no `usign -V` and no
`--allow-untrusted`. **The release still signs everything** — a `manifest.txt` plus a detached
`.sig` per asset, both verified in the `release` job — because that is what a by-hand install and
a mirror have to be checked against.
An ed25519 signature is the link that holds and a sha256 alone cannot: GitHub *computes* the asset
digest from the uploaded bytes, so whoever can swap an asset gets the digest recomputed for them.
## Changelog and release
Every substantive commit writes into `## [Unreleased]` **in the same commit as the code** — a
changelog written afterwards is written from the diff, and the diff is exactly what does not know
why. Format, categories and the release runbook: [releasing.md](releasing.md).
## The gates, and what each one holds
| Gate | Holds |
|---|---|
| `lint` | eslint over `htdocs/` and `ucode/`, stylelint over `styles/` — correctness only, not formatting |
| `audit` | `audit.py --strict`: undefined `var()`, shadowed declarations, export-tier reads, dead base declarations, stray `!important`, colour literals |
| `css-metrics` | ratchet: `!important` ≤ 27, max specificity `[1,7,0]`, 0 empty rules |
| `css-orphans` | dead `fs-*` selectors — it **gates** the forward direction (styled, emitted by nothing) and **reports** the reverse, where an unstyled class is often legitimate (a JS hook, an element riding on inherited styles). A new name in the reverse list wants a look or a line in `JUSTIFIED_UNSTYLED`; it does not fail the build |
| `acl` | every shipped `acl.d/*.json` parses **and** grants something — rpcd skips an unreadable file silently |
| `css-dup` | identical declaration bodies under different guards |
| `tables` | the table contract: where a cell may break (one allowlist, no viewport queries), the floor holds when a data table is squeezed, a carded cell prints its caption, and no `.cbi-dropdown` sits inside a scroll container |
| `mirror` | `@mirror`-pinned copies still byte-identical |
| `bang-ok` | every `!important` sits in an allowlisted file |
| `axes` | the pre-paint in `head.ut` agrees with the live appearance appliers, and `header.ut` reads every saved option back |
| `scroll-anchor` (live) | grows something above the reader and asserts the page does not move under them — twice, once with the engine's own scroll anchoring suppressed (the Safari path, forced on any engine with `localStorage.fsEngineAnchor='off'`) and once without, so a fallback that also runs where the engine already corrects is caught as well. Plus a scripted flick up and down: the theme may not correct WHILE the reader moves. Three page shapes (section bodies, a polled table's rows, a bare table under `#view`) across the two scrollers a layout and a width can produce; `--full` adds the axes measured not to change the answer, which is what CI crosses on a push |
| `table-tick` (live) | performs a poll tick on purpose — rows out, rows back in, marks stripped, then a forced layout — and fails if the replaced table was laid out before anything answered for it. The intermediate lasts a microtask, so no sampler can see it: with the stylesheet's gate removed this reports 613px on Обзор@390 and 817px on Processes, and nothing with it in place |
| `floor-contract` (live) | the poll floor: every `data-fs-floor` box is stripped and re-measured with `offsetHeight`, and the two must agree within 4px; a tab switch must leave no floor on the pane the reader left; and the tallest floor on the page is emptied and must be given back — judged against what the EMPTY box stands at, since a box with no children still has its padding (34px on /admin/network/network either way). All three faults shipped and none is visible in a file or on a page that is only loaded: 41px short on `.cbi-section-descr` (/admin/network/dhcp), 2432px left on `Initscripts` after a tab switch, 927px never taken off on Network → Interfaces. A box the poll refilled or replaced is not judged |
| `fit-quiet` (live) | the bar may not get shorter while it measures itself. `fitChrome()` strips the layout classes to ask whether the menu fits a row, and the bar is above the reader on every page: 33px of dip at 767px, 8px at 480px, which an engine with no scroll anchoring lays the page out against. The gate gives the bar a `classList` of its own and reads the height back at the instant the classes come off — the symptom does not reproduce headless, so the cause is what is watched |
| `page-modules` | the `data-page` → module map in `menu-footstrap-common.js` names the same page each mapped module tests for itself, every mapped module exports the `wire()` the loader calls, and no `'require'` pragma is left for one anywhere — one pragma puts the file back on every page and takes the saving with it |
| `chrome-fence` | the `[data-fs-chrome]` marker, fence and pin still match the chrome |
| `export-tier` | the `--*-color-*` contract: each level readable as text on three surfaces, each `--on-*` readable on its fill, and the ramp is not flat — measured with and without `prefers-contrast: more`, which re-states the inks |
| `css-i18n` | translatable strings emitted from CSS |
| `conffiles` | every shipped `/etc/config/*` is declared a conffile — `/etc/config/footstrap` is written at runtime by Save-as-default, and an undeclared one is replaced on upgrade |
| `changelog` | section set, order, dates, compare links, RU mirror parity, bold leads |
| `i18n` | `.pot` current, no empty `msgstr` |
| `shell` | every shell script in the source tree parses (`sh -n`) — including `release-notes.sh`, which otherwise fails inside the release job |
| `marker` | the `call BuildPackage` literal `include/scan.mk` greps for, without which the SDK does not see the package at all |
| `a11y` | axe-core WCAG 2.2 AA over `docs/gallery.html`, {light,dark} × {footstrap,hicontrast,bootstrap,2020,forum} × {untinted,60°,260°} |
| `placeholder-ink` | a hint may not read as a value the reader typed: every `placeholder` attribute and every `li[placeholder]` row on `docs/gallery.html`, across all eight palette/mode combinations, must have travelled ≥40% of the way from the field's own ink to its fill in light and ≥30% in dark (oklab lightness), and still measure 3:1 on that fill — SC 1.4.11, not AA's 4.5, which is the decision the token records: a dark palette has 6.45:1 of ink to spend against light's 14.84:1, and a hint that clears AA has not moved far enough to stop reading as a value. Every combination runs again under `prefers-contrast: more`, where the query hands the AA ink back and the thresholds swap (≥15%, 4.5:1). `a11y` is excluded from `li[placeholder]` for the same decision — axe measures that row as text and skips the attribute carrying the same ink. axe-core skips `::placeholder` entirely and the old ink was legal by every threshold the theme had — 11.12:1 on footstrap light, against the value's 14.84:1. It also caught a colour rule that had never applied: `li[placeholder]` (0,2,2) lost to the menu row's own `color` (0,3,2) |
| `test` | the unit suite (`node --test`, no browser): the shipped module is evaluated inside the same wrapper luci.js uses, and its pure logic is driven directly. For the cases a stand **cannot** produce — a luci-base with a surface missing, an alias loop in a foreign `menu.d`, a `firstchild` tie — not as a second opinion on what the stands already cover |
| `size` | ceiling on what the router SENDS, pinned once per release by `/release` (`--pin`, measured + 2 %) and never raised between releases: `cascade.css` after `build-css.sh` + the token mangle, and the shipped JS after terser — the package build's own asset half, reproduced. uhttpd serves `/www` uncompressed, so these are wire bytes |
| `icons` | the committed app-icon rasters still match `logo.svg` (per channel, with a tolerance) and still hold the maskable invariants — they are generated by `tools/build-icons.mjs` and cannot be rebuilt on the buildbot |
## The live gates, and why a file cannot answer for a page
`npm run live` — three gates that need a **running owlab router**, which is why they are not in
`npm run check`. Every gate above measures a file; every bug users have reported was about a page:
#11 a column shredded to one character per line, #22 a clipped submenu title, #14 an indicator that
did not fit, #10 phantom scroll from a hidden pane, #12 a doubled scrollbar in one engine, #8/#33/#36
a third-party app laid out wrong, and — reported on the upstream PR — two pages that came back empty
after a client navigation while every static gate stayed green.
| Gate | Holds |
|---|---|
| `upstream` | the coupling registry: every assumption fs-*.js makes about luci-base (`L.Poll`'s alias and queue, `uci.state.values`/`loaded`, `uci.load()` answering "what did THIS call fetch", `network.js` loading its three packages exactly once, `require()` publishing onto `L`, the modal contract, where `addNotification` puts a banner, an open dropdown being `position: absolute`) checked against the luci-base the router runs. Each failure names the module here that was written against it |
| `spa-parity` | every menu page opened BOTH ways — by click and by full load — compared on content, on uci's cache, on `network.getWifiDevices()` and on console errors. No baseline: a difference is always a bug |
| `install-check` | `install.sh` run twice on a stand — fresh, then over its own result — asserting the package is installed, `luci.main.mediaurlbase` points here and `cascade.css` is on disk. Three field reports (#16, #28, #30) were this script alone, all on the second-run path. It leaves the PUBLISHED release installed and re-syncs the working tree afterwards, so it runs last |
| `scroll-jank` | scrolls with a real wheel, long enough that a poll tick lands inside it, in BOTH layouts (they scroll different elements) and on every engine asked for: a table may not re-decide its remedy while the page moves, an element's document position may not change under the reader, and Chromium's layout-shift score for the window must stay under 0.02. The pass the theme defers to the stop is reported in a phase of its own, never failed. Chromium by default; `--engines chromium,firefox,webkit` needs the other two installed |
| `live-audit` | every menu page at 320/390/568/768/1024/1440, each resized into AND one (768 by default) ENTERED with a load of its own — a fault that only exists on arrival is invisible to a resize, which is how a table 65px past its column reached a user; the arrival records only what the resize at that width did not, because a fault both passes see is one fault: sideways document scroll, an element past the content column with no scroller, a clipped non-scrolling box, a sub-24px hit target with a neighbour, an operable element with no name, two stacked scrollports, a JS error. Ratcheted against `tools/baselines/live-audit.json` — a NEW signature fails, and `--update` (read the diff first) rewrites it. That file is a **union across platforms**: a handful of findings sit within a pixel of their threshold and text metrics differ between a maintainer's containers and CI's runner, so six signatures first appeared in CI |
`--engine firefox` and `--engine webkit` run the audit in the other two engines, keyed separately in
the baseline: #12 was Firefox-only, and a chromium run must not bless a finding it never saw.
Two more run in CI only. `tools/jsmin-verify.mjs` needs a jsmin built from the commit in
`luci-upstream.pin`. `ucode -T -c` over every template runs inside the `verify` containers, against
the installed theme with the router's own interpreter — which is also how you run it locally
(`owlab exec … ucode -T -c`), so nothing here has to build one.
+146
View File
@@ -0,0 +1,146 @@
# Working as a crew
The protocol for a Claude Code session on this repository: a lead thread that delegates, role
subagents that do the work, a card that holds the state, a loop that ends. For the lead and for
whoever edits a role in `.claude/agents/`. The short form is the "Crew" section of `CLAUDE.md`.
Why it exists: the heaviest session on record put 484 Bash results and 20 screenshots through one
context, against 5 delegations, and a context that carries every gate log recalls less of the rest.
A subagent spends tens of thousands of tokens and returns one to two thousand.
## Roles
| Role | Model | Writes | Sees | Returns |
|---|---|---|---|---|
| lead (main thread) | Opus | one-sentence diffs only | the card, the return blocks | decisions |
| `developer` | Sonnet | inside `card.files` | the card, its rules file, a handoff if any | files + gates block |
| `tester` | Opus | nothing | the diff and the card | verdict block |
| `security` | Sonnet | nothing | the diff | findings block |
| `researcher` | Sonnet | nothing | the question | answer + ranked sources |
| `caveman:cavecrew-investigator` | Haiku | nothing | a "where is X" question | `file:line` table |
One `developer` at a time, sequentially: two writers on one card produce two designs for one
problem, which is the failure every published account of parallel coding agents reports.
The tester runs on a different model from the developer: a judge of the same model recognises its
own text and grades it softer. It never sees the developer's reasoning, only the diff. Set the
lead's model with `/model opus`; the roles name theirs in frontmatter.
## The card
The lead writes `../tmp/task-<id>/card.json` before the first delegation. JSON, because models
rewrite JSON less readily than Markdown, and a file outlives a compaction of the lead's context.
```json
{
"id": "0142-appearance-note",
"goal": "one sentence",
"grade": "task",
"hardware": false,
"files": ["luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js"],
"rules": [".claude/rules/js.md"],
"acceptance": [
{ "cmd": "npm run lint:js", "expect": "exit 0" },
{ "cmd": "node tools/page-modules.mjs", "expect": "exit 0" }
],
"live": [{ "gate": "spa-parity", "flags": "--pages /admin/system/system" }],
"round": 0,
"max_rounds": 2,
"history": []
}
```
| Field | Values |
|---|---|
| `grade` | `task`, or `release` — adds `owlab test` on both formats. Any diff touching the Makefile, `root/**`, `build-css.sh`, `tools/stage.sh` or a `strip-*` script is `release` |
| `hardware` | `false` until the maintainer says otherwise for this card |
| `history` | one entry per tester round: `{ "round", "verdict", "blocking": [ids], "diff_hash" }` |
## Return blocks
Every role returns at most 25 lines: paths, not contents; no diff, no code, no story of the
attempt. The shape is in each role's file. The lead quotes the block and never opens the log it
summarises; the role that produced a log is the role that read it.
A role works in caveman full throughout, the mode the lead runs in — a subagent inherits no session
hook, so each role carries the rule in its own "Voice" section. It covers every line the role emits,
not the block alone: a question back to the lead, a `BLOCKED` line, a note on a gate that did not
run. Commands, flags, paths, numbers, quoted errors and every negation stay exact; the compression
is the prose around them, and it never grows the output. What a role writes to disk — code,
comments, a changelog line, a handoff — is normal English prose, as everywhere else here.
## The loop
1. **Size the task.** A diff the lead can describe in one sentence, it makes itself. More than one
file, any gate beyond T0, or more than ~50 lines of tool output: the crew.
2. **`developer`, round 0.** Implements, runs T0, stages what it touched with `git add`, returns.
3. **`tester`.** Reads `git diff HEAD`, runs what the card asks, returns a verdict. Only
`blocking: true` findings start a round; the rest go into `history` for the maintainer.
4. **Stop** when all three hold: T0 green, no blocking finding, the last round touched no file
outside the previous round's set.
5. **Otherwise, one fix round.** The developer gets the blocking findings and its previous
`not_done`. The tester gets `git diff` (worktree against the index, which holds the previous
round) and the previous findings — never the full diff again. Two fix rounds at most.
6. **Stall.** The same blocking id in two consecutive verdicts, or the same `diff_hash` twice, ends
the loop: the card goes to the human. A third round on a stalled finding is the ping-pong the
loop exists to prevent.
7. **`security`**, once, on the final diff. Skipped for CSS-only and one-line diffs. Findings go to
the human and are never auto-fixed.
While a card is open the index belongs to the loop: commit with `git commit`, not `git commit -a`,
and read `git status` before staging anything else.
The numbers behind the caps: gains of iterative repair sit in the first two rounds; a review
without a new external signal makes the result worse; a stall counter of 2–3 is what production
orchestrators use. Sources, ranked: the research artifact of 2026-09-03.
## Permissions
| Change in `.claude/settings.json` | Why |
|---|---|
| `owlab up/down/sync/test/exec/status`: `ask` → `allow` | disposable containers; a loop that prompts at every sync is not a loop |
| `dev-sync.sh`, `ssh`: → `ask` | the hardware router is the one thing a wrong run breaks for a person |
| `gh pr comment/review`, `gh issue comment`, MCP review calls: deny removed | publishing is a rule in `CLAUDE.md`, given per action, not a matcher |
| `Read/Write/Edit(../tmp/**)`: `allow` | the scratch directory, cards and handoffs |
| `additionalDirectories: ["../tmp"]` | `../tmp` is outside the project root, and the three rules above do not apply until the directory is registered |
| `git stash/clean/restore/reset --hard/checkout -- `, `git add -A|--all|.`: `ask` | the loop owns the index between rounds; these are the verbs that discard a round's work or widen it past the card, and `/upstream-pr` needs them, so they prompt rather than deny |
`tools/bg.sh` carries its own fence: `ssh`, `scp`, `sftp`, `rsync`, `dev-sync.sh`, `git push`,
`git commit`, `git tag` and `gh pr|release|issue` are refused there. The wrapper is allow-listed and
the matcher reads the first word only, so without that fence `tools/bg.sh sh -c '… ssh …'` runs past
every `ask` rule above. `tester` and `security` also carry `disallowedTools: Edit, Write,
NotebookEdit`, which makes "read-only" a fence and not only a sentence in the prompt.
`.claude/rules/*.md` do load inside a subagent when it reads a matching file (probed 2026-09-03,
Haiku subagent, `styles/**`). The developer still reads its rules file by name from the card: a
rule has to be in front of it before the first edit, not after the first Read.
## Measuring the crew
```sh
node .claude/tooling/measure-run.mjs # newest transcript of this project
node .claude/tooling/measure-run.mjs latest 3
```
The `delegated:` line is the other half of the trade: a subagent writes its own transcript under
`<session>/subagents/`, so the lead's number alone would only ever say that the lead got lighter.
The crew trial of 2026-09-03 reads 152k characters in the lead against 1.54M across 17 subagent
transcripts — the work moved, it did not evaporate.
Per card the crew must show: lead tool-output under 15k characters, zero images in the lead, every
gate log opened by a role, rounds ≤ 2, no stall. Baseline before the crew, as the command above
prints it: 136,544 characters of tool result plus 11 images (1.96 MB of payload) in this project's
largest session, and 417,073 characters, 20 images and 484 Bash calls against 5 delegations in the
largest of `luci-app-footstrap-files`.
Two testers were run side by side on trial card 1 (2026-09-03), `tester` on Opus and `tester-b` on
Sonnet, same prompt and same diff. Opus returned three findings to Sonnet's one: it also caught
that the new changelog entry pointed at files still untracked, and it ran `computed-diff --control`
without being asked, which is what turned 2468 differences from a number into a causal one. Sonnet
cost 36k tokens against 47k and finished in half the time, and found nothing the other missed.
Opus stays; `tester-b` is deleted.
The loop itself was exercised on a second card the same day: round 0 carried a planted `wrap-regex`
defect, the tester returned NEEDS_WORK with two blocking findings, and the developer closed them in
one round by deleting the unreachable helper rather than wrapping its regex — wrapping would have
been polish on dead code. One fix round, no stall, stop condition met.
+539
View File
@@ -0,0 +1,539 @@
# The stylesheet: source tree, layers, build
`cascade.css` is generated from `styles/` and is not in git. This page covers how the tree is
organised, how the cascade is kept disciplined, how the build works, and how to prove a CSS
change did what you meant.
Token names and values: [design-system.md](design-system.md). The rules a patch must follow:
[conventions.md](conventions.md).
## One directory per cascade layer
```
luci-theme-footstrap/
build-css.sh
styles/
00-header.css banner + the single @layer declaration
02-tokens.css @layer tokens private --fs-* tier + the --*-color-* export tier
03-palettes.css @layer tokens palettes (tokens only)
04-nocolormix.css @layer tokens static twins for the 36 color-mix() tokens
base/ @layer base widget defaults the views count on
10-reset 20-typography 30-forms 40-tables 50-chrome
60-modal 70-buttons 90-widgets 95-luci
theme/ @layer theme footstrap's own components and layouts
10-chrome 15-wallpaper 16-login-bg 20-shell 25-progressbar 30-tables
35-alerts 40-tabs 45-misc 50-toplayout 55-buttons 60-inputs
65-dropdown 70-modal 75-search 90-responsive 95-a11y-media 97-print
pages/ @layer page per-page corrections
10-login 20-overview 30-software 40-sshkeys 50-leases
60-assoclist 70-syslog 80-appearance 90-processes
```
Concatenation order is `styles/` → `base/` → `theme/` → `pages/`, and inside each directory the
numeric prefix is the order.
`base/` came out of one 2300-line file. The split was purely mechanical: not a single rule moved
within the layer, and the computed-style diff was zero — that was the condition for calling it
done.
Palettes are split across two files on purpose: `03-palettes.css` holds only token definitions
and lives in the `tokens` layer, while rules like
`:root[data-wallpaper="pattern"] .fs-main { background-color: … }` are ordinary styles and would
lose to `theme` from inside `tokens`. They live in `theme/15-wallpaper.css`.
The directory cannot be called `src/` — to `luci.mk` that means C sources.
## Layers
```css
@layer tokens, base, theme, page;
```
A later layer beats an earlier one **regardless of selector specificity**. So a `theme` rule
never has to outrank a `base` rule by specificity or by `!important`.
The unlayered level outranks every layer and is deliberately left empty — it is the escape
hatch. `node.css`, which LuCI attaches for individual pages after the theme, also lands there.
### Layer order is fixed by the FIRST mention of a name, and that can be hijacked
`@layer tokens, base, theme, page;` holds the order only while `cascade.css` is the **first sheet
in the document to name a layer**. A name met earlier becomes the *first* — that is, the weakest
— layer, and everything declared afterwards stacks above it. If a foreign sheet carrying
`@layer theme` lands ahead of ours, the order becomes `theme, tokens, base, page`, and `base`,
where `* { padding: 0 }` lives, starts beating the entire chrome. Measured on a router:
`.fs-content` loses its `24px 28px`, and the bar, tabs and buttons collapse with it.
Not hypothetical — it is the flip side of the re-host in `fs-sheets.js`: we wrap a foreign sheet
in `@layer theme`, but *where* the package inserted it is the package's choice. Ace (pulled in by
`luci-app-ssclash` and any package with an editor) puts its `<style>` **first child of `<head>`**
through `dom.importCssString`, and lazily — some of it on first hover. Hence the bug report
shaped "fine after a reload, right up until I hover something".
One declaration fixes it: `fs-sheets.js` re-inserts `@layer tokens, base, theme, page;` as a
**new** `<style>`, first child of `<head>`. New specifically — inserting a sheet recomputes the
order, moving an existing one does not (checked both ways). A static declaration in the
template cannot help: a foreign sheet can still land in front of it, so the answer has to be
reactive to a `<head>` mutation.
### `!important` inverts layer order
For important declarations the order is reversed: important in `base` beats important in
`theme`, which beats important in `page`.
`base/` is down to **8**: six are the `.left/.right/.center/.top/.middle/.bottom` utilities, whose
whole point is coercion, and two fight inline `style=` (zone colours, `stroke:black` on SVG graph
lines).
The rule for a flag anywhere: it must fight **inline `style=`** or an **unlayered `<style>`** an
app injected (`package-manager.js` emits exactly that). Anything else is cargo cult — if a rule
needs a flag to beat *another footstrap rule*, it is in the wrong layer. A revision against that
criterion, checked property-by-property against what the JS actually writes inline, removed 11
flags of 43 and added one that was missing.
The single exception is `theme/95-a11y-media.css`: `prefers-reduced-motion` has to kill
animations declared in `base` as well as `theme`, and only an important declaration reaches back
a layer. The inversion is what makes that file possible. `audit.py` holds the allowlist
(`BANG_OK`) and `--strict` fails on any flag outside it; `css-metrics` caps the total at **27**.
## The browser floor
The theme has never declared one, and the sheet quietly moved it twice. `npm run css-floor` now
derives it from the built stylesheet and fails if this section disagrees.
<!-- css-floor -->
**Chrome 108**, **Firefox 101**, **Safari 15.4** — below that the page is wrong, not plainer.
<!-- /css-floor -->
That floor is set by `@layer`, `:is()`/`:where()`, `:focus-visible`, `svh`/`dvh`, `accent-color`
and the logical properties. Nothing on the list has a fallback worth writing: a theme whose layers
are ignored is not a theme.
Five features are used **above** the floor and are progressive — the rule does not apply and the
page is plainer:
| Feature | Chrome / Firefox / Safari | What is lost below it |
|---|---|---|
| `:has()` | 105 / 121 / 15.4 | 59 refinements: port tiles, password control-group, stacked-table footers |
| `color-mix()` | 111 / 113 / 16.2 | the 36 mixed tokens fall back to `styles/04-nocolormix.css` |
| `@container` | 105 / 110 / 16.0 | five width adaptations inside `fs-view` / `fs-content` |
| `text-wrap: pretty`, `scrollbar-width` | — | typographic polish |
| `overflow: clip` value | 90 / 94 / 16.0 | `.fs-main`'s `overflow-x: clip` and `.fs-staging`'s `overflow: clip` drop, and the browser's own cross-axis correction (CSS Overflow 3) takes over: a sideways scrollbar on the desktop sidebar's `.fs-main` (its sibling axis is already `overflow-y: auto`) or, in the top/narrow layouts and on `.fs-staging`, whole-page horizontal scroll — exactly how stock LuCI (no `clip` at all) already renders. `.fs-staging` also stays `visibility: hidden` regardless, since nothing inside a staged view sets `visibility: visible` on itself, so nothing is exposed. Measured with `overflow-x`/`overflow` forced to `initial` in a live Chromium, task 0151 |
Two rules follow, and the gate holds both.
**A `:has()` part never shares a selector list with a part that has none.** A selector list is not
forgiving: one unsupported compound invalidates the whole rule. `.table.fs-stacked .tr,
.table.fs-stacked tfoot:not(:has(> .tr))` was one rule, so Firefox 115 ESR lost `display: flex` on
every stacked card — a broken mobile table, not a missing refinement. Split it, or wrap the list in
`:is()`, which *is* forgiving and drops only the compound it cannot parse.
**A token built with `color-mix()` gets a static twin in `04-nocolormix.css`.** A custom property
keeps any token stream it is handed, 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`. That is not a
fall back to the previous declaration — the ordinary two-declaration trick does nothing for a
token, which is why the twins are a file and not a prelude. Tints degrade to `transparent`
(an absent surface never lowers contrast), hairlines to the solid colour they are a fraction of,
and the focus ring to `--fs-focus-ring-solo`, which was already mix-free.
Adding any CSS feature the sheet has not used before fails `css-floor` until it is classified in
`tools/css-floor.mjs` as hard or soft and the baseline is refreshed with
`node tools/css-floor.mjs --update`. The JS floor is lower and is not the constraint: the SPA
router uses nothing younger than `ResizeObserver`, and `requestIdleCallback` and `CSS.supports`
are both feature-detected.
Classification is by PROPERTY, which misses a value with its own support story: `overflow` is
ancient, `overflow: clip` is Safari 16, and the gate said nothing while the value shipped a version
past the floor above. `css-floor`'s `VALUES` table checks the exact property/value pairs worth
tracking — most values share their property's support, so this stays a short, explicit list rather
than a scan of every value in the sheet.
### Vendor prefixes still in the sheet
Every prefix earns its place here or it goes — checked against MDN's browser-compat-data and
caniuse each time, not carried forward on habit. `-webkit-mask-image` is the one that looks safe
to cut and is not: it was nearly dropped from memory alone before the version was actually
checked.
| Prefix | Cost | Status (checked 2026-09-05) |
|---|---|---|
| `-webkit-mask-image` / `-webkit-mask-size` / `-webkit-mask-repeat` / `-webkit-mask` | 450 B | **required** — unprefixed masking ships only from Chrome 120; the floor is 108 |
| `-webkit-backdrop-filter` | 214 B | **removed 2026-09-05** — with it the property itself: the theme carries no `backdrop-filter` any more (see the changelog for the measurement) |
| `-webkit-line-clamp` + `-webkit-box-orient` | 49 B | **required** — no standard line-clamp equivalent at the floor |
| `::-webkit-scrollbar-thumb` | 56 B | **required** — no standard scrollbar-styling equivalent at the floor |
| `-webkit-appearance` | 96 B, 4 occurrences | **removed 2026-09-05** — unprefixed `appearance` ships from Chrome 84 / Firefox 80 / Safari 15.4 (MDN BCD; caniuse gives Chrome 83), all at or above the floor |
## The build
`build-css.sh` concatenates the directories and — **without `--dev`** — runs the result through
two awk passes: **467 615 B → 135 655 B** (measured 2026-08-12; the figure moves with the tree, the
ratio does not). That is not cosmetic: uhttpd serves
`/www/luci-static/*.css` with no gzip, so every byte travels as-is. (The package build shortens
the private token names on top of that, landing around 120 KB — see [package.md](package.md).)
With `--dev` it is a plain `cat`: the output is byte-identical to the concatenated sources. A
file to read on a router, not one to ship.
**Pass 1 — comments.** Strips `/* … */` except the `/*! … */` banner, which is Apache-2.0
attribution and is copied verbatim. The stripper is string-aware: a naive search for the
nearest `/*` would eat everything up to the next `*/` on the first `content: "/*"`.
**Pass 2 — whitespace CSS ignores anyway**: the space after `:`, spaces around `{ } ; ,`, the
**last `;` of a block**, and the newline after each declaration, so the output is one rule per
line. All of it inside the same string-aware pass. The last `;` used to be removed by a bolted-on
`| sed 's/;}/}/g'`, and sed does not see strings: `content: ";}"` became `content: "}"`, and a
data URI with the same two bytes broke the same way. No such pair exists in the tree today — and
that is precisely how the bug waits for whoever adds the first one.
What is not touched, each for its own reason:
- **a single space between selectors** — `.a .b` is a descendant, `.a.b` is not;
- **spaces inside `calc()`** — mandatory around `*`, `/` and the minus in `calc(100% - 8px)`;
- **a newline inside a declaration** — also a space. When the scanner once joined lines
needlessly, a wrapped `calc()` came out as `…))- .004 …`; a minus with no space *before* it is
a parse error, the declaration fell away, `--fs-tint-c` became undefined, `--fs-bg` invalid at
computed-value time, and the canvas silently went white (caught by `export-tier` as 1.5:1);
- **anything inside a string** — every data URI here is quoted and full of `:`, `;` and spaces.
Selectors and declarations are never rewritten.
**Two guards against a broken file** (there is no upper size budget — it was removed):
1. **Braces are counted twice** — before and after compression — and the build fails if the
**rule count changed**. The first count only sees the *input*; compression is the pass that
can corrupt a sheet, so an unchanged counter is the proof that it did not. Always counted on a
comment-stripped copy, otherwise a lone `{` in prose would fail a valid `--dev` build.
2. **A floor**, `FS_CSS_FLOOR` = 81 920 bytes. A correctness gate, not a size budget: the script
refuses to write a suspiciously short file — a truncated write, a full disk, a compression
that ate the tail — which would ship a sheet missing its second half.
An unknown option is an error rather than an output path (`--devv` once wrote the stylesheet to a
file called `--devv`).
### How it runs on OpenWrt
`luci.mk` copies only `luasrc ucode htdocs root src` into `PKG_BUILD_DIR`, then calls
`Build/Prepare/$(LUCI_NAME)`. `styles/` is not in that list, so the script reads from `$(CURDIR)`
and writes straight into the build tree — the sources stay clean. It needs only `cat` and `awk`,
so the OpenWrt buildbot builds it with no host dependency.
### Why no preprocessor
No LuCI theme runs one on the buildbot: `luci.mk` can only minify, and there is no `node`/`sass`/
`postcss` there. argon (LESS), aurora (Tailwind + Vite) and fluent (SCSS + Vite) therefore compile
on a developer machine and **commit the built CSS**. `cat` is enough here, so the build honestly
travels with the package.
Multiple `<link>`s are normal practice (stock bootstrap ships `cascade.css` + `mobile.css`) but
buy nothing here: the same total bytes, and `?v=` (`pkgs_update_time`) is one value for the whole
page, so the files cannot be invalidated separately anyway. A runtime `@import` (material's
approach) is worse than a second `<link>` — it is discovered only after the first file is fetched
and parsed, serialising the requests.
## `@mirror`: duplication you cannot delete but can stop rotting
`css-dup` finds two rules with identical declarations under mutually exclusive guards (a media
query against an attribute selector, two `@container` thresholds). A cascade-aware reader needs
both copies, so no linter will ever call it an error — and this is exactly the shape that drifts.
The trap it was built for: `css-dup` matches *identical* bodies, so the moment the copies diverge
they stop being a duplicate and it goes quiet — **precisely when it should shout**.
So every duplicated body must be a decision: merge it into one rule, or pin it. There is no
numeric budget — a budget is a number nobody defends, and it waves through the next unexplained
copy for free.
The body is pinned, not the rule, so the wrapper goes **inside the braces** (the selectors are
legitimately different; only the declarations must match):
```css
.table.fs-stacked .td[data-title]::before {
/* @mirror table-card/label */
content: attr(data-title); display: block; margin-bottom: 3px;
/* @endmirror */
}
```
`css-dup` then accepts the duplicate and `tools/mirror.mjs` (`npm run mirror`) keeps the copies
byte-identical: edit one and the build fails until you fix the other. **An unpinned duplicate is
a hard failure. A `@mirror` group with one copy is also a failure** — a mirror of one holds
nothing.
`npm run mirror` prints the current groups; read them from there, not from a doc. Today it reports
seven, plus one whole-file mirror:
| Group | Copies |
|---|---|
| `table-card/label`, `table-card/actions`, `table-card/actions-inner` | `theme/30-tables.css` (`.fs-stacked`) ↔ `theme/65-dropdown.css` (`@container`) |
| `ind-badge/paint`, `poll-glyph/mask` | two places in `theme/20-shell.css` (the sidebar and the rail) |
| `selected-row/paint` | `theme/60-inputs.css` ↔ `theme/65-dropdown.css` |
| `@same-file LICENSE` | the whole file |
## The card contract: what is measured and what is not
A table folds into cards by two different mechanisms, and that is not an unfinished job.
**A data table is measured.** `fs-fit.js` (through `fitTables` in `fs-select.js`) removes the
class, reads the width, decides, and sets `.fs-stacked`. The decision depends on what the table
needs, not on the screen, so `@media` cannot express it: cards can happen at any viewport
width. Measuring is safe because a data table holds no widgets.
**But a measurement is never taken while the reader is scrolling, and a table that has not been
answered for takes no room.** Both come from the same failure, reported from an iPhone against a
remote router: the poll REPLACES these tables, so every tick handed the fitter an unmarked element
that was laid out full-width for a frame — several screens taller, at 390 px, than the card stack it
was about to become — and the fit pass itself read layout once per table in the middle of a flick,
which is exactly the work iOS holds the main thread back to prevent.
So `fs-fit.js` has two registration channels, and which one a pass belongs in is a contract rather
than a habit: `fit.add()` for a pass that may read layout, which therefore never runs while the
reader scrolls and is re-run when they stop, and `fit.addAlways()` for a pass that only writes.
Marking a freshly arrived table is the second kind — it cannot wait, because
`:root[data-fs-fit] .table.fs-dt:not(.fs-fitted) { display: none }` keeps an unanswered table out of
the layout until it has an answer. Where does that answer come from without measuring? From the
SLOT: the section frame survives a poll tick, so the table that was replaced left its decision
there, keyed by column count and room. The guard on `:root[data-fs-fit]` is what makes the rule
safe: the attribute is written by the module that CLEARS the rule, so a document where that JS never
ran shows every table exactly as it did before the rule existed.
**And a table this rule is still hiding may not be judged.** A `display: none` box has no content
width, so the first pass over a table with no slot to inherit from read `scrollWidth: 0`, found that
0 overflows nothing, concluded "no remedy" — and CACHED that answer. The table then appeared at its
natural width: on Status → Processes at 768px, 777px inside a 712px column, columns cut off by
`.fs-main { overflow-x: clip }` with no scrollbar and nothing to say so. A second pass ~60 ms later
usually corrected it, which is why it reached a user rather than a gate: that pass exists only if
something mutates `#view` again, and a page that renders once and stands still never does. So
`fitTables()` treats a zero measurement as no answer at all — it lifts the gate, asks for one more
frame, and writes nothing to the slot. Measured entering that page at 768: 2 of 8 arrivals on 0.13.1
and 3 of 8 mid-fix left the table past the column; 0 of 12 after, on each of the four stands.
**A config table (`.cbi-section-table`) is not measured and must stay on
`@container fs-content (max-width: 960px)`.** Its rows are full of widgets (`fs-select.js` turns
every `<select>` into a `ui.Dropdown`), and a widget bakes in the width of the layout it was laid
out in — so unfolding it to take a reading **changes what you are measuring**. Measured on a live
router: after such a toggle the firewall zone table claimed it needed **1747 px** where it really
needs **1190 px**, and overflowed its section by **557 px** — an overflow the pure-CSS version
never had. **The act of measuring was the bug.** Do not "finish the job".
The price is the last irreducible duplicate: the same declarations under a class and under an
`@container`, which CSS cannot factor apart. It is pinned with `@mirror table-card/{label,actions}`.
### Which of the three a table gets, and what decides it
Every `<table>` or `<div class="table">` under one of the two content roots — `#view`, and the
`#modal_overlay` that `ui.showModal()` builds its dialog in — lands in exactly one of three tiers, and the
discriminator is **does it have a header row**, never who wrote it:
| Tier | Reached by | What it does |
|---|---|---|
| measured card | a header row **and** not `.cbi-section-table` → `fs-select.js` tags `.table.fs-dt` | folds into labelled cards when it stops fitting, at any width |
| `@container` card | `.cbi-section-table` | folds at 960 px of content, never measured (above) |
| scroll | no header row at all | `display: block; overflow-x: auto` on the table itself |
**Both roots, and that was a bug for as long as it said one.** The dialog is a sibling of `#view` on
`<body>`, so a table in it matched no selector this file describes: it was never tagged, never
captioned, never measured, and — since `fs-fit.js` watched only the one host — never re-measured when
the dialog replaced its rows. Measured at a 390 px viewport with the wireless scan dialog's own
markup: the table rendered **373 px wide inside 317 px** of dialog, the Encryption column got **10 px**
and printed one character per line; carded, it is 317 px with every value on a labelled line. The
roots are listed once in `fs-select.js` (`ROOTS`) and every query is built from them, because three
selectors that each name the same two places are three chances to fix only two.
**A dialog is a root only while it is open.** `hideModal()` drops `modal-overlay-active` off `<body>`
and leaves the markup where it is, and the hidden overlay shrink-fits — measured at **270 px**, i.e.
**236 px** of room for a table that will be shown at the dialog's real width. Measuring that is both
waste (every pass, on a polled page) and a decision about a width nobody will see, so the pass asks
per-pass whether the dialog is open. The flag flips **after** `showModal()` writes the content, so
`fs-fit.js` watches `body`'s class as well as the two subtrees; without it a dialog's table would
wait for its next poll to be fitted.
A header row is any of four markups, and each missing one has cost a page: `.tr.table-titles`
(`L.ui.Table`), `.tr.cbi-section-table-titles` (the apk Software list), a `<thead>` — E()-built, so
its `<th>`s may hang off it directly with no `<tr>` — and a first row made entirely of `<th>`, which
is what a foreign app writing plain HTML emits. `labelCells()` then **copies** the heading of the
column each cell sits in into `data-title`, which is what the card prints above the value; it never
overwrites one the app set, and it counts COLUMNS rather than cells so a `colspan` does not shift
every caption after it by one.
The third tier is the deliberate refusal. A card prints `attr(data-title)` above each value, and a
matrix — a log, a statistics grid, a layout table — has no headings to print, so carding one yields a
column of numbers with nothing saying what they are. Comparison is that shape's whole point, so it
scrolls instead. **The scroll rule is not scoped to a phone.** It spent its life inside
`@media (max-width: 767px)`, which is the same mistake the card stack was built to avoid: whether a
table fits is a property of its content and its column, and a 400 px panel on a 1600 px desktop hits
the wall a phone hits. It costs nothing where the table fits — `overflow` paints a scrollbar only
when there is something to scroll.
### The floor, and why exactly one tier has one
A cell that may break **anywhere** has a min-content of **one character** — that is what the value
means (css-text-3 §5.4: the soft wrap opportunities it introduces *are* counted towards min-content,
which `break-word` does not do). Given that, auto table layout is free to starve a column instead of
the table overflowing, and `fit.overflows()` — the one question the browser answers exactly — goes
blind. That single fact produced four per-page `nowrap` rules and three JS heuristics, each
reconstructing the number the engine had before the theme threw it away.
So the break value follows the tier, and the split is the design:
| Tier | Break value | Why |
|---|---|---|
| measured data table, while it is a table | `break-word` (`theme/30-tables.css`) | it can card, drop columns or shred one column, so it must tell the truth about what it needs |
| carded data table | `anywhere` | every value owns the row; there is no neighbour left to starve |
| config table, key/value include, meter rows, realtime legend | `anywhere` (`base/40-tables.css`) | none of them is measured and none can card on demand — containment is the only outcome available |
| header row, first column | `break-word` (`base/40-tables.css`) | the labels you read the table by (issues #32, #36) |
`tools/table-contract.mjs` (`npm run tables`) holds that table to the sheet, selector by selector: a
fifth place to decide how a cell may break is a hard failure, and so is any of them appearing under a
viewport query.
**One `!important` earns its place here.** `luci-mod-status`'s `processes.js` writes
`style="word-break: break-word"` on its Command span — the deprecated alias for
`overflow-wrap: anywhere`, from an inline declaration no layer can outrank. Measured at a 720 px
viewport: the column sat at **126 px** against a **353 px** token, and forcing that column's
`overflow-wrap` to each of the three values in turn changed nothing, because the floor was being
erased one level down. Neutralised, the same table reports **963 px** of need in **688 px** of room —
the truth the ladder needs.
**And one rule had to go for any of it to work below 767 px.** `theme/90-responsive.css` used to give
`table.fs-dt` `display: block`, which discards the table formatting context: the rows become an
anonymous table sized to the block, so the table can never be wider than its parent and therefore can
never overflow. With it in place no floor was readable at a phone width, at any break value.
### The ladder: what happens when a table does not fit
`fitTables()` asks one question — does it overflow? — and answers it with the cheapest remedy that
works, re-measuring at every rung:
1. **it fits** → it stays a table, and nothing was written;
2. **drop the columns the view marked expendable** (`hide-xs`/`hide-sm`, which `ui.Table` copies from
the header cell onto every body cell) and ask again — upstream's own priority hint, honoured by
measurement instead of at 767 px;
3. **let the widest breakable column shred** (`.fs-td-break`; never the first column, never a `nowrap`
one) and ask again;
4. **card it** (`.fs-stacked`).
Rungs 2 and 3 need no threshold, and that is the point: **the guard is the second measurement.** Where
one long token is the whole problem, breaking that column keeps the table a table; where every column
is over its share, breaking one changes nothing and the card is right. The only number left in the
file is `CRAMPED` (568), the judgement that a table below that much room has stopped being a table at
all — measured, stated, and not derivable from anything.
A table with **no** header row cannot card (a card prints `data-title`, and it has none), so it
scrolls inside itself instead — `.fs-xscroll`, written only when it is measured to overflow **and**
holds no widget. That refusal is not caution: `overflow-x: auto` computes `overflow-y` to `auto` as
well (css-overflow-3 §3.1), so a scrolling table clips every popup inside it, and luci-base sizes an
open dropdown against the nearest scroll parent. WCAG 2.2 SC 1.4.10 names data tables as the
exception where two-dimensional scrolling is acceptable, which is what makes this an outcome rather
than a failure — and the scrolled table keeps its first column pinned, takes a tab stop with
`role="group"` and a name, and draws a focus ring, because a scroll box the keyboard cannot reach is
content the keyboard cannot read.
## Proving a CSS change
**Screenshots do not work here.** On a live router, uptime, DHCP leases and signal strength give
0.5–1.3% pixel difference between two runs of the same stylesheet, while a real regression
(buttons switched to a monospace font) weighs 0.19%. The noise buries the signal.
The method that does work: load the page once, snapshot `getComputedStyle` over ~50 properties for
every element, swap the `<link>` for the second stylesheet, snapshot again. Same DOM, same data —
so any difference was caused by the CSS.
> `cssdiff.py`, the script the changelog and `audit.py` refer to by name, is the maintainer's own
> tooling and is not in this repository. Without it, drive the four steps above with Playwright
> yourself — the result a review asks for is the property diff, not that particular script.
Two traps in the method itself:
- **Web fonts.** Neither sheet ships one any more, but a machine with Manrope or JetBrains Mono
installed still resolves them, and any page-supplied `@font-face` restarts font matching when the
`<link>` is swapped. A snapshot taken before matching settles measures fallback metrics — every
width on the page shifts by a pixel or two and drowns the real diff (291 false differences on the
firewall page, back when the faces were bundled). Wait for `document.fonts.ready` before each
snapshot.
- **`admin/status/overview` polls and redraws itself**, so it shows ~18 differences even with
identical CSS on both sides. Run a control pass (A = B) to learn each page's noise floor.
Four bugs it caught that a screenshot diff missed: `.cbi-value-field *` painting buttons inside a
field monospace; a `max-width: 100%` filed under a "phone overflow" banner but sitting outside
its media query; table-row buttons described twice so the height came from one block and the
padding from another; and an actions column losing its right alignment.
### The component gallery
`docs/gallery.html` renders every widget `ui.js`/`cbi.js` can emit, with the real class names, so
you do not have to hunt the router for a page that happens to contain the control you changed. It
is not part of the package; it is published to GitHub Pages alongside the built stylesheet, and
the a11y gate runs axe-core against it.
It immediately found two holes that existed on no router page but that any third-party
`luci-app-*` can hit: native `input[type=button|submit|reset]` (which `styles/base` gave only
`width/height: auto`) and the `.label` family.
## There is nothing left to shrink, and that is measured
The idea "132 KB is a lot, let us compress it" comes back periodically. It has been tested with
measurements rather than argument, and the answer is no in every direction. This section exists so
the next attempt starts from these numbers instead of redoing the work.
**There is no dead code: 96% of the bytes actually match.** Rule coverage via CDP
(`CSS.startRuleUsageTracking`) across 15 router pages plus `gallery.html` gave **92%**; a second
pass through the branches the first missed (top layout, dark mode, wallpaper, reduced motion,
Russian locale) took it to **96%**. The remaining ~4.8 KB is four conditional branches that each
have to exist: `@media print`, the latin-ext/cyrillic `@font-face` blocks, an `assoclist` rule
that needs a page with connected clients, and the licence banner.
> Trap in the measurement itself: CDP returns only *used* rules in `ruleUsage`, and an
> `@layer`/`@media` block is reported together with everything nested in it. The first two attempts
> honestly reported "100% coverage" and were lies. Collect the use ranges, merge the overlaps, and
> subtract from a parse of the file. A number obtained any other way needs redoing.
**And it is not a deletion list.** The coverage contract forbids removing a selector because it
was not seen — see [conventions.md](conventions.md). The measurement answers "where is the weight",
not "what can go".
**No structural reserves either.** Selectors are 37% of the file, declarations 58%. Native nesting,
under an honest criterion (adjacent groups only, no reordering that would break the cascade), saves
**4.2 KB / 3.5%** — not worth rewriting the tree for ~3 ms on the wire. Vendor prefixes are 648 B,
data URIs 2.2 KB, all needed. The largest rules (the `:root` token block, the palettes) are
foundation, not fat.
**Compression is unavailable in principle.** gzip would take ~132 KB to ~23 KB, the biggest
possible win — but `uhttpd` has no compression option at all, in neither `-h` nor
`/etc/config/uhttpd`, and it does not serve a pre-compressed `.gz`. A grep of the whole uhttpd
source for `gzip|content-encoding|deflate|zlib|brotli` returns exactly one hit, a MIME type. The
2015 patches were not merged. Not our lever until the web server changes.
**Also tried and rejected, so nobody re-derives them:**
- **`<link rel=preload>` for JS modules is harmful, not merely useless.** LuCI fetches modules by
plain XHR, whose mode does not match preload's CORS mode, so **every file downloads twice**:
FCP 336 → 460 ms, 708 → 862 KB on two modules. Retested as `as="fetch" fetchpriority="low"`
over 22 modules: 49 → **71** requests, +250 KB wasted, wall time unchanged. No form of preload
deduplicates against the XHR loader.
- **Flattening the `require` graph at the entry point** hits the connection limit, not the depth.
A flat `L.require` list does collect the four waves into one — and gains almost nothing
(835 → **804 ms** at 60 ms RTT), because HTTP/1.1 keeps **6 connections per host** and the waves
simply re-form as batches of six. Worse, `menu-footstrap` finishes at **395 ms instead of 200**,
queued behind twenty siblings.
- **Warming `uci.load()`** for common configs: 1113 → **1106 ms**, identical XHR count.
- **Service Worker and CacheStorage are unavailable in principle.** A LAN IP over http is not a
secure context, so `serviceWorker`, `caches` and `navigator.storage` are all `false`. The whole
precache/offline family is out — not expensive, impossible. (`DecompressionStream` *is*
available, so "ship `.gz` and inflate in JS" is technically possible: 135 KB → 23 KB. At the
cost of FOUC on a cold load, a `<noscript>` fallback, and zero gain warm. Not done.)
- **Splitting into a critical and a deferred sheet** trades the wrong way: cold FCP 336 → 276 ms,
but warm **108 → 180 ms** — and an admin browses with a warm cache.
- **Dropping the font preloads**: −24 ms FCP at the cost of FOUT. Overtaken by 0.12.1, which
dropped the webfonts themselves — there is no preload and no FOUT left to trade.
- **`content-visibility` for long tables**: empty. Real router tables are short (Startup 46 rows,
Processes 34) and a full layout pass costs 2–3 ms.
**Where the time actually is.** Re-measured after the webfonts left, on the owlab 25.12.4 stand
with the packaged artefacts (mangled sheet, terser'd JS), overview page, five cold contexts,
medians: **691 KB over 56 requests**, of which ours is **187 KB** (121 774 B stylesheet plus
69 593 B of JS), the LuCI core 485 KB, the document 18 KB and **fonts 0 KB**. The core figure is
this stand's package set, not a floor. FCP is set by the sheet as a whole, but cutting the
sheet by 10% is ~10 ms — to move FCP visibly you would have to halve it, and as shown above there
is nothing to halve. A warm SPA transition costs **9 ms against 96 ms** for a full load: the
theme's main optimisation is already done, and it lives in the router
([spa-router.md](spa-router.md)), not in the stylesheet.
+523
View File
@@ -0,0 +1,523 @@
# Design system: tokens, palettes, type
What the theme's values are and why they are those values. How the stylesheet is built and how the
cascade is kept disciplined: [css.md](css.md). What the chrome does with them:
[chrome.md](chrome.md).
Reference mock-ups: `docs/design/`.
**There is one system.** One renderer (`menu-footstrap.js`), one template directory, one
`cascade.css`, one entry in `luci.themes`. Layout (sidebar or top bar) is a client setting,
`:root[data-layout]`, always with an explicit value.
**No colour literals in this page on purpose** — a colour written in two places drifts, and it
already has: this document was once read as the source for `--accent-soft: rgba(9,105,218,.10)`,
the very per-component copy the project killed. Values live in `styles/02-tokens.css` and
`styles/03-palettes.css`, with the reasoning in a comment beside each one.
## Two tiers, and the split is load-bearing
- **The private tier `--fs-*`** (`02-tokens.css`, `03-palettes.css`) — `--fs-bg`, `--fs-panel`,
`--fs-panel2`, `--fs-border`, `--fs-text`, `--fs-dim`, `--fs-faint`, `--fs-placeholder`,
`--fs-accent`, `--fs-good/-warn/-danger`, `--fs-track`, plus the radius, z-index, duration and
spacing scales.
**Every rule in the theme reads this and only this.**
- **The export tier `--*-color-*`** — the conventional LuCI names (`--primary-color-high`,
`--text-color-*`, `--border-color-*`, `--on-*-color`), defined from the private tier and read
by nobody inside the theme. This is not a bridge, it is a one-way export:
`audit.py --strict` **fails the build** on any read of an export name from `styles/`.
Why: `:root` is a shared scope, and every `luci-app-*` puts its CSS in the same document
**unlayered**, where it outranks any `@layer`. One app declaring `:root { --accent: … }` — or
`--radius`, `--text`, `--border`, names anyone would take — silently recoloured the whole theme.
Reading export names from `base` was the wider hole still: `--text-color-high` is a
*convention*, so an app declares it more readily.
| Hostile `:root` over `gallery.html` | elements recoloured |
|---|--:|
| before the split | **312 of 336** (93%) |
| after | **0** |
The exception is a local variable declared inside the very rule that reads it (`--bd-color`,
`--fg-color`, `--on-color`, `--focus-color`): a foreign `:root` cannot intercept those.
```css
/* NO — reading an export name; the gate fails */
color: var(--text-color-medium);
/* YES — the private tier */
color: var(--fs-dim);
```
Third-party apps get the other side of this contract in
[luci-app-styling-guide.md](luci-app-styling-guide.md).
### The export tier is a RAMP, not a set of aliases
`high`/`medium`/`low` must be **three different colours**: a consumer asks for a gradation and gets
whatever we declared. All three were once aliases of one token — and `luci-app-podkop` drew its
"no data" latency in `--primary-color-low`, i.e. the same bright accent as a live value. **A flat
colour passes every contrast threshold there is**, so nothing failed; it failed at the user.
The axis of the ramp is **chroma at constant lightness** (`color-mix(in oklch, …, var(--fs-dim))`).
Both obvious alternatives were measured and rejected: fading `low` toward the surface spends
contrast the palette does not have (in dark mode every accent on `--fs-panel2` already sits at
4.56:1, i.e. +0.06 over AA), and pulling `high` toward `--fs-text` collapses the ramp in dark mode
where `--fs-text` is nearly white.
The binding constraint: apps read a level as `color:` about as often as `background:`, so every
level must pass AA as text on `--fs-bg`/`--fs-panel`/`--fs-panel2` and carry a readable
`--on-*-color` as a fill. `tools/export-tier.mjs` proves all of it across
{footstrap, hicontrast, bootstrap, 2020} × {light, dark} × a sweep of tint hues — 56 combinations
and 4352 contrast checks today — including that the ramp is not flat, the only check that can catch flatness.
Borders are exempt from the text half: `--border-color-low` may legitimately fade into the surface,
which is what a hairline is for.
## Palettes
Five, all in `styles/03-palettes.css`, one self-contained block per (palette × mode):
**footstrap** (GitHub Primer colours, the default, filling a bare `:root`), **hicontrast**
(`data-palette="hicontrast"`), **bootstrap** (`data-palette="bootstrap"`, the stock LuCI theme's
surfaces and greys — its semantic colours and its light ink are raised where they miss AA, which
that block documents in place), **2020** (`data-palette="2020"`, the OpenWrt 2020 theme's
colourway) and **forum** (`data-palette="forum"`, the OpenWrt forum's Discourse colourway). Light
mode is the bare `:root`; dark is `:root[data-darkmode="true"]`. The file also carries the
instructions for adding a palette.
**forum** is sampled straight off forum.openwrt.org's own `getComputedStyle`, one read per colour
scheme rather than a guess: light ink `#222`/page `#fff`/accent `#0088cc`, dark ink `#ddd`/page
`#222`/accent `#0f82af`, plus its success, danger and highlight (search-hit) colours. None of the
six clears 4.5:1 as text on the surface Discourse pairs it with — its own contrast rules are for a
filled pill or an icon, not for the export tier every app prints text in — so each is walked the
way 2020's cyan was, hue and saturation held while lightness moves until AA clears (worst case
1.07:1 on white, for the yellow search-highlight that has no footstrap analogue and fills `--fs-warn`
instead, being the only status colour Discourse's scheme leaves spare). The forum's header bar
(`rgb(0, 43, 73)`) is not carried into either mode: its own dark scheme already has a canvas
colour, `#222`, unlike 2020's theme which had none to sample.
**2020** is the CI cyan `#00B5E2` on the navy `#002B49`, which is one scheme and no dark mode in
the theme it comes from. Here it is a pair, and the split is the interesting part: 2020's own
scheme IS the dark mode — navy canvas, cyan nearly untouched — while the light mode keeps the hue
and darkens it, because `#00B5E2` measures **2.09:1** on white and the export tier every app prints
text in wants 4.5. Its three semantic colours move for the same reason: on white 2020's green is
2.28:1 and its amber 3.42:1, while its red at 5.25:1 is the one kept where it was.
**hicontrast** is the same tokens, deeper and more saturated. Its light accents are deliberately
darkened: they used to be *brighter* than the defaults, so a palette named "hicontrast" contrasted
**worse** than the default (`--fs-good` as a label on `--fs-panel` — **2.55:1** against **5.08:1**).
### Ink is per palette AND per mode
`--fs-on-accent` / `--fs-on-good` / `--fs-on-warn` / `--fs-on-danger` live in `03-palettes.css`
next to the fills they have to be readable on. A dark palette has light fills and therefore
needs dark ink: one global `--fs-on-accent: #fff` failed WCAG AA on seven of eight dark fills,
down to **1.69:1** against a required 4.5. A new palette must define all four and check them
against its own fills.
### The fourth ink is the one that says "nothing here"
`--fs-text`, `--fs-dim` and `--fs-faint` are three weights of a value. `--fs-placeholder` is not a
weight of anything — it is what a field shows when it holds NOTHING, and LuCI puts the option's
default there (`form.Value.placeholder`). Drawn in `--fs-dim` it measured 11.12:1 on footstrap
light's field fill against the value's own 14.84:1, near enough that two forum readers reported
fields as holding values they had never typed.
It is mixed from `--fs-text` toward `--fs-panel2` in oklch, because the step is a lightness one, and
the PERCENTAGE is per mode rather than per palette — 55% light, 64% dark. Dark is the constrained
half: its ink starts at 6.45:1 on the field against light's 14.84:1, so the same percentage there
would buy a hint nobody can read.
**This is the one token the theme ships deliberately under AA** — 3.99-4.48:1 in light, 3.43-6.05:1
in dark, at 43-44% and 33-35% of the ink-to-field range — because 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
were measured first and moved 37% and 20%: the same fault, quieter. 3:1 (SC 1.4.11) is the line not
crossed, and `prefers-contrast: more` buys the AA ink back in both modes
(`theme/95-a11y-media.css`): that reader has said which of the two they want.
`placeholder-ink` holds both ends across the eight palette/mode combinations and runs each of them
again under the query. It is the only gate that can: axe-core does not measure `::placeholder` at
all, and it is excluded from the `li[placeholder]` row that carries the same ink, since a rule
reaching half a decision would fail it.
## The derived ladder: four steps, and the matrix is deliberately full
A tint of a role is mixed from that role, so it follows the palette and cannot go stale. What
it lacked was a name, and an unnamed step drifts silently: the same border was 40% in a table
and 45% in an action panel, the same diff block 30% in `base` and 18% in `theme`, the same hover
fill 12% here and 18% there — four forces where the design knows two.
| Step | Strength | What it is |
|---|--:|---|
| `-soft` | 12% | a quiet fill — hover on an outline button |
| `-fill` | 18% | a stronger fill — callout/diff, the invalid ring |
| `-line` | 40% | a hairline border |
| `-line-hi` | 55% | the same hairline on hover |
The role × step matrix is **filled completely on purpose**, whether or not anything reads a cell
today: a hole is exactly where the drift started (`--fs-accent-soft` existed, `good`/`warn`/
`danger` had no sibling, and every rule invented its own percentage). `--fs-accent-soft` is the one
member still in `03-palettes.css`, because its strength is the only one that depends on mode
(10% light / 15% dark).
Other derived values: `--fs-glass` (a frosted popup surface, panel at 96%) and `--fs-blur` (one
blur radius for every frosted surface); `--fs-emboss` / `--fs-text-emboss` (a raised 1px edge);
`--fs-hover-lift` (the one hover hint that cannot be a colour, since it brightens whatever fill the
role set — and its **direction depends on mode**: light darkens, dark lightens);
`--fs-focus-ring` and `--fs-focus-ring-invalid` (which takes `-fill`, not `-soft`: a red ring has
to read as alarm).
**The bar's blur stays, and that is a decision rather than an oversight.** `--fs-bar-bg` at 88% was
chosen for the blur: content has to show through the strip, and without the blur it shows
through sharply and reads as dirt under the text. One backdrop layer on one strip is what any
native mobile navbar does — unlike `theme/15-wallpaper.css`, which removed the blur from every
form button, where it was dozens of layers rather than one. It could not be measured either way:
a main-thread rAF loop cannot see compositor work, and a headless browser does not rasterise at
all, so the remaining step is a run on a real phone. Meanwhile there is a proper opt-out —
`@media (prefers-reduced-transparency: reduce)` in `theme/95-a11y-media.css` switches
`--fs-bar-bg`/`--fs-glass` to the opaque `--fs-panel` and kills `--fs-blur`. That is both an
accommodation and a measured line of retreat, and it leaves the choice with the device's owner.
**`--fs-bar-bg` (88%) is deliberately NOT merged with `--fs-glass` (96%)**: the bar is a thin strip
the page scrolls under, and content showing through is the point; a dropdown carries a menu and has
to stay readable. **`--fs-scrim` is the theme's one colour literal and stays one**: black at .7 is
the absence of light behind a dialog, not a shade of any token.
## Scales
**Radius** — one user-facing base (Footstrap page → Rounding, **0–20 px**), from which three semantic
radii are derived proportionally: cards/panels/modals/popovers, controls (inputs, buttons,
dropdowns, tabs, menu items, logo) and small parts (chips, code, insets). Pills and toggles are
always fully round. Every `border-radius` in `theme`/`pages` reads one of them.
**Z-index** — `--fs-z-*`, and **every z-index in the theme comes from here**. Bottom to top:
`raise` 2 → `sticky` 50 → `flyout` 70 → `header` 800 → `popover` 850 → `overlay` 900 →
`tooltip` 1000 → `dropdown` 1100. Before the scale there were nine bare numbers across seven files
and nothing fixing the order — the appearance popover of the day drew **on top of** an open modal.
As a list, that bug is obvious.
**Motion** — four durations, because the UI does four things: `--fs-dur` .15s (state change:
colour, border, shadow, background, filter), `--fs-dur-move` .2s (transform, max-height),
`--fs-dur-fade` .25s (something transient going away: spinner, notification), `--fs-dur-fill` .4s
(a progress bar growing to its value). There were seven durations (.12/.125/.15/.2/.22/.25/.4) and
four curves, none of them chosen.
**There is deliberately no easing token**: every transition omits the timing function and takes the
CSS default (`ease`) — one curve, nothing to keep in sync, and fewer bytes than naming it. A rule
that needs a different curve should justify it in a comment, because it is making a design decision.
**Spacing** — a half-step scale in `02-tokens.css`: `--fs-space-0-5`, `-1`, `-1-5`, `-2`, `-2-5`,
`-3`, `-3-5`, `-4`, `-5`, `-6`, `-7`, `-8`, `-10` (the number is the step, `-1` = 4 px at normal
density).
**Density** multiplies the type scale, the spacing scale and the shell geometry, through three
tokens the Density axis sets: `--fs-density-type`, `--fs-density-space` and `--fs-density-box`.
Compact is `.9` / `.65` / `.85`, Normal is `1` / `1` / `1` (a bare `:root`, so the default costs no
attribute), Large is `1.15` / `1` / `1.15` — the air gives up the most at Compact and does not grow
at Large, so the two ends are deliberately not mirror images. Every type, spacing and geometry token
is a `calc()` over one of them — which is why the numbers below are quoted **at normal density**.
**Shell geometry** — `--fs-sidebar-w` (224 px), `--fs-rail-w` (68 px), `--fs-content-min` (500 px),
plus `--fs-content-max` (1280 px), `--fs-content-pad` (28 px) and `--fs-bar-h` (46 px) — **tokens
rather than literals because the JS reads them**: `fitShell()` uses `getComputedStyle` to subtract
the sidebar's slice from the viewport and decide whether the remaining column is readable. The JS
used to keep its own copies against bare literals in the CSS, so narrowing the rail in styles left
the measurement quietly subtracting the old width. Reading them through `getComputedStyle` is also
what makes the measurement follow Density for free.
Shadows are `--fs-shadow` (per mode) and `--fs-shadow-pop` (floating surfaces).
## The appearance axes
The controls are `fs-appearance.js`, which builds the form; `view/footstrap/appearance.js`
dispatches it at a route of its own, **System → Footstrap** (`admin/system/footstrap`, registered
in `root/usr/share/luci/menu.d/luci-theme-footstrap.json`, ACL-gated the same as any `luci-app-*`
page). It used to be a fifth tab stapled onto the stock **System → System** page instead, beside
General Settings / Logging / Time Synchronization / Language and Style — watching `body[data-page]`
the way `fs-overview.js` does, then appending one `.cbi-tabcontainer` and one `<li>` to the group
`ui.tabs` had already initialised by hand, because `initTabGroup()` returns immediately on a group
carrying `data-initialized` and clearing that flag builds a *second* menu beside the first. That
workaround stood in for a route: a `luci-theme-*` package was believed unable to register a
dispatcher node of its own. It can — the entry belongs to the package, not to being the *active*
theme, so it is present for exactly as long as the package is installed, regardless of which theme
is selected — and `docs/architecture.md` has the boundary that replaced the old belief.
The values live in `fs-prefs.js`. Grouped the way the page groups them — **Interface** (Layout,
Theme, Palette, Density, Rounding, Submenus), then the **Colours** fold (Tint and its strength, the
four colour axes, the four surface axes), then the **Background** fold (Wallpaper, Dim, Pattern and
its three axes, File), then **Defaults**:
| Axis | Values | `localStorage` | `:root` |
|---|---|---|---|
| **Layout** | **top** (default) / sidebar | `fs-layout` | `data-layout` (always explicit) |
| **Theme** | auto / light / dark | `fs-darkmode` | `data-darkmode` + `data-theme` + `data-bs-theme` |
| **Palette** | footstrap / hicontrast / bootstrap / 2020 / forum | `fs-palette` | `data-palette` |
| **Density** | compact / normal / large | `fs-density` | `data-density` |
| **Wallpaper** | off / pattern / **file** | `fs-wallpaper` | `data-wallpaper` — `pattern` tiles an admin-uploaded SVG through a CSS mask; Scale, Strength and Colours are axes of their own |
| **Tint** | off, hue 1–360°, `#rrggbb` | `fs-tint` | `data-tint=hue\|hex`, `--fs-tint-h` / `--fs-bg` |
| **Tint strength** | 0–200%, default 100 | `fs-tint-strength` | `--fs-tint-strength` |
| **Accent** | off, hue 1–360°, `#rrggbb` | `fs-accent` | `data-accent=hue\|hex`, `--fs-accent-h` / `--fs-accent` |
| **Good / Warning / Danger** | same shape as Accent | `fs-good`, `fs-warn`, `fs-danger` | `data-good\|warn\|danger`, `--fs-*-h` / `--fs-*` |
| **Cards / Controls / Sidebar / Borders** | off, `#rrggbb` | `fs-card`, `fs-control`, `fs-bar`, `fs-line` | — (inline `--fs-panel`, `--fs-panel2`, `--fs-bar-bg`, `--fs-border`) |
| **Photo dim** | 0–100%, default 74 | `fs-photo-dim` | `--fs-photo-dim` |
| **Pattern scale** | 40–1600 px, default 440 | `fs-pattern-size` | `--fs-pattern-size` |
| **Pattern strength** | 0–100%, default 20 | `fs-pattern-strength` | `--fs-pattern-strength` |
| **Pattern colours** | theme / original | `fs-pattern-ink` | `data-pattern-ink=original` |
| **Rounding** | 0–20 px, default 12 | `fs-radius` | `--fs-radius-base` |
| **Submenus** | keep open / auto-collapse | `fs-menu-autocollapse` | — (no attribute) |
**Photo dim** is the scrim over a `file` wallpaper, and the three **Pattern** axes are the same
arrangement for the tiled SVG: both images' *bytes* are router-side — a file cannot live in
`localStorage` — but how a given browser draws them is an ordinary axis.
**Pattern colours** is the one axis whose off state is the interesting one. On `theme` (the bare
`:root`) the SVG is painted through a CSS `mask`, so the file supplies alpha and the theme supplies
`--fs-text`: the same upload reads correctly in both modes and under every palette. On `original`
the mask is dropped for a plain tiled `background-image`, because a mask flattens artwork that
carries its own palette to a single colour. Neither the file nor the two numbers are the axis that
decides whether anything paints — that is `fs-wallpaper`.
Three more `fs-` keys are not axes: `fs-rail` (the sidebar collapsed to an icon rail,
toggled in the chrome), `fs-menu-open` (the remembered set of open accordion sections) and
`fs-recent` (the command palette's history). None of them has a router default, which is why
`fs-rail` may delete its key on the off state where an axis may not.
### Three layers, and the browser always wins
The effective value of every axis is **`localStorage` ?? router default ?? built-in**.
- The router default is what **Save as default** writes: `saveAsDefault()` uci-sets
the axes into `/etc/config/footstrap`, and the server reads them back into `window.__fsSD`, which
`head.ut` stamps. So a new browser, an incognito window or a cleared cache inherits the router's
look — including the pre-login page, which is the point of putting the wallpaper there.
- The built-in is a bare `:root`.
- **This browser's own choice overrides the router default in either direction.**
**Every applier therefore stores its choice EXPLICITLY, including the off/default value, and that
is load-bearing.** Once a router default exists, clearing the key no longer means "the built-in" —
it means "inherit whatever the router set". An applier that deleted the key on the default value
could not express "I want the built-in, *not* the router default", so a router-defaulted tint could
not be turned back off. `lsDel` is reserved for **Reset to default**, which drops back to the router
default on purpose.
### Nothing else writes to the router
**`/etc/config/footstrap` is written by Save as default and by nothing else.** The only other uci
writes on the whole page are the login-background *token* on upload and its blanking on remove —
the identity of a file that has to live on the router anyway.
Two axes used to write through the moment they changed: `wallpaper` on every pick and `photo_dim`
on every drag, on the argument that the File photo is router-side, so "which wallpaper shows it"
and "how dim" belonged beside the image. The argument did not survive its consequence: choosing
Cats in one browser silently re-pointed the router-wide default for every other device — and
because the write also moved the Save baseline, the button did not even light up. A per-browser
preference must never mutate shared state with no way to see that it did. Both are ordinary axes
now.
`/etc/config/footstrap` ships as an empty stub and is written at runtime, which is why it **must be
declared a conffile** — see [package.md](package.md).
**Every axis is implemented twice** — inline in `partials/head.ut` before first paint, where
`require` is impossible because the module loader does not exist yet, and live in `fs-prefs.js`.
The two cannot be merged byte for byte, so `tools/axes.mjs` derives the contract **from the JS** and
checks the template against it: keys, `:root` attributes, custom properties, the 1–360 ranges, the
rounding default — and the load-bearing ordering rule, **custom property first, attribute second**.
Reversed, a reload paints exactly one frame in the previous hue. The gate exists for that one line:
it would be fixed in the live applier and forgotten in the template, and the only symptom is a single
wrong frame nobody reports.
### The colour axes, and why the slider went
Nine axes take a colour: Tint (the canvas), Accent, the three status colours Good /
Warning / Danger, and the four surfaces Cards / Controls / Sidebar / Borders. Each holds one
of three things — off, a hue 1–360°, or a `#rrggbb` — and `data-<axis>` carries `hue` or `hex` to
say which. The surfaces are the exception and hold only a colour: they set an inline custom property
and no attribute at all (`surfaceAxis` in `fs-prefs.js`), because there is nothing for a rule to
match.
**The UI offers only the hex field.** The hue slider was here and is gone: rotating a hue keeps the
palette's chroma, so no angle of it reaches a grey — which is the one thing #20 asked for. The hue
mode stays in storage and in the stylesheet so a value saved before the change goes on painting.
**The ink over a hex fill is derived, in CSS.** `--fs-on-accent` and the three status inks become
`oklch(from <fill> clamp(0, (l - .62) * -100, 1) 0 0)` — black above the sRGB crossover, white
below, chroma zeroed. The rule is written `[data-accent="hex"][data-accent]`: the palette's dark
block is also (0,2,0) and later in the file, so the single-attribute form lost **in dark mode only**
and left a grey accent carrying near-black ink at 1.9:1. Surfaces get no derived ink — what reads on
them is `--fs-text`, a palette token these axes must not move — so the page reports the contrast
each choice lands at instead.
### Tint and Accent
In HUE mode both are an angle 1–360°, both rotate `oklch(from …)`, and both default to "off" (no
attribute).
- **Tint** (`data-tint`, `--fs-tint-h`) washes a hue into the canvas (`--fs-bg`, the surface the
cards float on), so a whole LuCI reads as green / purple / amber at a glance. `localStorage` is
bound to the origin, so "which router is this" comes for free with no server state: the same
browser shows the main router green and the access point purple. **Nothing else moves** — cards,
chrome and semantic colours keep their palette values, because a status colour recoloured for
identification would start lying about status.
It rotates **chroma and hue rather than mixing a colour in**: `color-mix` in a polar space is a
trap (measured on a dark canvas, "2%" and "6%" at hue 165 gave the identical green — the knob
controlled nothing).
**Tint strength** (`--fs-tint-strength`, 0–200%, default 100) is the paired axis: the hue picks
the colour, this picks how strongly it reads. It is a multiplier on the tint chroma, it only
bites while a hue is set, and it is hidden and moot under a `file` wallpaper, where the tint
resets to neutral because the photo covers the canvas. Do not confuse it with Photo dim,
which darkens that photo rather than colouring the canvas.
- **Accent** (`data-accent`, `--fs-accent-h`) is the same idea applied to the interface colour:
the rotation moves every accented control, because they all read `--fs-accent` or a `color-mix()`
from it. `oklch(from … l c H)` preserves the palette's lightness and chroma and changes only the
hue, so the contrast of `--fs-on-accent` — which follows lightness — holds at any angle. The ink
is not recomputed.
To avoid a cycle (`--fs-bg` cannot be defined through itself — that is invalid at computed-value
time and drops the colour silently), the palette declares the raw `--fs-bg-base` /
`--fs-accent-base` / `--fs-accent-lt-base`, and exactly one block owns the derivation.
## Typography
**THE THEME CARRIES NO FONTS.** `--fs-font-sans` and `--fs-font-mono` (`02-tokens.css`) name
Manrope and JetBrains Mono **first** and the system stack after:
```
--fs-font-sans: "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--fs-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
```
A bare family name in `font-family` is matched against the fonts **installed on the visitor's
machine** before the browser moves down the list, so an admin who has either face installed sees
the theme drawn in it and one who does not falls through silently — no request, no 404, no flash.
Measured in the browser on a machine with neither installed: `"Manrope", system-ui` renders at
exactly the `system-ui` width, while `Impact, system-ui` and `"Courier New", system-ui` render at
their own — the mechanism works, the theme simply has nothing to add to it.
- **Sans: Manrope where present.** Designed against weights 600 and 700; with a locally installed
family that has 400, `normal` resolves to 400 rather than the semibold the design assumed.
- **Mono: JetBrains Mono where present.** Numeric values, hostnames, versions, port names.
**There is no bold mono and it must not come back.** `<strong>` is a LABEL — LuCI writes every
status as `<strong>MAC:</strong> ac:1f:6b:…` — so on a monospace surface it takes the interface
face instead (`theme/45-misc.css`; likewise `.ifacebadge` as a badge, and `code`/`pre`, whose
literal must not inherit the container's emphasis). Before that rule, **227 elements across seven
pages** rendered in bold mono and the browser fetched `jetbrains-mono-600` (**20 KB**) for the word
"MAC:" — **30% of all font traffic**. Anything now asking for mono at weight ≥600 gets a synthetic
bold that smears the monospace grid; if a rule "needs bold mono", the question is whether it is a
label (then it is not mono at all), not whether to bring 20 KB back.
**Excluding an element from the mono rule is not enough** — it still inherits mono from its
parent. Sans has to be assigned. (The first attempt added `strong` to a `:not()` and changed
nothing at all.)
Sizes (px): card title 14/700; KPI number 27/700 mono; large number 38–40/700 mono; body 13–14;
uppercase label 11/700 with `letter-spacing:.05em`; micro-caption 11–12 dim. Weight 800 from the
mock-up is not loaded — 18 KB for six elements; everything that asked for 800 draws at 700.
**A table's column heading is not one of those labels.** It carries the size, the weight, the
tracking and the faint ink, and NOT `text-transform: uppercase`: the string is the app's, and
uppercasing it printed LuCI's own `IPv4 address` as `IPV4 ADDRESS`, which reads as a typo rather
than as a style (issue #39). It is also the string LuCI copies into `data-title` for the card view,
from `innerText` — so a transform here rewrites the label the cards print and any selector keyed on
it. Four rules hold that decision together: `.cbi-section .table .th` and the title row in
`theme/30-tables.css`, the card label mirrored into `theme/65-dropdown.css`, and the alert's table
in `theme/35-alerts.css`. The chrome's own eyebrows — menu section labels, KPI captions, the search
palette's group headings — stay uppercase; they are the theme's words, not an app's.
The fonts used to be self-hosted: nine `.woff2` subsets (3 faces × latin / latin-ext / cyrillic)
under `htdocs/luci-static/footstrap/fonts/`, `@font-face` in `styles/01-fonts.css`, and the two
latin Manrope subsets preloaded in `partials/head.ut`. They are gone, and the measurement is why:
**the built package went from 128 290 to 66 690 bytes, −48%** — a far larger share than the raw
88 kB of `.woff2` suggests, because woff2 is already compressed and gains nothing from the
package's own compression while everything else does.
Removing them takes THREE deletions, not one, and the third is the one that hides: the
`@font-face` block, the files, **and the `<link rel=preload>` pair**. A preload is not in the
stylesheet, so dropping the rules alone left it behind and every page still asked the router for
the files — six 404s per page, measured, before that was noticed.
The OFL-1.1 half of `PKG_LICENSE` went with them. OFL §2 requires the notice and licence to travel
with every copy of the Font Software; with no Font Software in the package, declaring OFL would be
a false statement about its contents.
### Putting a font back, per router
The package still ships none. What it ships is the seam: **three uci options in
`footstrap.settings`**, filled in by `fonts/set-font.sh` (a repository tool, run once on the router)
or by hand. `fonts/README.md` is the admin-facing page; this is the contract.
| option | what | sanitised in `partials/head.ut` by |
|---|---|---|
| `font_sans` | a `font-family` stack, printed into an unlayered `:root` block | `_sd_family()` — `A-Za-z0-9 ,._"'-`, ≤120 chars |
| `font_mono` | the same for the monospace face | `_sd_family()` |
| `fonts` | md5 of the generated `@font-face` sheet: the cache key **and** the switch that emits its `<link>` | the hex whitelist `login_bg` and `pattern` use |
The two halves are independent by design. A stack alone costs nothing and renders for whoever has
that face installed — the same mechanism the defaults above rely on. A stack **plus** a file in
`/etc/footstrap/fonts/` makes the router serve it, so every visitor gets it.
Three things about the shape are load-bearing:
- **The generated sheet carries `@font-face` and nothing else.** The families come from the template,
so `uci set footstrap.settings.font_sans=…` by hand takes effect with nothing regenerated.
- **The `:root` block is unlayered**, so it beats `layer(tokens)` whatever order the sheets arrive in
— the same reason a `theme/` rule never needs `!important` to outrank `base/`. Nothing under
`styles/` changed to make this work.
- **The `<link>` is emitted only when the token is non-empty**, and the token is written only after
the file exists. That is the preload lesson above, applied before it could repeat.
None of the three is an **axis**: no localStorage, no Appearance control, no entry in
`snapshotAxes()` (`tools/axes.mjs` knows them as server-only, beside the two upload tokens). A font
is a property of the router, not a per-visitor preference — the one Appearance-adjacent setting with
no browser layer at all.
The weights are where an installed face goes wrong quietly. Body text is 600, so **what a face
claims decides what is visible**: a single static file declared `400 700` covers both, the browser
stops synthesising, and every heading renders in the regular face. The script therefore declares one
static face as `400` alone, a pair as `400 600` + `700`, and leaves `100 900` to be spelled out for
a variable font.
## Components: mock-up primitive → LuCI class
| Component | Spec | LuCI class to style |
|---|---|---|
| **Panel / card** | `--fs-panel` background, 1px `--fs-border`, `--fs-radius-lg` | `.cbi-section`, `.cbi-map > *`, `.table` containers |
| **KPI card** | `--fs-radius-lg`; column of uppercase label + mono-27 number + dim caption | no direct analogue — status overview blocks |
| **Progress bar** | track `--fs-track` at `--fs-radius-pill`; fill `--fs-accent`; value mono/dim over the right edge | `.cbi-progressbar` |
| **Percent badge** | `font: 11/700; padding: 2px 7px`; colour + `-soft` fill by status | inline status in `.cbi-progressbar`, zonebadge |
| **Status pill** | `--fs-radius-pill`; `--fs-panel2` fill, `--fs-border`; active text `--fs-good` | `#indicators [data-indicator]` |
| **Menu item** | `--fs-radius`; active `--fs-accent-soft` on `--fs-accent`; inactive `--fs-dim` | `#topmenu li a`, sidebar nav |
| **Logo** | 30px square, `--fs-radius`, gradient `--fs-accent` → `--fs-accent-lt`, wifi SVG on `currentColor`, wordmark 16/700 | `.fs-brand`/`.fs-logo` in `partials/brand.ut` |
| **Table row** | flex, space-between, 1px `--fs-border` bottom; label dim, value mono | `.cbi-value`, `.table .tr` |
Rings, sparklines and port tiles from the mock-up are content, drawn by view JS — not something
a theme can produce. See the boundary in [architecture.md](architecture.md).
### Meter polarity
`annotateMeter()` (`menu-footstrap-common.js`) colours a `.cbi-progressbar` from its fill
percentage — 80%/92% warn/danger, `docs/conventions.md` — but a fill is not always "how much is
used". Forum #134 (2026-09-06) caught Status -> Overview's memory row 0, "Total Available" /
«Свободно», turning yellow at 91% full: a HIGH reading there is healthy, not a warning. Row 4,
"Swap free" / «Свободно в подкачке», is worse — a fully free swap reads 100% and showed permanent
danger on an otherwise healthy router. Both measured live on owrt2512 and owrt2410, en and ru.
Three decisions, each keyed on the row's own label — `_(msgid)`, no msgctxt, the same match
`ROLES` in `fs-overview.js` already makes, verified against `modules/luci-base/po/ru/base.po`:
every msgid used here has an entry there, so it is in the `base` domain loaded on EVERY admin
page, not one only Status -> Overview fetches (unlike `_('Free')`, absent from that catalogue,
which comes back untranslated wherever it is tried — the trap this rule exists to avoid falling
into a second time):
1. **Inverted** ("Total Available", "Swap free"): the warn/danger split fires on a LOW reading —
`100 - FS_METER_WARN` / `100 - FS_METER_DANGER`, not a second pair of numbers, because an
inverted bar asks the identical health question ("how much headroom is left") from the other
end of the same fill.
2. **Neutral** ("Buffered", "Cached"): no colour, ever. A full page cache is the kernel doing its
job, not a resource running out — colouring it "danger" would say something false.
3. **Unrecognised** (everything else, including any bar a third-party app draws): keeps the plain
fill-based rule. Seventeen bars measured live on Overview alone (storage, active connections)
are used-based and carry no name this theme special-cases, and an app's own meter is unnamed by
construction — so fill-based stays the FALLBACK. A wrong colour on the rare inverted app meter
this theme has never seen is the accepted cost of not going dark on the common, correct case.
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!--
Design reference (source of truth) for luci-theme-footstrap.
Imported from Claude Design project a8c4bddc-b860-4d57-8229-50f157ea06bb
file "OpenWrt Status Redesign.dc.html".
Contains two layout directions: 1A Sidebar Console, 1B Bento Grid (top nav).
This is the ORIGINAL canvas mock (React/DCLogic theme-toggle). Kept verbatim
as the visual target. Implementation lives in ../../luci-theme-footstrap/.
-->
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
a{color:var(--accent);text-decoration:none}
a:hover{opacity:.82}
::selection{background:var(--accent-soft)}
@keyframes livepulse{0%{box-shadow:0 0 0 0 var(--accent-soft)}70%{box-shadow:0 0 0 7px transparent}100%{box-shadow:0 0 0 0 transparent}}
</style>
</head>
<body>
<div id="wrap" style="--bg:#0a0d13;--panel:#111620;--panel2:#161c28;--border:#232b3a;--text:#e8edf5;--dim:#8b95a7;--accent:#3ba7ff;--accent-soft:rgba(59,167,255,.14);--track:#1b2230;--good:#43e0a0;--warn:#ffb454;--danger:#ff6b6b;--knob-x:3px;--knob-bg:#3ba7ff;--shadow:0 1px 2px rgba(0,0,0,.4);background:var(--bg);color:var(--text);min-height:100%;padding:44px;font-family:'Manrope',system-ui,sans-serif">
<p style="color:#8b95a7;font-family:monospace">Полный исходник макета (обе панели 1A/1B + JS-переключатель темы) сохранён в истории Claude Design проекта a8c4bddc-b860-4d57-8229-50f157ea06bb. Здесь — только токены и заметки; см. docs/design-system.md для извлечённой спецификации.</p>
</div>
</body>
</html>
@@ -0,0 +1,547 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.0"
width="870.000000pt"
height="870.000000pt"
viewBox="0 0 870.000000 870.000000"
preserveAspectRatio="xMidYMid meet"
id="svg90"
sodipodi:docname="cats_final3.svg"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs90"><inkscape:path-effect
effect="bspline"
id="path-effect103"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect102"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect101"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect99"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect98"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect97"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect97-5"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect98-3"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect99-5"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect101-6"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect102-2"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="bspline"
id="path-effect103-9"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /></defs><sodipodi:namedview
id="namedview90"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="pt"
inkscape:zoom="11.313708"
inkscape:cx="57.010484"
inkscape:cy="819.84612"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer3"><inkscape:page
x="0"
y="0"
width="870"
height="870"
id="page123"
margin="0"
bleed="0" /></sodipodi:namedview><metadata
id="metadata1">
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata><g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Layer 1"
style="display:inline"><path
d="m 188.7,6.5 -5.8,6.2 -13,0.7 -13,0.7 -4.8,-5.1 c -3.7,-3.9 -5.4,-5.1 -7.2,-4.8 -2.9,0.4 -5.5,5.4 -5.7,11.2 -0.3,6.7 -4.1,16.7 -7.8,20.6 -3.9,4.2 -4.1,5.3 -1.4,8.7 1.1,1.4 2,3.6 2,4.9 0,5.9 2.9,9.6 12.6,16.2 8.6,5.7 8.9,6.8 8.9,29.2 -0.1,17.6 -0.2,18.9 -2.9,26.9 -2.3,6.8 -2.7,9.1 -2,12 0.7,2.9 1.4,3.7 3.7,3.9 1.8,0.2 3.7,-0.4 4.9,-1.5 2.5,-2.3 5.8,-8.6 5.8,-11.2 0,-1.1 0.4,-2.2 0.9,-2.5 0.4,-0.3 1.1,-2.4 1.4,-4.7 1,-7.6 9,-12.9 11.8,-7.7 0.9,1.6 0.8,5.1 -0.2,14.6 l -1.3,12.4 2.5,1.9 c 1.3,1 2.9,1.9 3.5,1.9 2.3,0 5.2,-4.8 6.8,-11.5 5.2,-20.9 5.6,-21.6 13.6,-21 3.2,0.3 8.1,1 10.7,1.6 3,0.7 13.4,1.2 27.3,1.1 14.3,0 22.9,0.3 23.7,1 1.8,1.4 1.6,9.8 -0.3,17.8 -1.7,7.3 -1.3,10.5 1.6,12 2.1,1.2 4.8,0.2 8.1,-2.9 2.1,-1.9 4.9,-11.8 4.9,-17.2 0,-3.5 3.3,-9.9 5.6,-10.6 1.1,-0.4 2.7,4.2 6.5,18.7 1.2,4.7 2.9,9.3 3.7,10.2 1.6,1.9 7.2,2.5 7.2,0.9 0,-0.5 0.6,-1.6 1.4,-2.4 1.2,-1.2 1.5,-7.4 1.8,-35.3 0.1,-18.7 -0.1,-37.8 -0.6,-42.5 -0.8,-8.2 -0.7,-9 1.8,-14.4 1.4,-3.2 2.6,-6.8 2.6,-8.1 0,-4.1 -4.8,-11.9 -10,-16.3 -5.7,-4.8 -10.2,-6.6 -19.8,-7.9 -3.7,-0.6 -8.3,-1.9 -10.1,-3 -4.1,-2.5 -7.3,-1.4 -9.2,3 -1,2.6 -0.9,3.3 1,5.6 1.2,1.5 2.4,3.4 2.7,4.2 0.8,2.3 8.4,6 12.4,6 4.4,0 11,3.7 13,7.3 1.3,2.4 1.3,3 0,5.4 -1.5,2.7 -1.9,2.8 -8.5,2.5 -3.9,-0.1 -18.2,-0.4 -32,-0.6 -13.7,-0.1 -26,-0.6 -27.3,-1 -3.1,-1 -4.4,-4.8 -3.6,-10.7 0.6,-4.4 0.4,-5.1 -2.3,-7.8 -1.7,-1.6 -3.7,-4.5 -4.5,-6.3 -0.8,-1.8 -3,-6.8 -4.9,-11 -3.3,-7.6 -3.5,-7.7 -6.9,-7.6 -3,0 -4.4,1 -9.3,6.3 z m 12.5,3.5 c 1,3 3.2,8.3 4.8,11.8 1.6,3.4 3.5,8.4 4.1,11 1.8,7 5.5,14.9 7.6,16 1,0.6 16.2,1.1 35.6,1.3 l 33.7,0.4 2.5,-3 c 1.8,-2.3 2.4,-4.2 2.5,-7.9 0,-4.3 -0.4,-5.2 -3.6,-8.2 -1.9,-1.9 -4.3,-3.4 -5.3,-3.4 -6.6,0 -19.1,-6.9 -19.1,-10.5 0,-2.4 1.6,-2.5 7.5,-0.5 3.2,1.1 6.9,2 8.1,2 1.3,0 4.6,0.8 7.3,1.9 14.1,5.3 19.4,16.4 12.9,26.9 -2,3.2 -2,4.2 -1.2,17 1.3,20.1 0.5,68.3 -1.1,70.1 -1.1,1.1 -1.6,0.4 -2.8,-4 -0.9,-3 -2.1,-7.7 -2.7,-10.4 -4,-18.2 -14.2,-19.3 -16.4,-1.7 -0.4,2.9 -1.3,6.7 -2.1,8.5 -0.7,1.8 -1.6,4.5 -2,6 -0.3,1.6 -1.1,2.7 -1.8,2.4 -0.9,-0.3 -1.1,-3.8 -0.8,-13.4 0.5,-17.3 2,-16.3 -24.4,-16.4 -18.2,0 -25,-0.4 -41.6,-2.5 -9.5,-1.1 -13.8,2.9 -15.5,14.3 -0.7,5.3 -4.2,14.3 -5.4,14.3 -1.4,0 -1.7,-2.3 -0.9,-6 1.4,-5.8 1.1,-19.4 -0.4,-21.5 -1.2,-1.5 -2.2,-1.7 -5.7,-1.1 -8.3,1.3 -13.5,7.7 -14.7,17.9 -0.7,5.6 -2.7,9.7 -4.9,9.7 -2.5,0 -2.5,-2.1 0.6,-13.3 2.7,-10.4 3,-12.7 3,-27 0,-9.3 -0.4,-15.8 -1,-16.2 -0.5,-0.3 -1,-1.4 -1,-2.4 0,-2 -7,-9 -11.8,-11.7 -4.1,-2.4 -8.1,-8.1 -9.3,-13.5 -0.9,-3.7 -0.6,-5.1 2.6,-12.4 1.9,-4.5 3.5,-9.5 3.5,-11.1 0,-4.2 1.8,-10 3.5,-11.4 1.2,-1 2.3,-0.5 5.9,2.8 5.7,5.3 8.3,5.9 17,3.8 3.9,-0.9 8.6,-1.6 10.4,-1.6 4.6,0 10.1,-3.3 14.2,-8.5 1.9,-2.4 4.1,-4.3 4.9,-4.2 0.8,0.1 2.2,2.7 3.3,5.7 z"
id="path1"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 168,33 c -2.3,4.4 2.9,8 6.4,4.4 3,-2.9 1.6,-5.5 -3.1,-6.2 -1.4,-0.1 -2.6,0.5 -3.3,1.8 z"
id="path2"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 147.4,33.1 c -1,1.7 1.3,3.6 2.7,2.2 1.2,-1.2 0.4,-3.3 -1.1,-3.3 -0.5,0 -1.2,0.5 -1.6,1.1 z"
id="path3"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 617.8,42.1 c -1,0.5 -1.8,1.6 -1.8,2.3 0,0.7 -0.8,2.4 -1.9,3.7 -3.8,4.9 -5.4,16.7 -5.6,41.4 l -0.3,23 -4.4,-0.1 c -4.9,-0.2 -9.7,-2.3 -13.7,-6 -2.7,-2.5 -6.5,-3.2 -7.6,-1.4 -0.6,1 -1.2,7.2 -1.4,14.1 -0.1,2.2 -0.7,2.8 -3.6,3.3 -1.9,0.4 -5.1,2.2 -7.1,4.1 -4.4,4.2 -5.9,4.3 -10.4,1 -2.5,-1.8 -4.6,-2.5 -8.2,-2.5 -4.4,0 -4.8,0.2 -4.8,2.3 0,1.3 0.6,4.3 1.4,6.8 5.1,16.2 6.7,23.1 6.2,25.6 -0.6,2.4 0.1,3.5 4.9,8.2 3,3 5.5,5.8 5.5,6.2 0,1.4 14.7,9.9 17.2,9.9 3.2,0 5.1,2.3 6.7,7.9 0.7,2.5 2.5,7.2 4,10.5 3.7,8.2 4.6,10.9 6.6,19.1 1.6,6.5 1.6,7.3 0.1,10.9 -0.9,2.1 -1.6,5.6 -1.6,7.7 0,3.3 0.4,4.1 2.5,4.9 7.3,2.8 12.9,-5 14.7,-20.3 1.4,-12.2 1.8,-12.5 13.5,-11.9 l 9.2,0.4 2,4.1 c 1.1,2.3 2.4,6.8 3,10 1.6,8.8 4,12.6 9.9,15.4 3.1,1.5 3.5,1.5 6,-0.4 3.5,-2.6 3.8,-5.1 1.2,-11.2 -1.9,-4.2 -2.1,-6.4 -1.8,-14.3 0.2,-5.9 0.9,-10.6 2,-12.8 2.4,-5 4.8,-18.1 4.8,-26 0,-9.5 -2.6,-22 -5.8,-28.2 -2.4,-4.8 -17.5,-20.8 -19.5,-20.8 -0.4,0 -2.7,-1.8 -5,-4 -4.3,-3.9 -4.7,-4.9 -8.8,-18.6 -1.4,-4.7 -1.1,-21.9 0.6,-31.4 2.2,-12.7 2,-26.7 -0.5,-30.8 -2.1,-3.3 -4.9,-4.1 -8.2,-2.1 z m 4.4,5.6 c 0.1,1 0.2,16.6 0.2,34.8 0.1,32.7 0.1,33 2.5,37.5 2.4,4.7 8.8,12 10.4,12 0.6,0 1.9,1 3.1,2.2 1.2,1.3 4.8,4.5 8,7.3 9.9,8.5 12.6,16.3 12.6,36.1 0,13.1 -1.7,24.4 -4.5,30.9 -0.7,1.6 -1.6,5.6 -2,8.7 -0.6,4.8 -0.4,6.3 1.2,9.1 4.4,7.5 3.3,15.7 -1.4,10.4 -3.2,-3.5 -7.3,-13.6 -7.3,-17.9 0,-4.3 -2.5,-9.5 -5,-10.3 -0.8,-0.2 -6.4,-0.5 -12.4,-0.7 l -10.8,-0.3 -2.9,3.3 c -2.5,2.8 -2.9,4 -2.9,9.2 0,8.2 -1.8,15.6 -4.4,18.7 l -2.3,2.6 -0.7,-2.7 c -0.4,-1.5 -0.2,-4.4 0.4,-6.6 1.4,-5.2 -0.3,-16.3 -4,-25.5 -10,-25.3 -9.2,-22.3 -8.8,-34.7 0.3,-10.9 0.4,-11.6 4.5,-19.8 4,-8.2 8,-13.7 15.3,-20.7 3.3,-3.2 3.5,-3.8 3.4,-9.6 -0.1,-3.4 -0.9,-10.5 -1.8,-15.7 -2.3,-12.7 -1.7,-27.8 1.7,-42.9 1.4,-6.3 3.1,-12.7 3.8,-14.2 1.3,-3.2 3.8,-3.9 4.1,-1.2 z M 592,113.3 c 1.9,1.3 5.5,2.9 8,3.6 10.1,2.9 11.6,8.1 4.2,15.1 -5.5,5.2 -8.9,9.7 -12.2,16 -1.2,2.5 -2.7,5.4 -3.3,6.5 -0.6,1.1 -1.3,7.1 -1.5,13.3 -0.5,13.1 -1.1,13.8 -7.8,10.2 -4.9,-2.6 -14.2,-11 -17,-15.5 -2.5,-4.1 -5.4,-12.3 -5.4,-15.6 0,-1.5 -0.7,-4.3 -1.6,-6.4 -1.6,-4 -1.6,-9.6 0.2,-10.2 0.6,-0.2 2.7,0.8 4.8,2.2 2,1.4 4.5,2.5 5.4,2.5 0.9,0 4.9,-2.5 8.7,-5.6 3.9,-3.1 8.1,-6 9.4,-6.5 2.1,-0.8 2.3,-1.4 1.8,-5 -1,-7.3 0.5,-8.4 6.3,-4.6 z"
id="path12"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 623,151.5 c 0,1.4 0.6,2.5 1.3,2.5 2.1,0 2.7,-1.1 1.6,-3.1 -1.4,-2.7 -2.9,-2.3 -2.9,0.6 z"
id="path13"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 595,126 c 0,2.4 2.6,3.8 4.2,2.2 0.8,-0.8 0.8,-1.5 -0.2,-2.7 -1.8,-2.1 -4,-1.9 -4,0.5 z"
id="path14"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 581.8,138.1 c -3.1,1.7 -1.6,6.9 1.9,6.9 2.2,0 3.8,-4.6 2.3,-6.5 -1.5,-1.7 -1.9,-1.8 -4.2,-0.4 z"
id="path15"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 564.4,148.1 c -1,1.7 2.3,4.6 3.7,3.2 0.7,-0.7 0.9,-1.9 0.6,-2.7 -0.7,-1.8 -3.3,-2.1 -4.3,-0.5 z"
id="path16"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 421.9,45.3 c -1.3,1.2 -4,4 -5.9,6.3 -4.7,5.5 -7.9,6.4 -17.6,5.2 -11.7,-1.4 -15.4,-2.8 -20.3,-7.5 -4.6,-4.4 -9.1,-5.7 -10.8,-3.1 -1.4,2.3 -3.5,8.2 -4,11.6 -0.3,1.8 -1.6,5.2 -2.9,7.5 -1.4,2.3 -3,5.4 -3.6,6.9 -0.7,1.5 -2.5,3.2 -4,3.9 -3.6,1.5 -4,3 -2.8,12.2 1,8.2 3.9,14.5 8.4,18.5 8.1,6.9 9.5,20 7,62.4 -0.5,8.9 -0.4,12.1 0.6,13.3 1.9,2.3 4.6,1.8 6.8,-1.3 2,-2.6 2.3,-2.7 11.4,-2.6 10.6,0.1 13.8,1.3 13.8,4.9 0,3.9 4.1,3.3 7.4,-1 3.3,-4.3 5,-4.4 6.6,-0.4 1.8,4.2 7.9,7.3 11.7,5.8 3.9,-1.5 17.5,-2.1 19.3,-0.9 1.8,1.2 0.2,2.8 -10.9,10.5 -11.9,8.2 -18,13.4 -19.6,16.6 -1.5,2.8 -1.4,3.2 0.6,6 1.6,2.3 2.7,2.9 4.3,2.6 12.2,-3 13.8,-3.7 20.3,-8.7 6.2,-5 9.1,-7.7 18.8,-18.2 1.9,-2.1 4.5,-5.9 5.7,-8.5 2.1,-4.5 2.3,-6.1 2.3,-24.3 0,-11.9 -0.5,-21.3 -1.2,-24 -0.6,-2.5 -2.1,-8.8 -3.3,-14.1 -2,-9.1 -9.2,-28.5 -12.3,-33.2 -0.8,-1.3 -1.8,-4.2 -2.2,-6.6 -0.4,-2.4 -1.3,-5.8 -2,-7.6 -0.8,-1.7 -2.1,-6.2 -2.9,-9.9 -0.9,-3.7 -2.8,-8.9 -4.3,-11.4 -2.5,-4.5 -10,-13.2 -11.4,-13.2 -0.3,0 -1.7,1 -3,2.3 z m -44.1,10.2 5.1,5.6 15.4,0.2 c 17.1,0.2 18,-0.1 21.4,-6.8 2.8,-5.4 5.6,-5.9 9.2,-1.4 2.9,3.8 8.4,18.3 10,26.9 1.3,6.5 1.6,7.5 6.3,18 4,9 8.5,21.5 9.8,27 0.4,1.9 1.5,5.1 2.3,7.1 1.2,2.8 1.6,8.6 1.9,28.3 0.3,21.8 0.1,24.9 -1.3,25.9 -0.9,0.7 -1.9,2.1 -2.3,3.2 -1.2,3.7 -18.1,20.2 -23.6,23 -1.7,0.9 -3,1.9 -3,2.3 0,1.1 -8.7,3.5 -9.9,2.7 -0.6,-0.3 -1.1,-1.4 -1.1,-2.4 0,-1.8 1.4,-3 14,-12.3 9.2,-6.7 20,-16.9 20,-18.7 0,-2.6 -0.9,-3.1 -3.9,-1.9 -3.7,1.5 -28,2.1 -29.7,0.8 -2.1,-1.9 -0.5,-4 3.1,-4 1.9,0 3.7,-0.5 4.1,-1.1 0.4,-0.7 0.6,-5.8 0.5,-11.5 -0.1,-10.3 -0.1,-10.5 3.1,-14.9 3.4,-4.5 7,-6.5 11.8,-6.5 3.3,0 4.3,-0.9 2.9,-2.7 -1.7,-1.9 -7.3,-1.6 -12.3,0.7 -3.6,1.7 -4.7,3.1 -7.2,8.3 -2.5,5.3 -2.9,7.4 -2.9,14.7 0,4.7 -0.4,9.1 -0.8,9.7 -0.5,0.8 -2.9,1.3 -5.7,1.3 -3.8,0 -5.2,-0.4 -5.9,-1.8 -0.6,-1.1 -0.6,-2.3 -0.1,-2.8 1.1,-1.1 4.9,-22.4 4.2,-24.2 -0.2,-0.6 -1.3,-1.2 -2.3,-1.2 -2.1,0 -2.5,1.4 -3.3,11.5 -1.2,14.5 -3.3,22.1 -5.9,21.2 -1.5,-0.5 -1.1,-23.5 0.5,-25.4 0.7,-0.9 0.6,-2 -0.6,-3.8 -1.8,-2.7 -3.5,-3.3 -3.7,-1.3 -0.1,0.7 -0.2,6.6 -0.3,13.1 -0.1,12.8 -0.7,14.3 -5.6,13.2 -1.4,-0.4 -5.3,-0.9 -8.7,-1.2 -7.3,-0.6 -8.1,-2 -5.8,-9.7 2.1,-6.8 3,-17 1.7,-19.2 -0.9,-1.7 -1.1,-1.6 -1.7,1.1 -0.3,1.7 -1.1,4.6 -1.6,6.5 -0.6,1.9 -1.4,7.2 -1.8,11.8 -0.4,4.5 -1,8.5 -1.4,8.8 -2.1,2.2 -2.5,-2.6 -2,-27.9 0.7,-31.2 0.6,-31.8 -8,-41.5 -2.5,-2.8 -5.1,-6.1 -5.7,-7.3 -1.9,-3.5 -1.4,-12.5 0.9,-16.3 1.2,-1.8 2.1,-3.9 2.1,-4.6 0,-0.7 1.4,-3.2 3,-5.7 1.7,-2.4 3,-5.2 3,-6.2 0,-1 0.8,-3.8 1.9,-6.2 1,-2.4 2.1,-5.2 2.4,-6.2 1,-2.6 1.9,-2.1 7.5,3.8 z"
id="path17"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 411.5,77.1 c -1.3,4.1 4.7,7.7 7.5,4.4 1.8,-2.2 0.8,-5.1 -1.7,-4.7 -1.2,0.2 -2.4,-0.2 -2.8,-0.8 -1,-1.6 -2.3,-1.2 -3,1.1 z"
id="path18"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 365.2,79.7 c 0.4,3.3 4.4,5.8 6.5,4.1 3.2,-2.6 0.3,-6.8 -4.7,-6.8 -1.7,0 -2.1,0.5 -1.8,2.7 z"
id="path19"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 388,80.5 c -1.8,2.1 0.3,6.8 2.8,6.3 2.1,-0.4 2.8,-4.4 1.2,-6.4 -1.6,-1.8 -2.4,-1.8 -4,0.1 z"
id="path20"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 423.2,85.2 c -1.5,1.5 -1.6,4.4 -0.2,5.3 0.6,0.3 1.9,0.3 3.1,0 1.5,-0.5 1.9,-1.4 1.7,-3.3 -0.3,-2.9 -2.6,-4 -4.6,-2 z"
id="path21"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 736.6,148.9 c -6,6.6 -0.4,23.5 16.2,49.1 8,12.3 9.6,18 7.1,26 -3.6,12.1 -3.9,14 -3,22.8 0.5,4.8 1.6,10.7 2.4,13.2 2.5,7.5 3.8,12.8 5.2,22.2 1.1,7.2 1.9,9.7 4.2,12.6 4.4,5.3 6.5,6.5 8.9,4.8 1.7,-1.3 1.9,-2.8 1.9,-14.8 0.1,-16.3 0.2,-16.8 1.9,-16.8 1.6,0 4.6,5.8 4.6,9.1 0,1.2 0.7,4.7 1.6,7.8 3.6,12.4 5.6,15.1 10.3,13.6 2.6,-0.8 3.1,-3.9 2.9,-17 -0.1,-6.1 0.2,-12.1 0.6,-13.4 0.8,-2.4 0.9,-2.4 10.5,-1.5 5.3,0.4 16.5,0.8 25,0.9 h 15.4 l 1.8,3.5 c 1.1,1.9 2.2,5 2.5,7 0.4,1.9 1,3.9 1.4,4.5 0.4,0.5 1.3,3.5 1.9,6.5 1.6,7.5 4.4,12 7.6,12 2.3,0 6.41118,-0.0354 6.41118,-4.0354 0,-4 -4.11118,-4.6646 -6.31118,-4.2646 -3.2,0.6 -4.1,-1.2 -5.6,-11.2 -1.3,-8.7 -2.8,-12.7 -6,-16.5 l -2.2,-2.4 -27.8,-0.3 c -23.6,-0.3 -27.9,-0.1 -29,1.2 -0.9,1 -1.1,5.1 -0.9,14.5 0.3,9.2 0.1,13 -0.7,13 -1.9,0 -3.4,-2.4 -4.3,-7.1 -1.5,-7.1 -4.9,-18 -6.2,-19.5 -2,-2.4 -6.5,-1.7 -8.4,1.3 -1.5,2.2 -1.7,4.3 -1.3,13.7 0.5,12.1 0.3,13.6 -1.8,13.6 -2.3,0 -4.9,-8.2 -6,-18.5 -0.3,-3.9 -2,-11.5 -3.6,-17 -2.5,-8.5 -2.9,-11.2 -2.6,-18.2 0.2,-4.5 0.9,-8.7 1.5,-9.5 0.6,-0.7 1.8,-3.9 2.6,-7 1.3,-5 1.3,-6.4 0,-10.7 -0.8,-2.7 -2.1,-5.5 -2.9,-6.1 -0.7,-0.7 -1.4,-1.7 -1.4,-2.2 0,-0.6 -3.1,-6 -6.9,-12.2 -12.3,-20 -17.5,-34 -14.1,-38.1 2.9,-3.5 8.8,4.5 21.6,29.4 2.7,5.2 7,11.8 9.7,14.7 4.1,4.5 5.4,5.3 9.5,5.9 3.3,0.4 6.2,0.1 9.2,-1 5.4,-1.9 26,-4.1 45.9,-5 7.9,-0.4 15.4,-1 16.6,-1.5 2.4,-0.9 4.4,-4.3 7.7,-12.8 2.2,-5.9 5,-7 7,-2.7 3.25672,4.52695 4.88817,4.75784 4.15897,0.16291 C 870.85742,177.50246 865.7,171 863.4,171 c -1.2,0 -8.4,10.8 -8.4,12.7 0,1.5 -2.9,7 -4.3,8.1 -0.6,0.5 -10.4,1.2 -21.7,1.5 -11.3,0.4 -22.1,1.1 -24,1.6 -5.3,1.6 -22.1,3.8 -24.6,3.3 -1.2,-0.2 -3.5,-1.6 -5,-3.1 -3.3,-3.2 -5.6,-6.6 -14.3,-22.6 -7.9,-14.4 -13,-21.4 -17.2,-23.5 -4.7,-2.5 -5.2,-2.5 -7.3,-0.1 z"
id="path25"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1"
sodipodi:nodetypes="cccccccccsssccccccccccsscccccccsccccscccccccsccccccccccssscccccccc" /><path
d="M -2.015625,175.87812 C -5.7714471,175.45439 2.7,189.5 5.5,192.4 c 1.1,1.1 3.4,1.6 6.8,1.6 2.9,0 7.3,0.8 10,1.7 l 4.7,1.7 5.5,-5.4 c 3.9,-3.8 5.8,-5.1 6.6,-4.3 1.1,1.1 3.6,13.1 4.6,22.5 0.6,5.5 0.5,5.6 -2.6,7.2 -4,2.1 -4,4.8 0,6.3 l 3.1,1.2 -2.8,4.8 c -1.5,2.6 -6.5,8.3 -11,12.6 -4.6,4.3 -9.2,9.2 -10.3,11 -3.1,5 -3.5,11.9 -1.6,25.1 0.9,6.4 1.5,11.9 1.2,12.2 -1.7,1.7 -6.7,-5.8 -6.7,-10.2 0,-3.1 -3.3,-11.9 -5.3,-14.2 -2.5,-2.7 -3.9,-2.8 -6,-0.4 -1.4,1.6 -1.7,4.4 -1.7,18.5 0,16.7 0.7,19.7 3.2,14.1 0.9,-1.9 0.9,-3.9 0,-8.3 -1.7,-8 -1.5,-19.2 0.2,-21.6 1.4,-1.9 1.5,-1.9 2.4,0.5 2.5,6.6 3.5,9.9 4.2,14.2 1,5.4 2.9,9.3 6.5,13 5.7,6.1 8,2.4 7.1,-11.1 -0.4,-5.3 -1.2,-11.9 -1.8,-14.8 -1.7,-8.1 0.5,-12.6 11.9,-24 10.5,-10.6 11.6,-12.4 13.3,-20.9 1.8,-9.1 -1.9,-45.4 -4.6,-45.4 -0.9,0 -6.9,4.4 -10.4,7.6 l -3.5,3.2 -9.7,-0.5 -9.7,-0.6 -2.4,-3.6 c -1.3,-2 -3.4,-5.2 -4.5,-7.1 l -2.996875,-2.98437 z"
id="path26"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1"
sodipodi:nodetypes="scscccsccccccccsscssccccccccccssccccccs" /><path
d="m 862,200 c 0,1.7 -3.9,5 -6,5 -1.3,0 -2,0.7 -2,1.9 0,3 2.1,3.7 4.1,1.3 0.9,-1.2 2.2,-2.2 2.7,-2.2 0.5,0 2.1,-0.9 3.6,-2.1 2.1,-1.7 2.4,-2.4 1.5,-3.5 -1.3,-1.6 -3.9,-1.9 -3.9,-0.4 z"
id="path27"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 369.4,208.3 c -3.7,2.2 -9.4,6.9 -9.4,7.8 0,0.5 -2.5,5.1 -5.5,10.2 -3,5 -5.5,9.6 -5.5,10.2 0,0.5 -1.4,3 -3,5.5 -1.6,2.5 -3,5.2 -3,6 0,0.9 -2,3.7 -4.4,6.4 l -4.5,4.8 -9.8,-0.7 c -5.4,-0.4 -10.9,-1.2 -12.3,-1.7 -4.5,-1.8 -15.2,-2.9 -32.5,-3.5 -12.1,-0.3 -17.5,-0.9 -18.9,-1.9 -1.1,-0.7 -3.2,-4.8 -4.7,-8.9 -2.6,-7.1 -5.8,-11.4 -8.6,-11.5 -1.2,0 -10.3,13.6 -10.3,15.6 0,2.3 -6.6,4.4 -13.9,4.4 -6.8,0 -7.4,-0.2 -14.2,-4.7 -5.6,-3.7 -7.4,-4.5 -8.7,-3.6 -2.6,1.6 -2.9,3.3 -5.9,30.8 -1.3,12.2 2.6,22 12.6,31.3 2.5,2.4 5.9,5.6 7.4,7 1.5,1.5 3.7,4.6 4.9,6.9 1.8,3.7 2,5.4 1.6,15.7 -0.3,7.1 -0.9,12.1 -1.6,12.8 -1.8,1.8 -1.4,7 0.7,9.5 3.6,4.3 6.2,2.5 10.4,-6.7 1.4,-3 3,-7.8 3.7,-10.5 0.7,-2.8 1.9,-6.5 2.8,-8.3 1.1,-2.6 1.7,-3 2.8,-2.1 1.9,1.6 1.8,15.5 -0.1,18.9 -3.3,5.8 -0.7,14 4.5,14 1.5,0 3.2,-0.6 3.7,-1.3 1.5,-1.8 7,-18.5 7.8,-23.6 0.9,-5.7 5.5,-10.6 8.8,-9.7 5.8,1.7 16.7,1.8 27,0.2 11.8,-1.9 18.2,-1.5 19.7,1.3 0.5,1 0.7,5.6 0.4,10.2 -1,16.9 -0.9,17.5 2.1,19.5 2.5,1.6 2.7,1.6 4.9,-0.5 2.7,-2.5 6.1,-11.3 7.1,-18.3 0.6,-4.9 4.3,-11.4 6,-10.7 0.6,0.2 1,6.1 1,14.6 0,11.9 0.3,14.5 1.7,15.9 2.5,2.5 6.8,0.6 9.3,-4.2 1.1,-2.1 2.9,-8.4 3.8,-13.9 1,-5.5 2.7,-12.9 3.7,-16.5 1,-3.6 2.5,-9 3.4,-12.1 2.2,-7.8 2,-13.9 -0.9,-25.3 -1.4,-5.4 -2.5,-11 -2.5,-12.5 0,-1.6 4.1,-9.6 10.1,-19.6 10.4,-17.7 14.3,-25.9 15.8,-33.9 1,-5.6 -0.1,-11.5 -2.5,-13.3 -2.1,-1.6 -2.3,-1.6 -5,0 z m 3.4,11.7 c -0.6,5.5 -7.5,20.3 -16.5,34.8 -3.6,5.9 -7.4,12.5 -8.4,14.7 -2.3,5 -2.4,11.4 -0.3,16.3 1.1,2.4 1.8,7.7 2.2,14.9 0.4,11.2 0.2,12.8 -4.3,27.3 -0.7,2.5 -1.7,7.6 -2,11.5 -0.7,7.2 -3.6,15.5 -5.4,15.5 -2,0 -2.4,-2.6 -2.2,-15.7 0.2,-9.2 -0.2,-13.9 -1,-14.9 -1.2,-1.5 -5.6,-1.9 -7.6,-0.6 -1.7,1.1 -3.5,5.9 -4.4,11.7 -0.9,6.5 -4.2,15.8 -5.7,16.3 -1.3,0.4 -1.3,1 -1.4,-14.8 0,-6.3 -0.5,-12.3 -1,-13.3 -0.9,-1.7 -2.5,-1.8 -28.6,-1.1 -26.8,0.7 -27.8,0.8 -29.8,2.9 -3.1,3.4 -5.3,8.6 -6.4,15.6 -1.8,11.5 -2.4,13.4 -4.5,13.4 -1.9,0 -2,-0.8 -2,-13.8 0,-13.5 -0.1,-13.9 -2.5,-15.9 l -2.4,-2 -2.3,2.9 c -1.3,1.5 -2.9,3.5 -3.6,4.3 -0.8,0.8 -2.1,5.2 -3,9.8 -1.5,7.8 -2.9,11.2 -4.7,11.2 -1.8,0 -2,-5.9 -0.5,-14 0.8,-4.7 1.5,-10 1.5,-11.8 0,-4.7 -4.6,-13.6 -9.4,-18.2 -7.4,-7.1 -9.7,-9 -10.7,-9 -0.5,0 -0.9,-0.4 -0.9,-1 0,-0.5 -1.1,-2.6 -2.5,-4.7 -3.1,-4.5 -2.9,-7.4 0.8,-9.1 3.5,-1.7 3.4,-2.8 -0.2,-4.6 -1.6,-0.9 -3.1,-2.2 -3.5,-3.1 -0.3,-0.8 -0.1,-5.9 0.4,-11.3 0.5,-5.3 1,-10.7 1,-11.9 0,-1.2 0.8,-2.7 1.8,-3.2 1.4,-0.7 2.8,0 6.7,3.5 4,3.6 5.5,4.4 7.5,3.9 1.4,-0.3 6.5,-1.1 11.3,-1.8 l 8.8,-1.2 5,-7.8 c 2.7,-4.3 5.5,-7.7 6.1,-7.5 0.6,0.2 2.4,3.3 3.9,6.9 1.4,3.5 4,7.9 5.6,9.6 l 3,3.3 h 15.1 c 12.3,0 18.5,0.6 32.7,3 9.7,1.6 19,3 20.9,3 7.4,0 13.2,-5.6 20.9,-20.4 8.1,-15.2 13.4,-24 16.6,-27.5 2.7,-2.9 3.3,-3.1 4.7,-2 1.2,1 1.5,2.5 1.2,5.9 z"
id="path28"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 246.4,261.1 c -1,1.7 1.3,3.6 2.7,2.2 1.2,-1.2 0.4,-3.3 -1.1,-3.3 -0.5,0 -1.2,0.5 -1.6,1.1 z"
id="path29"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 255.4,267.2 c -0.9,1.5 2.7,4.1 3.9,2.9 1.3,-1.3 0.1,-4.1 -1.7,-4.1 -0.8,0 -1.8,0.5 -2.2,1.2 z"
id="path30"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 227,269.5 c -1,1.2 -1,1.8 0,3 2,2.5 5.5,1.5 5.5,-1.5 0,-3 -3.5,-4 -5.5,-1.5 z"
id="path31"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 205.3,270.6 c -0.8,2.1 1.3,4.2 2.8,2.7 1.3,-1.3 0.5,-4.3 -1.1,-4.3 -0.6,0 -1.3,0.7 -1.7,1.6 z"
id="path32"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 34.2,209.2 c -2,2 -1.4,4 1.1,3.6 1.4,-0.2 2.2,-1 2.2,-2.2 0,-2.4 -1.6,-3.1 -3.3,-1.4 z"
id="path33"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 12.6,209.9 c -0.4,0.7 -0.5,1.5 -0.2,1.8 0.9,0.9 1.9,-0.4 1.4,-1.8 -0.4,-1.1 -0.6,-1.1 -1.2,0 z"
id="path34"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 867.4,267.1 c -0.9,1.5 0.6,19.8 1.7,20.5 0.5,0.3 0.9,-4.2 0.9,-10.5 0,-10.2 -0.7,-13 -2.6,-10 z"
id="path35"
style="display:none;fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 496.3,275.2 c -1.3,0.6 -2.5,1.9 -2.9,3 -0.3,1 -1.7,3.6 -3.1,5.8 -2.8,4.3 -8.6,7.1 -17.3,8.5 -2.5,0.3 -5.9,1.1 -7.6,1.7 -2.8,1 -3.7,0.7 -8.5,-2.5 -6.1,-4 -13.1,-5.8 -14.5,-3.6 -0.5,0.8 -1.1,5 -1.5,9.4 -0.4,5.9 -1.3,9.5 -3.3,13.5 -2,4 -3,8 -3.6,14.5 -0.5,4.9 -1.2,10.7 -1.6,12.7 -0.6,3.5 -0.5,3.8 2.7,5.1 1.9,0.8 6.4,4.3 10,7.8 3.6,3.5 8.4,8 10.7,10 4.9,4.1 5.5,7.8 2,10.2 -1.3,0.8 -4.3,3.3 -6.8,5.5 -2.5,2.3 -5.6,4.4 -7,4.7 -3.8,1 -8.6,5 -10,8.5 -1.1,2.7 -1,3.3 0.5,5 3.5,3.9 9.4,2.1 20.7,-6.2 4,-3 9.3,-6.5 11.8,-7.9 2.5,-1.4 6.2,-3.5 8.3,-4.7 4.7,-2.7 4.7,-2.7 4.7,0.2 0,2.4 -4.9,7.6 -10.7,11.2 -4,2.6 -6.7,5.6 -8.2,9.2 -2.8,6.7 3,9.3 11.4,5.2 7.3,-3.6 20.7,-14.2 21.7,-17.2 0.3,-0.9 2.2,-0.5 7.5,1.6 l 7,2.8 11.8,-2.6 c 6.5,-1.4 13.9,-2.6 16.4,-2.6 5.5,0 22.9,-3.3 32.6,-6.1 7.2,-2.1 13.3,-4.9 17.3,-8 3.7,-2.8 9.9,-12 10.7,-15.9 0.4,-1.9 1.4,-4.8 2.2,-6.3 1.8,-3.5 1.4,-24.4 -0.6,-29.7 -7.2,-19.4 -17.6,-29.8 -33.4,-33.4 -11.1,-2.6 -18.7,-1.8 -35.2,3.6 -13.8,4.6 -15.6,4.6 -18.6,0.6 -1.3,-1.8 -3.3,-5.1 -4.4,-7.3 -3.7,-7.6 -6,-8.8 -11.2,-6.3 z m 8.4,10.8 c 4.7,9.4 6.2,10.5 13.3,10.4 3.3,-0.1 6.5,-0.6 7.2,-1.1 0.7,-0.5 4.5,-1.8 8.3,-2.8 3.9,-1 7.6,-2.3 8.4,-2.9 0.8,-0.7 5.9,-1.1 12.5,-1.1 8.6,0 11.8,0.4 14.1,1.7 1.7,0.9 4.7,2.4 6.7,3.3 6.1,2.7 13.7,11.9 17.1,20.5 2.6,6.8 2.9,8.6 2.9,19.5 0,13.7 -1.1,18.7 -5.2,24.8 -7.5,11.1 -20,17 -41,19.2 -3.6,0.4 -7.6,1.2 -9,1.7 -1.4,0.6 -5.9,1.2 -10,1.4 -4.1,0.3 -9.8,1.1 -12.7,1.9 -6.4,1.9 -9.2,1.9 -13.4,0 -6.9,-3 -8.3,-4.7 -8.1,-10.6 0.1,-4.3 0.5,-5.7 2.4,-7.1 1.3,-1 2.8,-1.8 3.5,-1.8 1.4,0 2.8,6.3 1.9,7.9 -1,1.5 3.1,5.1 5.8,5.1 1.3,0 5.2,-0.9 8.7,-2.1 3.5,-1.1 12.5,-2.7 19.9,-3.4 17.2,-1.6 27.3,-4.4 31.7,-8.7 1.8,-1.8 3.3,-3.6 3.3,-4 0,-1.4 -3.3,-0.9 -5.1,0.8 -1.8,1.6 -5.8,2.9 -13.9,4.4 -2.5,0.4 -6.4,1.6 -8.6,2.5 -2.6,1 -6.2,1.5 -9.9,1.3 l -5.8,-0.3 -1.9,-5.5 c -1.3,-3.7 -1.9,-8.6 -2,-15 -0.1,-8.3 0.2,-10.2 2.5,-15 2.7,-5.8 6.7,-10.7 11.3,-13.7 4.8,-3.1 13.2,-5.2 18.5,-4.6 4.2,0.5 4.9,0.3 4.9,-1.1 0,-3.1 -5.7,-4.5 -13,-3.3 -3.6,0.5 -7.2,1.6 -8.1,2.3 -0.8,0.8 -2.1,1.4 -2.7,1.4 -1.7,0 -13,11.9 -14.7,15.5 -4.6,9.5 -6,22.6 -3.1,28.3 0.7,1.5 1.6,4.6 2,6.9 0.5,3.8 0.3,4.4 -1.9,5.9 -3.4,2.2 -10.6,3.5 -12.2,2.2 -0.7,-0.6 -1.3,-2.9 -1.3,-5.2 0,-4.5 -2.2,-8.6 -4.7,-8.6 -0.8,0 -3.2,1.4 -5.3,3 -4.4,3.5 -5.5,6.8 -5.1,15.2 0.3,6.2 0.3,6.2 -8.6,13.8 -6,5 -16.1,11.2 -17.3,10.5 -2.5,-1.6 -0.6,-4.8 6.3,-10.4 10.4,-8.6 17.3,-17.9 15,-20.2 -0.9,-0.9 -1.6,-0.9 -2.5,-0.2 -0.7,0.6 -3,1.3 -5.1,1.6 -2.1,0.3 -6.4,2.3 -9.5,4.3 -3.1,2.1 -7.6,4.8 -10.1,6.2 -2.5,1.3 -6.1,3.6 -8.1,5.1 -5.7,4.5 -9.7,6.4 -11.5,5.8 -2.4,-1 -1.4,-4 2,-5.8 6.6,-3.4 10.4,-6.2 20.7,-15.3 3.2,-2.8 1.3,-7.4 -5.5,-13.9 -3.4,-3.2 -7.9,-7 -9.9,-8.4 -2.1,-1.4 -3.8,-3.2 -3.8,-3.9 0,-0.7 -0.6,-1.7 -1.4,-2.1 -2.8,-1.7 -5,-7.7 -5,-14.3 -0.1,-6.9 2.1,-14.3 5.7,-19.2 1.6,-2.2 1.9,-3.7 1.4,-8 -0.9,-9.4 1.8,-10.7 9.5,-4.7 5.3,4.2 8.2,4.5 16.8,1.5 4,-1.4 8.6,-2.6 10,-2.6 5.8,0 9,-2 11.7,-7.4 3,-6 5.2,-8.8 6.7,-8.3 0.6,0.2 2.5,3.2 4.3,6.7 z"
id="path36"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 504.9,311.9 c -1.8,1.4 -2.8,1.7 -4.2,0.9 -2.6,-1.4 -4.5,0.5 -2.7,2.7 2.8,3.4 4.7,3.6 7.9,0.9 4.7,-3.9 3.7,-8.3 -1,-4.5 z"
id="path37"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 519.8,312.6 c -1.5,3.3 0,5.9 3.3,5.9 2.8,0 3.7,-3.1 1.9,-6.4 -1.5,-2.9 -3.8,-2.6 -5.2,0.5 z"
id="path38"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 477.4,321.4 c -0.9,2.4 0.3,4.7 2.1,4 1.8,-0.7 1.6,-4.5 -0.2,-5.2 -0.7,-0.2 -1.6,0.3 -1.9,1.2 z"
id="path39"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 450.8,321.7 c -5.8,0.5 -6.3,2.2 -1.4,5.2 4.3,2.7 5.5,2.6 8.9,-0.5 3.3,-2.8 3,-5.5 -0.5,-5.2 -1.3,0.1 -4.4,0.3 -7,0.5 z"
id="path40"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 129,284 -4.4,5 h -7 c -3.9,0.1 -9.1,0.5 -11.6,1.1 -11.8,2.5 -11.6,2.6 -16.8,-2.3 -5.8,-5.4 -10.4,-7.5 -14,-6.2 -3.1,1.1 -3.5,2.6 -5.2,19.9 -1,9.8 -1.5,12.2 -3.1,13.3 -2.3,1.6 -2.4,4.3 -0.4,8.2 1.2,2.4 1.5,6.2 1.2,19.5 -0.2,9.1 -0.6,16.7 -0.9,17 -0.3,0.3 -2.5,1.6 -4.9,2.8 -3,1.5 -5.3,3.8 -7.4,7.2 -2.8,4.7 -3,5.7 -3,14.5 0.1,9.2 0.2,9.8 3.9,16.3 2.1,3.8 5.2,8.4 6.9,10.4 2.9,3.3 3.2,4.3 3.2,10.2 v 6.6 l 6,0.3 c 3.3,0.2 6.4,0.7 6.9,1.3 1.7,1.6 3.7,1 6.6,-2.1 l 2.8,-3 16.9,0.1 c 9.2,0 19.1,-0.3 22,-0.6 5.1,-0.7 5.3,-0.7 9.4,3.4 2.3,2.2 4.5,4.1 4.8,4.1 0.3,0 2.4,-1.2 4.6,-2.5 2.2,-1.4 5.2,-2.5 6.7,-2.5 2.2,0 3.4,-1 6.1,-5.3 1.9,-2.8 4.9,-6.6 6.7,-8.2 1.8,-1.7 4.3,-4.6 5.6,-6.5 1.3,-1.9 4,-5.5 5.8,-8 1.9,-2.5 3.5,-4.8 3.5,-5.2 0.1,-0.4 2,-4 4.4,-8 4,-6.9 7.7,-15.9 7.7,-19.1 0,-1.1 -2.5,-7.1 -3.5,-8.5 -0.1,-0.1 -1.3,0.2 -2.6,0.7 -4.2,1.5 -8.8,6.7 -11.4,12.5 -1.4,3.1 -2.9,5.6 -3.5,5.6 -0.5,0 -1,-0.4 -1,-0.8 0,-0.5 -1.4,-3 -3.1,-5.6 -3.1,-4.7 -9.4,-9.6 -12.3,-9.6 -4.1,0 -3,-10.2 1.9,-18.3 5.1,-8.5 6.1,-13.2 4.1,-20.1 -1.1,-3.5 -1.4,-7 -1,-9.2 0.6,-3.3 0.3,-3.9 -4.9,-8.9 -4.9,-4.8 -12.7,-16.1 -12.7,-18.4 0,-0.5 -0.9,-2 -2.1,-3.5 -3.1,-3.9 -5.9,-3.3 -10.9,2.4 z m 11.3,7.5 c 2,4.1 4.1,7.5 4.7,7.5 0.5,0 1,0.4 1,1 0,0.5 1.5,3.1 3.4,5.7 3.7,5.2 6.6,15.1 6.6,22.4 0,2.9 -0.9,6 -2.5,8.9 -5.5,9.7 -5.9,11.1 -6.2,23.6 -0.1,6.6 -0.7,17 -1.3,23 -0.6,6 -1.3,17 -1.6,24.4 -0.5,12.6 -1.6,18 -3.6,18 -1.8,0 -2.6,-5 -3.2,-19.8 -0.4,-8.5 -1,-16 -1.3,-16.8 -0.8,-2.3 -3.2,-1.6 -3.9,1.1 -0.7,2.7 0,18.5 1,22.5 1.2,4.5 -0.4,5.4 -10.6,5.6 -5.1,0.1 -15,0.4 -22,0.8 -9.6,0.4 -12.9,0.2 -13,-0.7 -0.1,-0.7 -0.2,-7.8 -0.3,-15.9 0,-14.5 -0.5,-17.2 -2.7,-15 -0.8,0.8 -1.1,5.9 -0.9,16.6 0.3,16.8 -0.4,20 -4.2,19.4 -2.1,-0.3 -2.2,-0.8 -2.7,-16.3 -0.3,-8.8 -0.9,-16.5 -1.2,-17.1 -0.4,-0.6 -1.4,-8 -2.2,-16.5 -2.4,-25.2 -0.5,-78.4 3,-85.1 l 1.5,-2.9 3.7,2.3 c 2,1.2 4.6,3.4 5.8,4.7 2.3,2.7 4.1,2.9 27.1,2.3 l 11.2,-0.2 4.1,-5.5 c 2.2,-3 4.6,-5.5 5.3,-5.5 0.7,0 2.9,3.4 5,7.5 z m 47.1,73.1 c 0.4,1 -0.1,3.8 -1.1,6.3 -2,5.3 -9,18.8 -11,21 -0.8,0.9 -3.6,5.1 -6.4,9.4 -2.7,4.3 -5.6,8 -6.5,8.4 -0.8,0.3 -2,1.7 -2.5,3 -0.9,2.6 -2.9,2.3 -2.9,-0.4 0,-0.9 2,-5.2 4.4,-9.7 2.4,-4.5 6.7,-12.6 9.6,-18.1 10.1,-19.2 14.6,-24.7 16.4,-19.9 z m -28.3,2.7 c 4.9,4.4 7.3,8.6 7.1,12.3 -0.3,3.5 -6.2,18.5 -11.1,27.9 -2.1,4.2 -3.1,7.4 -3.1,10.7 0,2.6 -0.5,5 -1.1,5.3 -1.6,1 -2.1,0.1 -2.3,-4.2 -0.1,-2.1 -0.5,-4.3 -0.8,-4.9 -0.9,-1.3 1.8,-29.8 3.2,-34.4 0.5,-1.9 1,-6 1,-9.1 0,-8.1 1.3,-8.8 7.1,-3.6 z m -91.7,-0.1 c 1.7,2.7 3.1,10.9 4.6,26.9 1.8,20.3 1.2,23.1 -3.7,17.6 C 65.7,408.8 59,398.3 59,397 c 0,-0.6 -0.4,-1.8 -0.9,-2.8 -0.5,-0.9 -1.4,-4.2 -2.1,-7.3 -0.9,-5 -0.8,-6.3 0.9,-10.9 1.9,-4.9 6.4,-11 8.3,-11 0.4,0 1.4,1 2.2,2.2 z"
id="path41"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 134,305.9 c -1.4,2.7 -1.3,5.8 0.3,6.4 0.6,0.2 2.7,0.2 4.5,0 2.5,-0.4 3.2,-1 3.2,-2.8 0,-4.8 -5.9,-7.4 -8,-3.6 z"
id="path42"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 82.2,308.3 c -1.9,2.3 -2.4,6.3 -0.9,7.5 1.1,1 1.7,0.9 2.7,-0.3 0.9,-1.1 2.3,-1.3 5.1,-0.9 3.2,0.5 3.9,0.4 3.9,-1 0,-2.4 -4.8,-6.6 -7.5,-6.6 -1.3,0 -2.7,0.6 -3.3,1.3 z"
id="path43"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 112.2,312.2 c 0.3,2.5 3.4,3.6 5.1,1.9 1.4,-1.4 -0.9,-4.1 -3.5,-4.1 -1.4,0 -1.9,0.6 -1.6,2.2 z"
id="path44"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 771.2,356.7 c -1.7,0.6 -4.5,6.4 -7.2,14.4 -1.5,4.3 -3.4,7.7 -6.2,10.6 -3.9,4 -4.1,4.3 -3.5,9.5 0.5,5.8 -1,10.1 -4,11.2 -0.9,0.4 -9.8,0.6 -19.7,0.5 -10,0 -24,0.2 -31.2,0.6 l -13,0.8 -2.2,-2.8 C 683,400 682,398 682,397 c 0,-2.9 4.3,-5.9 11,-7.6 11.2,-2.8 17.1,-7 18.6,-13.1 0.8,-2.9 -0.9,-7.1 -3.2,-8 -1.7,-0.6 -8.7,1.3 -12.1,3.3 -1.2,0.8 -5.5,1.9 -9.5,2.5 -8.1,1.2 -11.6,3 -16.9,8.6 -7.3,7.7 -8.4,16 -3.3,25.2 2.8,5 3,9.1 0.9,15.1 -1.2,3.4 -1.5,10.4 -1.5,36.9 v 32.6 l 2.5,3.8 c 3.4,4.8 6.7,5 9.5,0.4 1.1,-1.7 2,-4.2 2,-5.3 0,-1.2 0.7,-3.8 1.5,-5.7 0.8,-2 1.5,-4.8 1.5,-6.2 0,-2.9 3.6,-10.5 4.9,-10.5 1.3,0 3.9,7.7 5,14.6 1,6.8 3.7,12.9 7,15.8 4.4,4 10.8,-1.3 8.3,-6.9 -1.4,-3.1 -2.7,-17.4 -1.8,-20.8 l 0.6,-2.5 h 25.3 c 21.5,0.1 25.7,-0.2 27.8,-1.5 3.3,-2.2 9.8,-3.1 13.1,-1.8 2.7,1 5.8,6.4 5.8,10.2 0,2.6 4.8,16.9 6.6,19.6 1,1.7 2.4,2.3 4.9,2.3 4.5,0 4.8,-1.4 3.6,-15.1 -1.1,-12.5 -0.4,-17.3 2.5,-17.7 3.8,-0.5 7.4,4.9 10,15 1.9,7.1 3.2,10 5.6,12.5 3.5,3.6 8.1,4.5 9.2,1.7 1,-2.7 0.7,-9.9 -0.8,-14.5 -2.7,-8.4 -4.9,-26.8 -4.3,-35.1 0.7,-8.9 3.9,-17.4 6.9,-18.4 1.1,-0.3 4.8,-3.3 8.2,-6.5 4.5,-4.2 6.6,-7.1 7.5,-10.1 0.7,-2.4 1.7,-5.3 2.2,-6.6 1.6,-4.1 1.1,-7.2 -1.4,-8.3 -2.6,-1.2 -6.9,-12.4 -7.3,-19 -0.3,-5.3 -4.5,-13.9 -6.8,-13.9 -1,0 -4.3,2.5 -7.3,5.6 l -5.6,5.6 -5.1,-0.6 c -2.8,-0.4 -8.1,-1.1 -11.7,-1.5 -8,-1 -12.1,-3.2 -15.6,-8.6 -4.1,-6.1 -5.7,-7.2 -9.1,-5.8 z m 7.3,11.3 c 5.3,6.6 8.8,7.8 23.9,8.6 l 12.5,0.7 4.2,-4.1 c 5.7,-5.8 7.9,-5.3 7.9,1.6 0,3.1 1.6,8.3 4.1,13.7 2.9,6.2 4.2,14.7 2.9,19.5 -1.1,4 -6.1,9.6 -11.8,13 -4.1,2.5 -8,7.6 -9.1,11.8 -1.3,4.7 -0.7,31.7 0.9,39.7 0.7,3.3 2.1,8.1 3.2,10.7 2,4.6 1.9,7.8 -0.1,7.8 -1.5,0 -4.7,-6 -7.1,-13.5 -3.6,-11.3 -3.9,-12 -7,-13.8 -4.1,-2.4 -11.9,-3.4 -13.1,-1.6 -1.5,2.3 -1,15.7 0.8,22.5 1.2,4.5 1.4,6.7 0.6,7.5 -1.4,1.4 -4.3,-1.5 -4.3,-4.4 -0.1,-1.2 -0.7,-3.3 -1.5,-4.7 -0.8,-1.4 -1.4,-3.7 -1.5,-5.1 0,-3.9 -2.8,-11 -5.2,-13.6 -1.8,-1.9 -3.2,-2.3 -8.7,-2.3 -3.6,0 -10.4,0.5 -15.1,1.2 -4.7,0.6 -18,1.2 -29.6,1.2 -16.6,0.1 -21.5,0.4 -22.8,1.5 -1.4,1.2 -1.6,2.7 -1.2,9.3 0.4,4.4 1.1,8.8 1.6,9.8 1.2,2.3 1.3,10.7 0.1,11.4 -1.7,1.1 -4.2,-4.5 -6.1,-13.6 -2.1,-10.1 -5.7,-18.1 -8.4,-18.6 -3.4,-0.7 -6.8,4.3 -9.5,14.3 -0.6,2.2 -1.8,6.6 -2.7,9.7 -1.6,6.1 -3.9,7.7 -4.9,3.5 -0.3,-1.2 -0.5,-7.6 -0.3,-14.2 0.1,-6.6 0.1,-19 -0.1,-27.5 -0.2,-9.8 0.1,-17.8 0.9,-21.8 1.5,-7.6 0.8,-19.5 -1.4,-23.7 -2.3,-4.3 -2.1,-12 0.4,-16 3.9,-6.3 17.2,-12.5 26.6,-12.5 1.7,0 3.7,-0.7 4.4,-1.5 1.8,-2.1 5,-1.9 5,0.3 0,3.3 -6.1,8.1 -11.8,9.2 -5,0.9 -10.8,3.5 -14.6,6.4 -2.6,2.1 -3.3,9.4 -1.2,13.4 2.1,4 7.6,5.6 15.9,4.7 3.2,-0.4 17.6,-0.8 31.9,-0.9 l 26,-0.1 2.3,-2.7 c 2.4,-2.7 5.5,-11.8 5.5,-15.8 0,-1.2 1.1,-4 2.5,-6.4 1.3,-2.3 3.1,-6 3.9,-8.1 2.9,-8.1 4.7,-11.5 5.9,-11.5 0.6,0.1 3,2.3 5.2,5 z"
id="path45"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 775.5,387 c -0.4,0.7 -0.3,1.6 0.3,2.2 1.4,1.4 4.5,0.2 4,-1.5 -0.6,-1.6 -3.4,-2.1 -4.3,-0.7 z"
id="path46"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 821.3,388.7 c -0.7,0.3 -1.3,1.6 -1.3,2.9 0,1.9 0.5,2.4 2.5,2.4 1.8,0 2.5,-0.5 2.5,-1.9 0,-2.2 -2,-4 -3.7,-3.4 z"
id="path47"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 795.7,390.2 c -0.6,1.8 2.1,6.8 3.6,6.8 1.7,-0.1 4.7,-2.5 4.7,-3.9 0,-0.7 -1.6,-1.9 -3.5,-2.7 -4.2,-1.7 -4.2,-1.7 -4.8,-0.2 z"
id="path48"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 220.4,467.7 c -1.6,2.1 -3.5,5 -4.3,6.4 -2.1,4.3 -6.2,6.6 -14.7,8.4 -12.2,2.7 -15.2,2.5 -20.9,-1.3 -4.9,-3.3 -11.7,-4.4 -13,-2.1 -0.4,0.6 -0.9,4.7 -1.2,9.2 -0.4,6.3 -1.2,9.7 -3.5,14.5 -2.3,5 -3.1,8.2 -3.4,14.3 -0.2,4.4 -0.9,8.9 -1.4,10 -1.6,2.9 -0.4,5.3 3.8,7.7 2.1,1.1 6.3,4.5 9.3,7.4 3,2.9 7.6,7 10.2,9.1 7,5.6 6.6,8.1 -2,14.4 -3.8,2.7 -8.2,5.5 -9.9,6.2 -1.7,0.6 -4.5,2.5 -6.3,4.2 -2.7,2.4 -3.2,3.5 -2.9,6.6 l 0.3,3.8 5.8,-0.1 c 4.2,-0.1 6.4,-0.7 8,-2.1 3.8,-3.4 15,-10.9 19.2,-12.8 2.2,-1 5.4,-2.9 7.2,-4.2 3.7,-2.8 5.9,-2.3 4.2,0.9 -0.6,1.1 -3.8,4.5 -7.2,7.6 -10.8,10 -10.7,9.9 -10.7,13.5 0,1.9 0.6,4 1.4,4.6 2.2,1.9 8.4,0.2 13.7,-3.6 2.5,-1.8 5.1,-3.3 5.6,-3.3 0.6,0 3.6,-2.6 6.7,-5.7 l 5.6,-5.7 5.3,1.7 c 3,0.9 7.1,1.7 9.1,1.7 4.8,0 17.7,-2 19.6,-3 0.9,-0.5 6.3,-1.3 12,-1.9 25.9,-2.8 39.8,-7.3 48.7,-16 8.7,-8.5 11.8,-18.5 11,-35.6 -0.8,-15.9 -3.5,-23.2 -12.9,-34.6 -4.8,-5.9 -15.9,-11.8 -24.3,-13 -8.7,-1.2 -22.2,0.6 -34.6,4.7 -5.8,2 -11.6,3.4 -12.9,3.2 -1.6,-0.2 -3.2,-2.2 -5.7,-6.8 -7.3,-13.5 -9.7,-14.9 -14.9,-8.3 z m 9.8,8.9 c 5.2,10.6 10.4,13.5 18.1,10 1.8,-0.8 6.8,-2.4 11.2,-3.6 4.4,-1.2 8.8,-2.6 9.7,-3.1 1,-0.5 6,-0.9 11.1,-0.9 8.5,0 9.8,0.3 16.6,3.5 7.7,3.7 15.5,10.8 18.5,16.9 2.1,4.4 6.3,18.2 7.1,23.8 0.7,4.5 -0.3,8.5 -5.2,22.5 -1.8,5.1 -8.8,12.2 -14.8,15.2 -6.8,3.4 -17.3,6.3 -24.5,6.8 -3.6,0.3 -7,0.9 -7.7,1.5 -0.6,0.5 -5.8,1.4 -11.5,1.9 -5.7,0.6 -12.5,1.7 -15.1,2.5 -5.7,1.9 -15.1,1.1 -18.3,-1.4 -2.3,-2 -4.4,-7.2 -4.4,-11.2 0,-3 4,-8 6.5,-8 1.1,0 1.5,1.2 1.5,5 0,8.1 5.6,11.3 12.8,7.6 3.3,-1.7 10.8,-3.1 24.7,-4.7 5.5,-0.6 12,-1.7 14.5,-2.4 2.5,-0.7 5.6,-1.6 7,-1.9 1.4,-0.3 4.4,-2.1 6.5,-4 3.6,-3.1 3.8,-3.5 2.1,-4.2 -1,-0.4 -2.6,-0.2 -3.5,0.4 -4,2.9 -9.8,5.2 -12.8,5.2 -1.8,0 -5,0.6 -7,1.4 -4.9,1.8 -15.1,2.8 -17.3,1.6 -2.9,-1.5 -5.2,-10.6 -5.4,-20.5 -0.1,-7.8 0.3,-9.7 2.4,-14 1.4,-2.7 4.4,-6.9 6.8,-9.3 2.3,-2.3 4.2,-4.6 4.2,-5.1 0,-1.4 13.8,-4.3 18.8,-3.9 4,0.2 4.7,0 5,-1.6 0.7,-3.3 -2.3,-4.5 -9.6,-3.9 -13.1,1.1 -25.5,11.3 -30.4,25.1 -3.3,9.2 -3.4,13.6 -0.8,22 1.1,3.5 2,7.7 2,9.3 0,2.6 -0.6,3.2 -4.7,4.9 -2.7,1.1 -5.6,2.4 -6.5,3 -2.8,1.6 -4.9,-1.4 -4.3,-6 0.5,-4.3 -2.3,-9 -5.5,-9 -1,0 -4.2,2.2 -7.1,5 -3,2.7 -6.4,5 -7.5,5 -1.2,0 -2.7,0.7 -3.4,1.5 -0.7,0.8 -2.5,1.5 -4,1.5 -1.6,0 -6,2.2 -11.1,5.6 -4.6,3.1 -9.1,5.9 -9.9,6.2 -0.8,0.3 -4.5,2.5 -8.3,4.9 -6.9,4.4 -10.7,5.4 -10.7,2.8 0,-0.8 1.2,-2.2 2.8,-3.2 1.5,-0.9 3.2,-2.1 3.7,-2.7 0.6,-0.6 2.8,-2 5,-3.1 4.7,-2.4 13.5,-10.7 13.5,-12.8 0,-4 -3.4,-8.9 -10.2,-14.5 -12.2,-10 -16.6,-16.3 -17.3,-24.7 -0.3,-3.5 2.8,-13.9 5.3,-17.5 1.1,-1.6 1.9,-5.3 2.2,-10.5 0.7,-10.6 1.6,-10.7 13.7,-1.9 2,1.4 2.9,1.4 7.5,0.4 2.9,-0.7 6.2,-1.9 7.2,-2.6 1.1,-0.7 4.7,-1.6 8,-2 5.2,-0.6 6.6,-1.2 9.4,-4.2 1.8,-1.9 4.2,-5.2 5.3,-7.3 1,-2.1 2.6,-3.9 3.4,-3.9 0.8,0 2.9,2.9 4.7,6.6 z M 218,568.4 c -0.1,5 -0.8,6 -9,12.3 -11.5,8.7 -19,12.3 -19,9 0,-0.7 4.4,-5.7 9.8,-11.2 5.3,-5.5 11,-11.5 12.5,-13.3 3.6,-4.2 5.7,-3 5.7,3.2 z"
id="path49"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 230.6,501.5 c -1.1,0.8 -3.3,1.5 -4.8,1.5 -1.7,0 -3.1,0.7 -3.5,1.5 -0.7,1.9 2.3,4.4 5.3,4.5 2.7,0 7.8,-5.4 7.1,-7.4 -0.8,-2 -1.6,-2 -4.1,-0.1 z"
id="path50"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 246.1,502.4 c -1.9,2.3 -1,6.1 1.4,6.1 1.6,0 2,-0.7 2,-3.4 0,-3.8 -1.5,-5 -3.4,-2.7 z"
id="path51"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 201.3,511.6 c -0.8,2.1 2.8,5.7 5,4.8 1.8,-0.7 3.1,-3.9 2.2,-5.4 -1,-1.6 -6.5,-1.2 -7.2,0.6 z"
id="path52"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 173,513.5 c -1.6,1.9 2.8,5.5 6.7,5.5 2.2,0 6.1,-3 6.5,-5 0.2,-1.2 -0.9,-1.5 -6,-1.5 -3.5,0 -6.7,0.5 -7.2,1 z"
id="path53"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 524.5,489.3 c -0.8,1.8 -2.4,6.3 -3.5,10.2 -2.8,9.5 -8.1,18.9 -10.8,19.3 -3.6,0.5 -4.4,3.4 -3.1,10.9 0.6,3.8 1.5,7.6 2,8.5 0.5,1 0.9,2.4 0.9,3.1 0,0.8 2.3,4 5,7.1 8.6,9.8 8.2,7.9 8.3,43.5 0,17.4 0.4,32.4 0.9,33.3 1.3,2.6 3.1,2.2 5.7,-1.2 l 2.2,-3.1 10.2,0.5 c 8.3,0.4 10.4,0.8 11.2,2.2 0.6,0.9 1.8,2.6 2.8,3.6 2.2,2.4 5.4,0.9 6.8,-3.2 1.7,-4.7 3.4,-4.3 7.6,1.7 l 3.8,5.5 6,-0.6 c 3.3,-0.4 9.1,-0.9 12.8,-1.3 6.8,-0.6 6.9,-0.5 6.5,1.7 -0.3,2.2 -7.6,8 -21.5,16.9 -4.6,2.9 -10.2,9.6 -10.3,12.2 0,2.2 4.2,5.9 6.8,5.9 2.9,0 13,-3.8 16.7,-6.3 6.1,-4.2 22.8,-21.4 26.3,-27.2 l 3.7,-6 V 605 c 0,-17.5 -0.4,-23 -1.9,-29.5 -2.6,-11.1 -8.1,-27.7 -12.1,-36 -3.1,-6.6 -4.8,-11.6 -6.6,-19.5 -1.2,-5.4 -5.4,-17 -7.7,-21.5 -3.1,-6.2 -8.4,-11.5 -11.2,-11.5 -2.4,0 -6.2,3.7 -8.8,8.7 -1.9,3.6 -6.5,4.4 -18.9,3.4 l -11.2,-0.9 -5.7,-5.1 c -3.1,-2.7 -7,-5.5 -8.6,-6 -2.7,-0.9 -3,-0.8 -4.3,2.2 z m 60.9,5.8 c 4.6,5.4 7,11.4 11.1,27.9 1.8,7.1 5.5,16.9 9,24 1.2,2.5 3.8,9.7 5.8,16 4.1,13.2 5.7,26.4 5.8,47.6 0.1,12.5 -0.1,14.5 -2.1,18.5 -2.7,5.5 -21.6,24 -28.5,28 -5.2,3 -10.8,3.8 -12.1,1.7 -0.9,-1.3 1.8,-3.7 15.3,-13.6 11.6,-8.4 19.5,-16 19.1,-18.2 -0.3,-1.2 -2.2,-1.5 -11.3,-1.5 -6.1,0 -12.9,0.3 -15.2,0.8 -3.3,0.5 -4.5,0.3 -5.8,-1 -1.6,-1.6 -1.6,-1.7 0.1,-3 1,-0.7 2.6,-1.3 3.6,-1.3 2.8,0 3.8,-3.2 3.7,-12.6 0,-4.9 0.4,-10 0.9,-11.4 1.2,-3.1 7.7,-9.9 10.6,-11 1.8,-0.7 1.5,-0.9 -1.4,-0.9 -9.7,0.1 -15.4,10.4 -15.5,27.9 v 5.5 h -6 c -5.5,0 -6,-0.2 -6.3,-2.3 -0.2,-1.2 0.6,-4.6 1.7,-7.6 2.5,-6.8 3.2,-17.1 1.2,-17.1 -1.7,0 -2.6,2.8 -4.5,14.5 -1.8,10.5 -3.9,16.5 -5.7,16.5 -1.6,0 -1.6,-2.3 0,-17.4 1.1,-9.4 1,-10.6 -0.5,-12.3 -2.9,-3.1 -3.7,-0.6 -3.8,12.2 -0.1,9.1 -0.5,12.3 -1.6,13 -1.8,1.1 -11.6,0 -15.8,-1.7 -2.8,-1.2 -3.3,-1.8 -2.8,-3.7 0.6,-2.4 2.1,-12.4 3.3,-20.9 0.5,-4.3 0.4,-4.7 -1.4,-4.7 -2.3,0 -3.3,3.3 -3.3,11.1 0,2.7 -0.4,4.9 -0.9,4.9 -0.4,0 -1.1,2.6 -1.5,5.7 -0.8,7.2 -1.9,9.4 -3.4,6.6 -0.8,-1.4 -0.8,-4.2 0,-9.4 0.6,-4.1 1.2,-15.7 1.2,-25.9 0.1,-18.3 0.1,-18.6 -2.7,-24.5 -1.5,-3.3 -4.1,-7.4 -5.6,-9 -9.2,-9.9 -9.4,-13.9 -1.5,-30.3 3,-6.4 6.1,-13.9 6.8,-16.7 2,-7.5 3.6,-7.9 9.3,-2.2 2.8,2.8 6.2,5.1 8.2,5.6 6.2,1.6 27.9,2.2 29.1,0.8 0.6,-0.7 2.9,-3.6 5.2,-6.5 2.3,-2.8 4.5,-5.2 4.9,-5.2 0.4,0 1.9,1.4 3.3,3.1 z"
id="path54"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 568.5,517.8 c -2.2,2.2 1.3,8.2 4.8,8.2 1.9,0 5.1,-5.5 4.3,-7.5 -0.5,-1.3 -7.9,-1.9 -9.1,-0.7 z"
id="path55"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 522.6,520.5 c -0.8,2.3 1.1,5.5 3.3,5.5 1,0 2.9,-0.9 4.2,-1.9 2.9,-2.3 2,-3.6 -3.2,-4.5 -2.8,-0.5 -3.8,-0.3 -4.3,0.9 z"
id="path56"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 546.2,524.2 c -0.7,0.7 -1.2,2 -1.2,3.1 0,1.4 0.6,1.8 2.7,1.5 3.1,-0.3 4.4,-3.5 2.1,-4.9 -1.7,-1.2 -2.2,-1.1 -3.6,0.3 z"
id="path57"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 580.8,527.1 c -2.4,1.3 -2.3,3.6 0.2,4.9 4.2,2.3 8,-1.6 4.8,-4.8 -1.4,-1.4 -2.7,-1.5 -5,-0.1 z"
id="path58"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 401,494.7 c -7.4,9 -12.3,42.8 -8.6,58.6 2,8.3 2,8.2 0,10.1 -2.7,2.7 -6,1.8 -13.5,-3.9 -11,-8.3 -12.9,-7.4 -12.9,6.1 0,5.2 -0.1,5.4 -3.6,6.9 -2,0.8 -5.2,2.9 -7.1,4.6 -4,3.5 -6.8,3.4 -14.2,-0.7 -2.4,-1.3 -5.1,-2.4 -6.1,-2.4 -3.5,0 -4,6.3 -1,12.5 1.1,2.2 1.9,5.4 2,7.2 0,1.8 0.7,5.3 1.6,7.9 1.4,4 2.1,8.8 2.3,14.2 0.1,0.7 1.3,1.7 2.9,2.3 1.5,0.5 4.4,2.9 6.5,5.3 5.2,6.1 12.2,10.6 16.6,10.6 5,0 5.8,0.8 7.4,7 1.5,5.6 3,9.3 7.6,19 2.4,5.1 2.6,6.5 2.6,19.5 0,7.7 0.4,14.6 0.8,15.2 0.5,0.7 2.1,1.3 3.6,1.3 6.1,0 12.1,-11.4 12.1,-23.3 0,-9.5 0.3,-9.7 12.9,-9.7 11.9,0 12.1,0.1 12.1,6.3 0,7.6 6.3,21.6 10.5,23.5 2.9,1.3 5.5,1.5 7.9,0.6 2.6,-1 2.2,-12.3 -0.5,-16.9 -2.7,-4.5 -2.2,-10.6 1.8,-21 2.4,-6.4 2.6,-8.5 3,-25.9 0.3,-11.5 0.1,-19.7 -0.5,-21 -0.6,-1.1 -1.8,-4.1 -2.7,-6.6 -1.7,-4.5 -9.6,-14.7 -12.1,-15.7 -0.8,-0.3 -3.1,-2.1 -5.2,-4.2 -2.1,-2 -6,-5.4 -8.6,-7.6 -6.6,-5.6 -7.7,-10 -7.6,-32.5 0,-9.6 0.4,-20.2 1,-23.5 1.4,-8.8 1.2,-17.5 -0.6,-22.8 -1.4,-4.1 -1.9,-4.7 -4.4,-4.7 -2.1,0 -3.7,1 -6,3.7 z m 6.5,13.8 c -0.1,6.1 -0.7,17.8 -1.4,26.2 -1.3,13.8 -1.3,15.9 0.3,23.9 1.7,8.3 6.5,19.4 8.9,20.3 2.5,1 18.6,15.4 20.9,18.8 1.5,2.1 4,7.8 5.7,12.7 3.7,10.5 4.3,23.5 1.5,30.2 -2.3,5.4 -5.4,22.4 -5.4,29.3 0,3.2 0.7,8.2 1.6,11 1.4,4.1 1.4,5.3 0.4,6.6 -0.9,1.1 -1.7,1.2 -3.1,0.4 -2.8,-1.5 -4.7,-5.9 -6,-14.1 -0.7,-4.4 -2.1,-8.8 -3.6,-11.1 l -2.4,-3.9 -13,0.3 c -7.7,0.2 -13.3,0.7 -13.9,1.4 -0.5,0.6 -1.4,5.1 -2,10 -1.9,14.5 -2,14.9 -3.9,17.4 -2.7,3.5 -5.6,2.2 -4.6,-2.2 2,-8.8 0.2,-22.2 -4.3,-31.8 -1.6,-3.5 -3.9,-9.5 -5.1,-13.3 -4.1,-13.4 -2.2,-33.8 4,-42.8 1.1,-1.4 1.9,-3 1.9,-3.4 0,-1.4 4.5,-7.4 5.5,-7.4 0.6,0 3.2,-2.3 5.9,-5.1 l 4.8,-5.1 -0.6,-6 c -0.4,-3.3 -1.2,-7.2 -1.8,-8.6 -0.7,-1.6 -1.2,-10.3 -1.2,-21.7 -0.1,-16.6 0.2,-20.2 2.3,-28.5 2.5,-10.5 4.8,-15.2 7.1,-14.8 1.2,0.3 1.5,2.3 1.5,11.3 z M 377.9,564 c 2.4,1.7 5.2,3 6.1,3 1,0 2.3,0.7 3,1.5 0.7,0.8 1.9,1.5 2.6,1.5 1.7,0 5.4,3.5 5.4,5.1 0,0.6 -3.3,4.3 -7.2,8.3 -4,3.9 -8.3,9.2 -9.4,11.6 -6.6,13.7 -6.8,14.4 -7.2,23.8 -0.4,7.7 -0.7,9.4 -2.3,10.3 -2.7,1.4 -7.5,-0.9 -14,-6.6 -6.5,-5.8 -10.7,-13.9 -13.9,-27 -1.2,-5 -2.7,-10 -3.3,-11.3 -1.8,-3.7 0.5,-4.9 4.2,-2.1 4.6,3.4 10.8,3.2 15.7,-0.6 2.1,-1.7 5.5,-4.1 7.4,-5.4 4.5,-3 5,-3.8 5,-8.3 0,-4.4 0.8,-6.8 2.4,-6.8 0.6,0 3.1,1.4 5.5,3 z"
id="path59"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 406.6,599.5 c -1.2,1.3 -1.3,2.1 -0.4,4 1.9,4.2 6.4,2.2 4.8,-2.1 -1.2,-3.1 -2.6,-3.7 -4.4,-1.9 z"
id="path60"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 380.7,574.6 c -1.3,1.3 -0.7,4.4 0.7,4.4 0.8,0 1.7,-0.4 2,-0.9 0.7,-1.1 -1.8,-4.4 -2.7,-3.5 z"
id="path61"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 365.8,587.7 c -2.9,0.7 -2.3,5.1 0.8,5.9 3.1,0.8 4.7,-0.6 4.2,-3.7 -0.3,-2.4 -1.9,-3.1 -5,-2.2 z"
id="path62"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 349,600 c 0,2.5 0.4,3.1 1.8,2.8 1.1,-0.2 1.7,-1.2 1.7,-2.8 0,-1.6 -0.6,-2.6 -1.7,-2.8 -1.4,-0.3 -1.8,0.3 -1.8,2.8 z"
id="path63"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 849.2,521.2 c -1.1,1.3 -2.4,3.6 -2.8,5.2 -0.4,1.6 -1.8,4.5 -3.1,6.4 -1.2,1.9 -2.3,3.8 -2.3,4.2 0,2.7 -12.9,1.2 -22.5,-2.7 -2.9,-1.1 -7.5,-1.6 -16.5,-1.6 -12,0 -12.8,0.1 -19.2,3.2 -9.2,4.3 -16.4,11.6 -21.1,21.3 -2,4.2 -3.7,8.5 -3.7,9.6 0,1.1 -0.7,3.9 -1.5,6.2 -2,5.6 -1.9,10.8 0.4,21.1 2.6,11.9 5.2,17.1 11.6,23.2 6.5,6.3 16.3,10.5 27,11.9 3.9,0.4 9.7,1.2 13,1.7 3.3,0.5 9.6,1 14,1 4.4,0.1 11.8,0.8 16.5,1.6 8.9,1.6 15.4,1.2 19.9,-1.1 3.3,-1.8 5.3,-1.8 7.7,0.1 3.66122,3.39212 5.85278,2.92751 5.85278,0.22751 0,-1.2 -3.35278,-3.82751 -4.45278,-4.52751 -1.4,-0.8 -2,-2.3 -2,-4.7 0,-2.2 0.44665,-4.81655 2,-5.5 4.85334,-2.1354 4.1247,-5.06573 1.8247,-5.86573 -1,-0.4 -4.6247,-2.03427 -6.3247,-3.83427 -3.9,-4.5 -10.6,-5.8 -12.5,-2.4 -0.6,1.2 -1,4.6 -0.8,7.4 l 0.3,5.2 -3.9,-0.2 c -2.2,-0.2 -4.4,-0.7 -5,-1.3 -0.5,-0.5 -2,-1 -3.2,-1 -4,0 -4.5,-2 -2.8,-11.1 2.3,-12.2 0.7,-25.6 -3.7,-30 -1,-1 -1.9,-2.3 -1.9,-2.7 0,-1.9 -10,-10.3 -15.2,-12.6 -4.4,-2 -7.3,-2.6 -13,-2.6 -7.6,0 -9.8,0.9 -9.8,4.2 0,1.9 3.4,2.5 4.5,0.8 1,-1.5 8,-1.2 12.2,0.6 12.3,5.2 15.7,8.3 20.8,19 3.5,7.3 3.7,8.1 3.2,15.3 -0.3,4.9 -1.2,9 -2.5,11.4 -3.5,6.5 -4.1,6.9 -9.6,6.4 -10.9,-1 -24.3,-3.9 -28.5,-6.3 -6.2,-3.4 -8.6,-1.1 -3.6,3.6 4,3.8 26.9,7.7 37.5,6.4 3.1,-0.4 5.9,-0.1 8.4,1 2.4,1 6.8,1.6 11.7,1.6 8.7,0 9.2,-0.4 8.7,-7.4 -0.4,-6.3 3.8,-7.2 6.7,-1.5 3.2,6 0.4,14.8 -5.2,17 -4.4,1.6 -57.1,-2.9 -66.3,-5.8 -14.9,-4.6 -22.3,-11.1 -26.9,-24 -2.1,-5.5 -2.3,-7.6 -1.9,-19 0.5,-14.6 1.9,-18.7 9.4,-28.3 7.9,-10 12,-12.4 23.5,-14.1 7.7,-1.1 9.6,-1 20.9,1.1 6.9,1.2 13,2.7 13.6,3.2 0.7,0.5 3.4,1.1 6.1,1.4 3.8,0.5 5.2,0.3 6.2,-1 0.7,-0.9 1.9,-1.6 2.6,-1.6 1.1,0 4.3,-6.1 5.9,-11.7 0.4,-1.1 1.4,-2.7 2.2,-3.4 1.4,-1.1 2.1,-0.7 5.3,2.7 2,2.2 4.4,5.5 5.3,7.2 1.3,2.5 2.4,3.2 4.8,3.2 2.6,0 8.55889,1.16838 9.0988,-1.57417 C 877.01728,532.76023 869.5,533.1 867.1,532.7 c -1.6,-0.4 -3.2,-1.4 -3.5,-2.4 -0.8,-2.6 -9.2,-11.3 -10.9,-11.3 -0.7,0 -2.3,1 -3.5,2.2 z"
id="path64"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1"
sodipodi:nodetypes="cscscsccsccccsccccscssccccccsscsscssccccccccccsccccccccccccsccccssccsc" /><path
d="m 32,531.7 c -0.8,0.3 -2.8,1.8 -4.5,3.3 -1.6,1.6 -3.9,3 -5,3.1 -3.1,0.4 -16.7,-2.2 -19.7,-3.7 -2.8,-1.4 -4.8657393,-1.6614 -3.9269514,1.50225 L -0.53033009,538.9708 6.3,540.3 c 3.4,0.3 7.7,1.3 9.5,2.2 3.9,2 8.1,1.3 12,-1.9 1.5,-1.3 4.1,-3.1 5.9,-4 4.6,-2.3 6.3,-0.3 6.3,7.4 0,4.7 0.5,6.4 2.4,8.7 1.3,1.5 2.9,4.6 3.7,6.8 1.6,4.8 1.7,20.2 0.2,23 -0.6,1.1 -5.2,6 -10.2,11 -13.1,12.9 -14.8,17.7 -8.1,23.2 2.1,1.8 4.3,3.3 4.8,3.3 0.5,0 1.7,0.9 2.7,2 1,1.1 4.7,3.4 8.2,5 4.6,2.2 6.3,3.5 6.3,5 0,2.8 -3.8,2.6 -8.4,-0.5 -2.1,-1.4 -7.2,-4.4 -11.4,-6.7 -4.2,-2.2 -9.4,-5.2 -11.4,-6.4 -2,-1.3 -5.6,-2.6 -8,-3 -2.4,-0.3 -5.3,-1.3 -6.5,-2 -3.3,-2.1 -6.3550291,-1.83258 -6.3550291,1.46742 0,2 2.5550291,3.13258 3.5550291,3.13258 0.8,0 1.5,0.4 1.5,0.8 0,1.4 7.4,8.8 15.3,15.1 4.2,3.4 7.7,6.7 7.7,7.2 0,1.7 -3,2.2 -6.3,0.8 -4,-1.6 -16.8,-9.7 -18.4,-11.7 -1.1,-1.4 -2.8909903,-1.46517 -2.8909903,0.73483 0,2.8 7.6909903,9.36517 11.5909903,10.56517 1.2,0.4 4.1,2 6.5,3.6 3,2 5.5,2.9 8.4,2.9 3.5,0 4.1,-0.3 4.7,-2.6 1.2,-4.7 -0.8,-8.7 -6.1,-12.2 -6,-4 -13.5,-10.7 -13.5,-12.1 0,-0.5 0.5,-1.3 1,-1.6 1.1,-0.7 12.7,5.3 26.7,13.7 7.5,4.5 8.7,4.9 13.5,4.6 4.2,-0.2 5.4,-0.7 5.6,-2.1 0.9,-4.5 -0.8,-6.3 -10.2,-11.7 -5.3,-3 -11.4,-7 -13.7,-9 -3.5,-3 -4.1,-4 -3.6,-6.1 0.4,-1.4 1.4,-3.3 2.4,-4.2 1,-0.9 4.5,-4.5 7.8,-7.8 5.6,-5.7 7.4,-7.4 13.3,-13.1 1.7,-1.7 2.2,-2.9 1.7,-4.8 -0.3,-1.4 -0.8,-3.9 -1.1,-5.5 -2.2,-14.7 -2.9,-17.2 -5,-20.1 -1.6,-2.1 -2.9,-5.9 -3.9,-11.9 -0.9,-4.8 -1.8,-9.2 -2.1,-9.6 -0.6,-1 -8,-1.1 -10.4,-0.2 z"
id="path65"
style="display:inline;fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1"
sodipodi:nodetypes="sccssccccsscccccsscscccccssscsccscssccscscccccscccccccs" /><path
d="m 846,557.2 c 0,2.6 4,6.8 6.4,6.8 2.8,0 6.7,-3.5 6,-5.4 -0.3,-0.9 -2,-1.4 -4.2,-1.3 -2,0 -4.7,-0.3 -5.9,-0.8 -1.7,-0.5 -2.3,-0.4 -2.3,0.7 z"
id="path66"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 829.4,559.4 c -1.2,4.8 3.2,8.6 6.4,5.4 1.7,-1.7 1.5,-4.4 -0.4,-6.2 -2.3,-2.4 -5.3,-2 -6,0.8 z"
id="path67"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 4.4,565.4 c -1,2.6 0.4,4.8 2.8,4.4 3.3,-0.4 3.3,-5.2 0,-5.6 -1.3,-0.2 -2.5,0.3 -2.8,1.2 z"
id="path68"
style="display:inline;fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 27.3,564.7 c -2,0.7 -1.5,4.1 0.6,5.3 4.4,2.3 11.1,0.1 11.1,-3.8 0,-1.8 -2,-2.5 -3.6,-1.1 -1.1,0.9 -2,0.9 -4,0 -1.4,-0.6 -2.6,-1.1 -2.7,-1 -0.1,0 -0.8,0.3 -1.4,0.6 z"
id="path69"
style="display:inline;fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 301.3,656.5 c -1.2,0.9 -3.2,3.3 -4.4,5.5 l -2.3,4 h -11.5 c -6.4,0 -13.3,0.5 -15.3,1 -4.2,1.2 -7.8,-0.1 -11.3,-4 -2.4,-2.7 -7.4,-5 -10.9,-5 -2.1,0 -2.7,0.7 -3.5,3.7 -1.6,6.5 -3.4,16.6 -4.1,22.8 -0.3,3.3 -1.2,7 -2,8.2 -1.2,1.8 -1.2,2.8 -0.2,5.6 0.8,2.1 1.2,9.5 1.2,20.5 v 17.1 l -3.1,1.6 c -10.2,5.3 -14.6,13.7 -13.4,25.8 0.9,8.8 3.9,15.3 10.5,23 2.8,3.2 5,6.3 5,6.9 0,0.5 -0.8,2.6 -1.7,4.5 -2.3,5 -0.9,6.8 5.3,6.9 2.7,0.1 6.4,0.5 8.3,0.9 3,0.6 3.8,0.4 5.4,-1.6 1.8,-2.3 2.6,-2.4 14.6,-2.6 6.9,-0.2 17.5,-0.7 23.6,-1.3 l 11,-1 3.8,4.5 c 4.3,5 5.6,5.4 8.2,2.5 1,-1.1 2.5,-2 3.4,-2 2.8,-0.1 5.9,-2.4 8.9,-6.6 1.7,-2.3 5.2,-6.3 7.9,-9 4.4,-4.4 12.6,-15.9 16.1,-22.4 0.7,-1.4 1.9,-3.2 2.6,-4 0.6,-0.8 2.4,-3.8 3.9,-6.6 2.1,-4.1 2.7,-6.5 2.7,-11.7 0,-5.7 -0.3,-6.8 -2.2,-8.1 -1.2,-0.9 -2.6,-1.6 -2.9,-1.6 -1.5,0 -6.9,5.2 -8.4,8 -0.8,1.7 -2.2,4.3 -3.1,6 -1.7,3.3 -5.4,4.2 -5.4,1.2 0,-2.1 -8.7,-11.2 -10.7,-11.2 -5,0 -6.9,-9.4 -3.3,-16 7.3,-13.3 7.4,-13.8 6,-21.9 -0.8,-4.2 -1.3,-9.3 -1.2,-11.4 0.2,-3.5 -0.3,-4.4 -5.1,-9 -4.6,-4.5 -11.7,-14.5 -11.7,-16.5 0,-0.3 -1.2,-2.3 -2.6,-4.4 -2.9,-4.1 -4.8,-4.6 -8.1,-2.3 z m 4.9,6.2 c 0.8,1 2.5,3.8 3.7,6.3 1.3,2.5 4,6.8 6.2,9.7 2.1,2.9 3.9,5.5 3.9,5.9 0,0.4 0.6,1.5 1.3,2.3 1.3,1.5 2.9,7.5 4.3,15.3 0.7,4.5 -0.9,9.9 -5,15.8 l -3.1,4.5 -0.7,19 c -0.5,10.9 -1.5,22.2 -2.4,26.4 -1.3,5.7 -1.5,10.2 -1.1,18.6 0.7,13.2 0.3,15.7 -2.7,15.3 -2.5,-0.4 -3,-2.6 -4.6,-22.4 -0.6,-7.6 -1.3,-14.1 -1.5,-14.3 -0.2,-0.1 -0.9,0 -1.6,0.4 -0.9,0.6 -1,4.4 -0.6,14.6 0.3,8.3 0.1,14.1 -0.4,14.5 -1.7,1 -41.8,1.5 -43.6,0.5 -1.5,-0.8 -1.7,-2.5 -1.4,-13.8 0.3,-14.8 -0.3,-18.3 -3.1,-18.3 -1.9,0 -1.9,0.7 -1.7,17.2 0.3,18.2 -0.2,22.1 -2.9,21.6 -2.2,-0.4 -3.1,-5.3 -3.1,-16.3 0,-5 -0.4,-10.3 -1,-11.8 -0.6,-1.6 -1.1,-6.3 -1.1,-10.5 0,-4.2 -0.7,-13.3 -1.5,-20.2 -1,-8.3 -1.2,-15.4 -0.7,-21 0.4,-4.7 0.6,-9.2 0.4,-10 -0.3,-2 1.8,-43.9 2.4,-46.8 0.7,-3.7 4.4,-2.7 10.8,3 4.7,4.2 6.4,5.1 9.2,5 1.9,-0.1 3.9,-0.4 4.4,-0.7 0.4,-0.3 6.3,-0.5 13.1,-0.6 h 12.3 l 3.3,-3.7 c 1.8,-1.9 3.5,-4.4 3.9,-5.4 0.7,-2.3 2.7,-2.3 4.6,-0.1 z m 49.8,81.8 c 0,3.4 -7.4,18.8 -10.9,23 -1,1.1 -2.7,3.7 -3.9,5.8 -1.2,2 -2.6,4.3 -3.2,5 -0.5,0.6 -2.5,3.2 -4.3,5.7 -3.6,4.8 -6.7,6.4 -6.7,3.3 0,-1 1.8,-4.9 3.9,-8.6 2.2,-3.7 4.3,-7.7 4.6,-8.9 0.4,-1.3 1.3,-3 2,-3.8 0.8,-0.8 2.3,-3.5 3.5,-6 5.1,-10.9 12.2,-20.6 14.2,-19.4 0.4,0.3 0.8,2 0.8,3.9 z m -116.6,7 c 0.9,3.8 1.6,9.9 1.7,13.5 0,3.6 0.4,9.9 0.8,14 0.8,7.3 0.1,12 -1.7,12 -1.5,0 -9,-10.7 -11.1,-15.8 -1.1,-2.6 -2.8,-7.3 -3.7,-10.4 -1.7,-5.7 -1.7,-5.8 0.7,-12 1.9,-4.8 3.4,-6.9 6.4,-9 4.6,-3.1 4.4,-3.4 6.9,7.7 z m 90.4,-6.1 4.6,4.4 v 7.4 c 0,6.8 -0.4,8 -5.2,17.4 -2.9,5.6 -5.2,10.8 -5.2,11.7 0,2.2 -5.1,6 -6.4,4.7 -1.3,-1.3 1,-30.2 3.1,-38.3 0.8,-3.2 1.2,-6.6 0.9,-7.4 -0.7,-1.6 1,-4.3 2.6,-4.3 0.5,0 3,2 5.6,4.4 z M 241.5,799 c 0.3,0.6 -0.1,1.3 -1,1.6 -1.9,0.8 -3,0 -2,-1.5 0.8,-1.4 2.2,-1.4 3,-0.1 z"
id="path70"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 302.8,683.5 c -1.5,3.4 0.2,5.2 4.9,5.1 3.2,-0.2 3.9,-0.6 4.1,-2.6 0.7,-4.9 -6.9,-7 -9,-2.5 z"
id="path71"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 252.3,685.3 c -2.8,3.1 -3.6,6.7 -1.6,7.4 0.9,0.4 2.1,-0.1 2.9,-1.2 1.3,-1.8 1.4,-1.8 3.3,0 2.2,1.9 5.1,1.4 5.1,-0.8 0,-1.2 -2.1,-3.5 -6.1,-6.5 -1.3,-1 -2,-0.8 -3.6,1.1 z"
id="path72"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 282.4,688.4 c -0.9,2.2 0.2,4.6 2.1,4.6 1.2,0 1.5,-0.8 1.3,-2.7 -0.3,-3 -2.5,-4.2 -3.4,-1.9 z"
id="path73"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 49,668.2 c -1.7,1.9 -7.3,11 -8.7,13.8 -0.6,1.4 -1.9,5.4 -2.8,9 -2.6,10 -7.1,23.1 -10,29.6 -4.7,10.4 -7.3,17.7 -9.5,26.9 -1.2,4.9 -2.6,11 -3.3,13.5 -0.7,2.7 -1.2,12.5 -1.2,24.5 v 20 l 3.2,6 c 4.3,8.2 21.8,25.5 30.4,30.2 5.7,3 7.1,3.3 12.9,3.1 l 6.5,-0.3 -0.1,-4 c -0.1,-3.4 -0.9,-4.6 -4.9,-8.2 -2.5,-2.4 -9.1,-7.2 -14.5,-10.8 -15.1,-10 -14.8,-12.1 1.5,-11.1 6.1,0.4 11.9,0.4 12.8,0.1 0.8,-0.4 2.8,-2.4 4.3,-4.6 1.4,-2.1 3.2,-3.9 3.8,-3.9 0.6,0.1 2,1.5 3.1,3.3 2.9,4.6 6.2,4.8 8,0.4 l 1.4,-3.4 10.8,-0.6 c 11.5,-0.6 11.5,-0.6 14.8,4.7 1.6,2.5 3.4,0.5 5,-5.3 1,-4 1.1,-5.9 0.3,-8 -1.6,-3.8 -1.9,-44.2 -0.3,-50.1 1.6,-6 2.6,-7.7 7.9,-13.9 4.3,-5.1 5.1,-7.2 7.5,-18.9 1.5,-7.5 1.5,-7.7 -2.3,-10.3 -3.9,-2.6 -9.8,-13.4 -11.5,-20.9 -3.2,-14.2 -6.3,-15.4 -15.6,-5.8 l -6,6.2 -12.7,0.4 -12.7,0.4 -2.8,-2.9 c -1.5,-1.7 -4.1,-4.7 -5.8,-6.7 -3.1,-3.8 -7.3,-4.8 -9.5,-2.4 z m 10.6,11 c 3,5.7 5.5,6.1 27.2,4 9.1,-0.8 9.4,-0.9 13.5,-5 2.4,-2.3 5,-4.2 5.9,-4.2 1.6,0 5.8,9.8 5.8,13.6 0,1.1 1.6,4.3 3.6,7.2 6.9,10.4 8.4,20.1 4,27.1 -1.4,2.2 -2.9,4.1 -3.3,4.2 -1.7,0.8 -7,9.6 -8.1,13.5 -1.5,5.1 -1.6,35.4 -0.2,44 1,6.4 0.2,13.5 -1.5,13.5 -0.6,0 -1.4,-4.1 -1.9,-9.1 -0.4,-4.9 -1.2,-9 -1.7,-9 -0.5,0 -1.2,-1.8 -1.5,-4 -0.8,-4.6 -3.4,-5.6 -3.3,-1.3 0.1,3.4 1.4,11.6 2.7,16.5 1.5,5.6 0.2,6.5 -10.4,7.3 l -9.2,0.7 -0.6,-3.9 c -0.3,-2.1 -0.5,-7.5 -0.4,-12 0,-8.5 -0.4,-10.3 -2.7,-10.3 -2.3,0 -2.9,4.9 -1.4,11.5 1.7,7.5 1.9,19.7 0.2,20.2 -2,0.7 -3.1,-2.9 -5.1,-16.3 C 69,773.1 68.4,771 66.4,771 c -1.9,0 -1.8,6.2 0.1,13.7 1.9,7.4 2,14.2 0.1,14.9 -0.8,0.3 -3.3,0.2 -5.7,-0.3 l -4.2,-0.8 -0.1,-9.5 c -0.1,-8.2 -0.5,-10.2 -2.8,-14.6 -4.5,-8.6 -14.2,-13.9 -18.2,-9.9 -2.2,2.3 -1.4,3.1 4.3,4.5 3.2,0.8 5.9,2.3 8.2,4.6 l 3.4,3.5 0.3,11.8 0.4,11.8 3.9,1.2 c 7.5,2.3 3.4,5.3 -6.2,4.6 C 28,805 26,805 26,807 c 0,2.1 18.8,18.9 24.2,21.6 4,2 10.8,8.3 10.8,10 0,2 -3.7,2.6 -7.8,1.3 -4.8,-1.5 -12,-7.1 -20.5,-15.6 C 22.6,814 20.1,810.1 18.8,802.8 c -1.5,-9.1 -0.2,-42.6 1.8,-48.1 0.9,-2.3 2.8,-8.5 4.2,-13.7 1.4,-5.2 2.9,-9.7 3.2,-10 1.3,-0.9 9,-19.9 10.1,-24.7 0.6,-2.7 2.2,-8.2 3.6,-12.3 1.3,-4.1 2.9,-9.2 3.5,-11.3 0.6,-2.1 2.6,-5.5 4.5,-7.4 l 3.3,-3.6 2.6,2.4 c 1.4,1.3 3.2,3.6 4,5.1 z"
id="path74"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 58.4,699.4 c -1,2.7 0.1,5 2.8,6 3.5,1.2 5.8,-0.6 5.8,-4.5 0,-3 -1.7,-3.8 -3.7,-1.8 -0.8,0.8 -1.3,0.8 -1.7,0 -1,-1.5 -2.6,-1.4 -3.2,0.3 z"
id="path75"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 106.7,701.7 c -0.9,0.9 -0.8,3.5 0.2,5.1 1.3,2.1 6.1,-0.6 6.1,-3.5 0,-1.9 -0.5,-2.3 -2.8,-2.3 -1.6,0 -3.2,0.3 -3.5,0.7 z"
id="path76"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 85.7,702.7 c -1.3,1.2 -0.7,5 0.9,6.3 1.2,1.1 1.8,1 3.1,-0.3 1.4,-1.3 1.4,-2.1 0.5,-4.2 -1.1,-2.4 -3.1,-3.2 -4.5,-1.8 z"
id="path77"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 50.3,708 c -3.2,1.3 -3,4.8 0.3,6.1 5.3,2 8.6,-1.8 4.8,-5.5 -1.7,-1.8 -2,-1.8 -5.1,-0.6 z"
id="path78"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 695.5,710 c -4,2.6 -8.3,7.9 -12.4,15.3 -14.2,26.2 -16.5,29.9 -19.9,32.5 -3.6,2.7 -5.5,2.7 -18.7,-0.2 -8.5,-1.9 -18.1,-2.7 -38.3,-2.9 -15,-0.2 -15.4,-0.2 -17.3,-2.7 -1.1,-1.4 -2.7,-4.1 -3.5,-6 -3.7,-9 -7,-15 -8.3,-15 -1.7,0 -7.5,6.3 -10,11 -1,1.9 -3,4.7 -4.4,6.2 -2.5,2.7 -3.1,2.8 -11.5,2.8 h -9 l -5.8,-4.5 c -3.1,-2.5 -6.3,-4.5 -7,-4.5 -3.5,0 -6.4,14.7 -6.4,32.7 0,17.6 1.2,20.5 13.9,32.6 13,12.2 14.3,16.1 11.2,34.7 -1.4,8.3 -1.4,15.7 -0.1,16.5 2.1,1.3 7,-0.7 8.5,-3.4 1.9,-3.9 5.2,-13.6 6.5,-19.6 1,-4.4 2.3,-6.4 4.1,-6.5 1.4,0 1.7,7.6 0.9,18.1 -0.9,11.4 -0.3,15.9 2.4,15.9 2.6,0 7.2,-4.8 8.4,-8.7 3.1,-10.6 4.1,-14.2 5.2,-19 1.3,-5.9 4.5,-8.4 9,-7.2 3.9,1.1 20,0.9 25,-0.2 8,-1.9 18.7,-2.3 19.6,-0.7 0.5,0.7 1,7.4 1.1,14.8 0.1,7.4 0.2,14 0.2,14.6 0.1,1.5 4.7,2.7 7.2,1.9 2.6,-0.8 5,-5.7 5.5,-11.1 0.2,-2.5 1,-5.7 1.8,-7.2 0.7,-1.5 1.6,-4.3 2,-6.4 0.7,-3.9 3.3,-5.8 4.3,-3.2 0.3,0.9 0.3,7.1 0,13.8 -0.6,12.4 -0.5,13.1 2.5,15.4 1.3,1.1 2.2,1.1 5,-0.1 2.7,-1.1 3.7,-2.5 5.6,-7.3 1.3,-3.2 2.7,-8.8 3.2,-12.4 0.5,-3.6 1.5,-8.1 2.1,-10 1.2,-3.8 1.9,-7 4.4,-19.5 1.7,-8.9 1.3,-14.6 -1.6,-23.9 -3.2,-10.1 -2.7,-13.3 4.4,-24.7 1.2,-1.9 3.1,-5 4.2,-6.8 1.1,-1.9 3,-4.8 4.2,-6.7 2.7,-4.2 9.6,-19.5 11.3,-25.1 1.6,-5.3 0.7,-11.3 -2.1,-13.6 -2.7,-2.2 -3.7,-2.1 -7.4,0.3 z m 4.8,6.7 c 0.3,1.6 0.2,4.6 -0.2,6.8 -0.9,4.8 -8.4,20.5 -12.2,25.5 -1.4,1.9 -3.7,5.5 -4.9,8 -1.2,2.5 -3.2,5.8 -4.4,7.5 -1.1,1.6 -2.9,5.2 -3.8,7.8 -1.7,4.8 -1.7,5 1.4,17 l 3.1,12.1 -1.7,9.5 c -1,5.3 -2.3,11.4 -3.1,13.6 -0.7,2.2 -2.1,8.5 -3,14 -2.4,15.4 -2.5,15.5 -4.5,15.5 -2.7,0 -3.2,-2.6 -2.8,-14.3 0.3,-11.5 -0.9,-15.7 -4.6,-15.7 -4.8,0.1 -8.3,6.6 -10.1,19 -0.7,4.7 -2.9,9 -4.5,9 -0.6,0 -1,-5.3 -1,-13.3 0,-9 -0.4,-13.7 -1.2,-14.5 -0.9,-0.9 -8.7,-1.2 -28.9,-1.2 h -27.7 l -2.5,2.9 c -1.6,1.9 -3.3,6.6 -5.1,13.6 -3.1,12.3 -5,17.5 -6.6,17.5 -1.9,0 -2.1,-2.5 -0.9,-9.1 2.2,-12.2 2.2,-15.3 0,-19.8 -2.4,-4.6 -3.7,-5 -7.3,-2.2 -2.4,1.9 -4.4,7.2 -6.8,17.6 -0.6,2.7 -1.8,6.1 -2.7,7.4 -2.5,3.9 -3.4,0.5 -2.2,-8 2,-14.1 2.2,-21.2 0.7,-24.7 -1.4,-3.1 -9.1,-11.7 -17.7,-19.5 -3.9,-3.6 -7.1,-9.2 -7.1,-12.3 0,-0.8 1.1,-1.9 2.5,-2.4 3.3,-1.3 3.2,-3.3 -0.6,-6.3 l -3.1,-2.4 1.1,-7.9 c 0.6,-4.3 1.2,-10.1 1.4,-12.9 0.3,-6.7 2.5,-7.1 8.2,-1.5 l 4.3,4.2 9.8,-1.1 c 13.7,-1.6 14.5,-2.1 20,-12.9 0.9,-1.9 2.4,-3.6 3.2,-3.9 2,-0.8 4.8,3.1 6.9,9.6 2.7,8.4 4.5,9.1 23.4,9.1 10.9,0 17,0.4 20.5,1.5 2.7,0.8 6.3,1.5 7.9,1.5 1.7,0 7.7,0.9 13.5,2.1 8.9,1.7 11.1,1.8 14.3,0.8 3.9,-1.3 10.7,-7.1 10.7,-9.3 0,-0.6 1.2,-2.8 2.6,-4.7 1.4,-1.9 4.5,-7.2 6.9,-11.9 4,-7.8 8.9,-16.1 13,-21.8 2.2,-3.1 5.1,-2.9 5.8,0.5 z"
id="path79"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 575,762 c 0,1.5 0.8,2.2 2.9,2.6 2.1,0.4 3.1,1.3 3.3,2.8 0.4,3 5.8,3.7 5.8,0.7 0,-1.4 -0.9,-2.3 -2.7,-2.7 -1.5,-0.4 -3.7,-1.8 -4.9,-3.1 -2.6,-2.8 -4.4,-3 -4.4,-0.3 z"
id="path80"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 555.1,769.4 c -1.6,2 0.3,5.6 2.9,5.6 2.6,0 4.5,-3.6 2.9,-5.6 -0.6,-0.8 -1.9,-1.4 -2.9,-1.4 -1,0 -2.3,0.6 -2.9,1.4 z"
id="path81"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 533.2,771.7 c 0.5,2.6 3.8,2.9 3.8,0.4 0,-1.5 -0.6,-2.1 -2.1,-2.1 -1.4,0 -1.9,0.5 -1.7,1.7 z"
id="path82"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><g
id="g1"
style="display:inline"><path
d="m 839.5,862.7 c -1.6,0.9 -4.2,2.9 -5.7,4.5 l -2.6,2.8 h 2.5 c 1.4,0 3.9,-1 5.5,-2.2 l 3.1,-2.1 1.8,2.1 c 0.9,1.2 2.5,2.2 3.4,2.2 1.4,0 1.6,-0.6 1,-2.8 -0.7,-3.1 -3.3,-6.2 -4.9,-6.2 -0.6,0.1 -2.4,0.8 -4.1,1.7 z"
id="path89"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1"
inkscape:label="path89" /><path
d="m 783.7,862.7 c -0.8,1 -1.9,3 -2.2,4.5 -0.6,2.4 -0.4,2.8 1.4,2.8 1.1,0 2.5,-0.9 3.1,-2 1.4,-2.6 4.2,-2.6 6.5,0 1,1.1 3.1,2 4.8,2 h 2.9 l -4.9,-4.5 c -5.2,-4.8 -8.9,-5.7 -11.6,-2.8 z"
id="path88"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g479"
inkscape:label="fish"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="translate(252.06731,446.07601)"><path
d="m 52.769736,-42.117156 c -4.967114,4.169731 -4.967114,4.811228 -0.584367,14.433684 5.843664,12.829942 10.518595,15.716679 14.024793,8.980959 4.382747,-8.339462 9.057678,-6.094222 7.012396,3.528234 -1.753099,8.0187143 -1.460916,8.3394623 2.045282,3.207486 5.55148,-7.697965 8.765495,-6.73572 6.42803,1.603742 -1.753099,7.0564683 -1.460916,7.0564683 1.753099,0.9622463 3.506198,-5.7734743 3.798381,-5.7734743 5.55148,-0.320749 1.460916,5.131977 2.045282,4.811228 5.259297,-2.5659883 2.921832,-7.056468 2.629649,-7.697965 -1.460916,-6.094222 -3.798381,1.603742 -4.382747,0.641497 -2.921831,-3.848983 1.753099,-4.490479 1.460915,-5.131976 -1.753099,-2.24524 -5.551481,5.131977 -8.765496,0.962246 -4.382748,-5.452725 2.921832,-4.169731 2.921832,-4.490479 -0.584366,-1.282994 -7.012396,7.056468 -12.563877,5.131977 -8.473312,-2.565988 5.55148,-11.546948 -10.518595,-17.320421 -21.913738,-8.339462 z m 16.946624,0.962245 c 4.674931,1.924492 4.090564,4.169731 -3.798381,16.999673 -2.337466,3.848983 -3.506199,3.848983 -7.30458,0.641497 -10.810777,-9.943205 -2.045282,-23.093895 11.102961,-17.64117 z"
id="path48-2"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.306133" /><path
d="m 60.658681,-34.419191 c 1.168733,0.962245 2.921832,0.962245 4.090565,0 0.87655,-1.282994 0,-2.24524 -2.045282,-2.24524 -2.045283,0 -2.921832,0.962246 -2.045283,2.24524 z"
id="path49-0"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.306133" /><path
d="m 99.22686,-11.646045 c -4.674931,3.8489833 -4.674931,10.2639533 0,8.3394623 3.21401,-1.603743 4.38275,0.320749 4.38275,5.773474 0,14.4336837 7.59676,21.8108987 11.39514,10.9054497 1.7531,-4.8112277 6.13585,-11.5469467 9.64205,-14.7544327 8.18112,-7.056467 -0.58437,-11.5469473 -15.48571,-8.018713 -6.42803,1.603743 -8.47331,1.282994 -7.30458,-1.2829943 2.33746,-4.169731 1.46091,-4.49048 -2.62965,-0.962246 z m 21.91374,7.0564683 c 0,1.603743 -1.16874,3.207485 -2.92183,3.207485 -1.46092,0 -3.5062,3.528234 -4.67494,8.018714 -0.87654,4.4904797 -2.92183,7.0564677 -4.38274,6.0942217 -3.79838,-2.565988 -3.21402,-16.6789237 1.16873,-18.2826667 6.13585,-2.886736 10.81078,-2.245239 10.81078,0.962246 z"
id="path68-2"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.306133" /></g><g
id="g483"
inkscape:label="star1"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="matrix(3.9452232,0,0,4.2240352,-552.25879,-827.74646)"><path
d="m 178.53387,240.94477 c -0.3,1.2 -1.6,2.4 -2.7,2.7 -2.1,0.6 -2.1,0.7 -0.4,2 0.9,0.7 1.7,2.1 1.7,3 0,1.8 1.5,2.2 2.5,0.6 0.4,-0.6 1.5,-0.8 2.6,-0.5 1.5,0.5 1.9,0.1 1.9,-1.7 0,-1.3 0.5,-2.9 1.2,-3.6 0.9,-0.9 0.8,-1.2 -0.7,-1.2 -1,0 -2.7,-0.8 -3.7,-1.7 -1.8,-1.6 -1.9,-1.6 -2.4,0.4 z m 4.3,2.3 c 1.3,0 1.4,0.3 0.6,1.1 -0.6,0.6 -0.9,1.8 -0.6,2.7 0.4,1 0.2,1.3 -0.6,0.8 -0.6,-0.4 -1.7,-0.3 -2.4,0.2 -0.9,0.8 -1.5,0.4 -2.3,-1.3 -0.9,-1.9 -0.9,-2.5 0.2,-2.9 0.8,-0.3 1.4,-1.3 1.4,-2.3 0.1,-1.7 0.1,-1.7 1.1,-0.1 0.5,1 1.7,1.8 2.6,1.8 z"
id="path175"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g480"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
inkscape:label="paw1"
transform="matrix(4.9319668,0,0,5.7559584,-126.28228,122.10017)"><path
d="m 166.13387,74.74477 c 0,0.8 0.5,1.5 1,1.5 0.6,0 1,-0.7 1,-1.5 0,-0.8 -0.4,-1.5 -1,-1.5 -0.5,0 -1,0.7 -1,1.5 z"
id="path46-3"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 162.13387,75.64477 c 0,0.9 0.5,1.6 1,1.6 0.6,0 1,-0.4 1,-0.9 0,-0.6 -0.4,-1.3 -1,-1.6 -0.5,-0.3 -1,0.1 -1,0.9 z"
id="path47-7"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 170.13387,76.24477 c 0,0.7 0.3,1 0.7,0.7 0.3,-0.4 0.3,-1 0,-1.4 -0.4,-0.3 -0.7,0 -0.7,0.7 z"
id="path50-5"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 160.13387,79.24477 c 0,0.5 0.7,1 1.6,1 0.8,0 1.2,-0.5 0.9,-1 -0.3,-0.6 -1,-1 -1.6,-1 -0.5,0 -0.9,0.4 -0.9,1 z"
id="path59-9"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 164.83387,78.94477 c -2.1,2.1 -0.7,5.5 2.1,4.8 3.5,-0.8 4.6,-2 3.9,-3.9 -0.6,-1.6 -4.8,-2.2 -6,-0.9 z"
id="path60-2"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g484"
inkscape:label="paw2"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="matrix(4.040358,0,0,4.774807,-159.45488,-523.8208)"><path
d="m 77.733867,236.14477 c -0.4,0.7 -0.5,1.5 -0.2,1.8 0.9,0.9 1.9,-0.4 1.4,-1.8 -0.4,-1.1 -0.6,-1.1 -1.2,0 z"
id="path169"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 73.133867,238.24477 c 0,0.5 0.5,1 1,1 0.6,0 1,-0.5 1,-1 0,-0.6 -0.4,-1 -1,-1 -0.5,0 -1,0.4 -1,1 z"
id="path171"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 81.133867,238.84477 c 0,0.8 0.5,1.2 1,0.9 0.6,-0.3 1,-1 1,-1.6 0,-0.5 -0.4,-0.9 -1,-0.9 -0.5,0 -1,0.7 -1,1.6 z"
id="path172"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 74.733867,241.84477 c -2.1,2 -2.1,2 1.5,3.8 3.5,1.7 4.9,1.4 4.9,-1.3 0,-3.7 -3.7,-5.2 -6.4,-2.5 z"
id="path176"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 82.133867,242.24477 c 0,0.5 0.4,1 0.9,1 0.6,0 1.3,-0.5 1.6,-1 0.3,-0.6 -0.1,-1 -0.9,-1 -0.9,0 -1.6,0.4 -1.6,1 z"
id="path178"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g481"
inkscape:label="klubok1"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="matrix(2.8077392,0,0,2.729068,127.57199,210.94513)"><path
d="m 220.93387,182.14477 c -7,6.3 -2.9,16.1 6.7,16.1 5.1,0 9.1,1.6 10,3.9 0.6,1.5 0.1,2.1 -5.6,6.1 -2.7,2 -1.9,5.2 1.7,6.8 4,1.9 5.3,1.4 1.4,-0.6 -4.9,-2.4 -5,-4.7 -0.3,-6.9 6.1,-2.9 5.8,-5.5 -1.5,-9.9 -0.2,-0.1 0.7,-1.8 1.9,-3.8 5.7,-9.8 -5.9,-19.2 -14.3,-11.7 z m 5.8,1.9 c -1.7,1.6 -1.8,1.6 -0.5,-0.5 0.7,-1.2 1.5,-2 1.8,-1.7 0.3,0.2 -0.3,1.2 -1.3,2.2 z m 4.4,-2.4 c 0,0.3 -0.5,0.8 -1.2,1.2 -0.7,0.4 -0.8,0.3 -0.4,-0.4 0.7,-1.2 1.6,-1.6 1.6,-0.8 z m -6.6,4.8 c 0.8,3.4 5.5,9 6.8,8.1 0.7,-0.3 0.8,-0.1 0.4,0.5 -0.4,0.6 -1.4,0.8 -2.3,0.5 -1.2,-0.5 -1.4,-0.3 -0.8,0.8 0.4,0.8 -0.6,0.1 -2.3,-1.5 -1.8,-1.6 -3.4,-3.7 -3.7,-4.7 -0.3,-1 -1.1,-1.7 -1.8,-1.5 -0.7,0.1 -0.9,0.1 -0.4,-0.1 0.4,-0.3 0.7,-1.4 0.7,-2.6 0,-1.7 0.5,-1.2 2,1.8 1.1,2.2 1.7,3.1 1.4,2 -0.8,-2.2 -1.6,-8.6 -1,-7 0.2,0.5 0.7,2.2 1,3.7 z m 7.8,-3 c -1.9,1.9 -1.4,3.2 0.6,1.7 1.4,-1.1 1.5,-1.1 0.5,0.1 -1.8,2 -1.6,3.3 0.3,2.6 1.4,-0.5 1.5,-0.4 0.4,0.9 -1,1.2 -1,1.5 0,1.5 0.8,0 1,0.3 0.6,0.7 -0.4,0.5 -1.4,0 -2.1,-1 -0.8,-1.2 -1.5,-1.4 -2,-0.8 -0.3,0.6 -1.1,1.1 -1.7,1.1 -0.6,0 -0.3,-1 0.8,-2.3 1,-1.2 1.2,-1.6 0.4,-1 -1.9,1.7 -3.5,1.7 -2.1,0 0.6,-0.8 0.7,-1.8 0.3,-2.2 -0.4,-0.5 -0.2,-0.5 0.4,-0.2 0.7,0.4 1.7,0 2.4,-0.8 0.7,-0.8 1.5,-1.5 1.8,-1.5 0.3,0 0.1,0.5 -0.6,1.2 z m -11.5,7.5 c -0.4,0.3 -1,0.3 -1.4,0 -0.3,-0.4 0,-0.7 0.7,-0.7 0.7,0 1,0.3 0.7,0.7 z m 0,2 c -0.4,0.3 -1,0.3 -1.4,0 -0.3,-0.4 0,-0.7 0.7,-0.7 0.7,0 1,0.3 0.7,0.7 z m 13,1 c -0.4,0.3 -0.7,0 -0.7,-0.7 0,-0.7 0.3,-1 0.7,-0.7 0.3,0.4 0.3,1 0,1.4 z m -11.2,1.3 c 0.3,0.5 0.4,1 0.1,1 -0.3,0 -0.8,-0.5 -1.1,-1 -0.3,-0.6 -0.4,-1 -0.1,-1 0.3,0 0.8,0.4 1.1,1 z"
id="path131"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 225.13387,192.04477 c 0,0.5 0.7,1.5 1.5,2.2 0.8,0.7 1.5,0.8 1.5,0.2 0,-0.5 -0.7,-1.5 -1.5,-2.2 -0.8,-0.7 -1.5,-0.8 -1.5,-0.2 z"
id="path132"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g483-8"
inkscape:label="star1"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="matrix(3.9452232,0,0,4.2240352,-279.64123,-226.23287)"><path
d="m 178.53387,240.94477 c -0.3,1.2 -1.6,2.4 -2.7,2.7 -2.1,0.6 -2.1,0.7 -0.4,2 0.9,0.7 1.7,2.1 1.7,3 0,1.8 1.5,2.2 2.5,0.6 0.4,-0.6 1.5,-0.8 2.6,-0.5 1.5,0.5 1.9,0.1 1.9,-1.7 0,-1.3 0.5,-2.9 1.2,-3.6 0.9,-0.9 0.8,-1.2 -0.7,-1.2 -1,0 -2.7,-0.8 -3.7,-1.7 -1.8,-1.6 -1.9,-1.6 -2.4,0.4 z m 4.3,2.3 c 1.3,0 1.4,0.3 0.6,1.1 -0.6,0.6 -0.9,1.8 -0.6,2.7 0.4,1 0.2,1.3 -0.6,0.8 -0.6,-0.4 -1.7,-0.3 -2.4,0.2 -0.9,0.8 -1.5,0.4 -2.3,-1.3 -0.9,-1.9 -0.9,-2.5 0.2,-2.9 0.8,-0.3 1.4,-1.3 1.4,-2.3 0.1,-1.7 0.1,-1.7 1.1,-0.1 0.5,1 1.7,1.8 2.6,1.8 z"
id="path175-9"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><path
d="m 194.4,868.5 c -1.8,1.3 -1.6,1.4 2.5,1.5 3.7,0 4.2,-0.2 3.1,-1.5 -1.6,-1.9 -3.1,-1.9 -5.6,0 z"
id="path90"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><g
id="g484-3"
inkscape:label="paw2"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
transform="matrix(3.2174039,0,0,3.8427558,264.04962,-855.12678)"><path
d="m 77.733867,236.14477 c -0.4,0.7 -0.5,1.5 -0.2,1.8 0.9,0.9 1.9,-0.4 1.4,-1.8 -0.4,-1.1 -0.6,-1.1 -1.2,0 z"
id="path169-6"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 73.133867,238.24477 c 0,0.5 0.5,1 1,1 0.6,0 1,-0.5 1,-1 0,-0.6 -0.4,-1 -1,-1 -0.5,0 -1,0.4 -1,1 z"
id="path171-1"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 81.133867,238.84477 c 0,0.8 0.5,1.2 1,0.9 0.6,-0.3 1,-1 1,-1.6 0,-0.5 -0.4,-0.9 -1,-0.9 -0.5,0 -1,0.7 -1,1.6 z"
id="path172-2"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 74.733867,241.84477 c -2.1,2 -2.1,2 1.5,3.8 3.5,1.7 4.9,1.4 4.9,-1.3 0,-3.7 -3.7,-5.2 -6.4,-2.5 z"
id="path176-9"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /><path
d="m 82.133867,242.24477 c 0,0.5 0.4,1 0.9,1 0.6,0 1.3,-0.5 1.6,-1 0.3,-0.6 -0.1,-1 -0.9,-1 -0.9,0 -1.6,0.4 -1.6,1 z"
id="path178-3"
style="fill:#e6e6ec;fill-opacity:1;stroke:none;stroke-width:0.1" /></g><g
id="g107"
inkscape:label="left"
style="display:inline;fill:#e6e6ec;fill-opacity:1"><path
d="m 779,4.4 c -0.7,2.4 -2.5,5.7 -4.1,7.2 -2.5,2.4 -8.9,13.9 -8.9,16.1 0,0.5 -0.8,2.4 -1.8,4.3 -1.1,1.9 -2.2,4.9 -2.5,6.7 -0.6,2.8 -0.3,3.4 2.7,5.2 2.1,1.3 4.3,4.1 6,7.8 1.6,3.2 3.8,6.9 5,8.3 7.2,8.3 8.5,13.3 4.1,15 -1.5,0.6 -5,2.1 -7.9,3.5 -2.9,1.4 -7.3,2.9 -9.8,3.4 -6.8,1.2 -11.8,4.6 -11.8,8 0,6.3 6.1,7.7 16.3,3.7 5.9,-2.2 11.5,-4.2 23.3,-8 5.8,-1.8 9,-2 9.8,-0.7 1,1.5 -2.1,3.5 -17.7,11.2 -4.2,2.1 -6.2,3.8 -7.4,6.4 -1.6,3.3 -1.5,3.6 0.2,5.5 3.3,3.7 7.3,2.9 28.7,-5.2 1.5,-0.5 3.4,-1.8 4.4,-2.9 2.4,-2.6 4.3,-2.4 7.9,1 3.8,3.7 12.2,5.6 30.5,7.1 7.4,0.6 20.7375,1.125 22.9375,1.625 7.6875,-1.0375 9.56458,-13.251512 9.21875,-1.53125 -0.0877,2.97105 0.5625,-3.28125 -13.85625,-3.09375 -3.19973,0.0416 -9.6,-0.7 -14.3,-1.5 -4.7,-0.8 -11.9,-1.5 -16.1,-1.5 -6,0 -8.5,-0.5 -11.9,-2.3 -6.5,-3.4 -8.3,-6.4 -7.5,-12.2 0.4,-2.6 1.6,-5.6 2.6,-6.6 4.1,-4.1 11.3,-3.2 7.6,0.9 -1.8,2 -2.2,7.8 -0.7,10.2 0.7,1.2 4,1.6 14.7,2.1 7.6,0.3 15.4,1.1 17.3,1.8 2.275,-0.196875 6.26456,0.180958 9.02812,0.63125 9.78125,1.59375 11.62813,0.7375 11.62813,-1.5625 0,-2.2 -7.05625,-3.625 -10.35625,-2.96875 -4.7,-0.1 -14.3,-1 -16.2,-1.6 -1,-0.3 -1.2,-3 -0.9,-12.7 0.4,-11 0.7,-12.6 3.1,-16.8 3,-5.1 11.2,-13.3 15.2,-15.1 13.70312,-3.678125 11.0125,-9.7375 8.8125,-9.7375 -1.7,0 -14.1125,6.8375 -15.0125,8.1375 -0.4,0.7 -1.7,1.8 -2.8,2.4 -3.6,1.9 -11.4,12.6 -12.1,16.3 -0.3,2 -1.3,5.6 -2,8 -0.9,2.7 -1.3,6.8 -1.1,10.7 0.3,3.7 0,6.7 -0.6,7.3 -1.3,1.3 -11.6,1.4 -13.6,0.2 -0.9,-0.6 -1.2,-2.5 -1,-6.2 0.2,-3 0,-6 -0.4,-6.7 -1.2,-1.8 -8.8,-1.4 -12.2,0.7 -3.4,2 -6.6,2.8 -17.1,4.1 -3.9,0.4 -9.3,1.5 -12,2.3 -2.7,0.9 -9.9,3.2 -16.1,5.1 -10.1,3.3 -14.3,3.7 -14.3,1.6 0,-1.5 2.8,-3.1 9.6,-5.4 10.3,-3.5 21.4,-8.9 21.9,-10.7 0.9,-2.7 -2.2,-9.5 -7.8,-17.1 -8.4,-11.4 -9.2,-13.2 -9.1,-21.8 0,-8.7 2.8,-15.2 9.4,-22.4 2.7,-2.9 4.1,-5.5 4.6,-8.4 0.6,-4 0.5,-4.2 -1.8,-4.2 -2.1,0 -2.7,0.7 -3.8,4.4 z m 32.3,81.8 c -0.3,2.9 -1.1,6.2 -1.8,7.3 -2.5,4 -27.4,14 -29.5,11.9 -1.9,-1.9 0.2,-4.6 5.4,-7.1 10.7,-5.1 21.5,-12.2 23.4,-15.2 2.1,-3.6 3.1,-2.4 2.5,3.1 z"
id="path4"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1"
sodipodi:nodetypes="ccsccccccscscccccccccccssssccsccccsscccccscccccscccccccsccscccscccsccc" /><path
d="m 796.7,4 c 1.7,2.5 3.4,4 4.7,4.1 1.2,0 8.2,0 15.6,-0.1 l 13.5,-0.1 3.3,-3.1 C 835.5,3 837,1.2 837,0.8 837,0.4 835.4,0 833.5,0 831.1,0 830,0.5 830,1.4 c 0,2 -6.6,2.9 -18.5,2.4 C 803.7,3.6 801.6,3.2 800.6,1.7 799.9,0.8 798.1,0 796.7,0 h -2.6 z"
id="path5"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 845,1 c 0,3.6 2.7,12 4.7,14.7 3.2,4.1 4.8,4.6 13.3,3.7 6.5,-0.6 10.7824,-0.749957 11.21875,-2.90625 C 874.96875,12.7875 869.7,14 862.5,14 853.7,14 851.9,12.7 849.9,4.9 849,1.3 848.1,0 846.8,0 845.8,0 845,0.4 845,1 Z"
id="path6"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1"
sodipodi:nodetypes="sccsscss" /><path
d="m 779.5,26 c -0.8,1.3 1.2,4.8 3.5,6 2.8,1.5 7.6,1.2 9,-0.5 1.8,-2.1 0.4,-4.8 -2.1,-4.2 -1.1,0.3 -3.8,-0.1 -6,-0.8 -2.2,-0.8 -4.1,-1 -4.4,-0.5 z"
id="path8"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 830,30.1 c 0,1.5 1.2,3.2 3.2,4.6 2.9,2 3.3,2.1 6,0.7 1.5,-0.8 2.9,-1.9 3,-2.4 0.4,-3.2 0.1,-3.5 -3.7,-3.6 -2.2,-0.1 -5,-0.5 -6.2,-0.9 -2,-0.6 -2.3,-0.4 -2.3,1.6 z"
id="path9"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 809.4,33.1 c -1,1.7 2.3,4.6 3.7,3.2 0.7,-0.7 0.9,-1.9 0.6,-2.7 -0.7,-1.8 -3.3,-2.1 -4.3,-0.5 z"
id="path10"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m 851.5,35.1 c -1,1.6 0.3,5.6 2,6.3 2.3,0.8 4.5,-0.4 5.1,-3 0.8,-3.2 -0.2,-4.4 -3.6,-4.4 -1.6,0 -3.2,0.5 -3.5,1.1 z"
id="path11"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /></g><g
id="g106-5"
transform="translate(4.85616,-2.0112115)"
style="display:inline;fill:#e6e6ec;fill-opacity:1"
inkscape:label="right2"><path
d="m -27.33033,-5.908252 c -2.6,1.5 -2.2,4.9 0.7,4.9 2.6,0 3.6,2.1 3.6,8.1999996 0,4.2000004 3,9.9000004 6.5,12.2000004 2,1.3 4.9,1.5 18.2000001,1 14.9999999,-0.5 16.1999999,-0.4 24.0999999,2 10.6,3.2 14.2,5.5 19.6,12.2 10.6,12.9 12.4,31.7 4.9,49.4 -1.3,3.1 -9.2,17.000002 -9.7,17.000002 -0.1,0 -4,1.6 -8.6,3.6 -8.2,3.5 -8.104505,3.00338 -19.37868,2.40676 -3.3607341,-0.89437 -7.3357849,0.68908 -13.8529656,0.57475 -1.2,-0.6 -9.8871044,-0.39404 -15.1871044,-0.89401 -12.9,-1.2 -12.58125,-2.9875 -12.58125,-0.6875 0,1.4 0.9,2.1 3.8,2.5 2,0.4 5.645299,0.48082 8.102252,1.46742 2.4125,0.96875 12.177253,2.16229 24.9914981,1.68883 22.1848629,-0.81969 26.4062499,-1.45625 33.9062499,-7.05625 4.2,-3 9.2,-8.600001 9.2,-10.100001 0,-0.3 1,-2.2 2.3,-4.300001 3.9,-6.2 5.7,-15 5.7,-27.3 0,-13.4 -1.6,-19.6 -7.2,-27.9 -10,-15.1 -25.6,-21 -51.79999988,-19.5 -15.00000012,0.8 -14.70000012,1 -18.80000012,-11.5000002 -2.9,-8.7999998 -5.3,-11.6999999 -8.5,-9.8999998 z"
id="path7-4"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1"
sodipodi:nodetypes="csscccccsccccscsscscscccc" /><path
d="m -29.03033,33.591748 c 0,3.1 2.3,2.8 2.8,-0.4 0.2,-1.5 -0.2,-2.2 -1.2,-2.2 -1.1,0 -1.6,0.9 -1.6,2.6 z"
id="path22-7"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m -15.93033,37.091748 c -1.8,2.5 -2,3.3 -1,4.5 0.7,0.8 2.4,1.4 3.8,1.2 2.1,-0.2 2.7,-0.9 2.9,-3.5 0.2,-2.3 -0.2,-3.4 -1.6,-4.2 -1.6,-0.8 -2.3,-0.5 -4.1,2 z"
id="path23-6"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1" /><path
d="m -0.11157988,38.941748 c -0.7,0.7 -6.78437502,0.534375 -9.35312502,5.51875 -11.8000001,7.9 -14.4656251,19.63125 -15.0656251,37.03125 -0.3,7.9 -0.6,9.1 -2.4,10.000001 -1.2,0.5 -2.1,1.5 -2.1,2.1 0,1.4 18.670796,5.393243 32.5719224,6.225826 C 12.741593,100.21758 16.06967,98.69175 18.86967,97.29175 c 1.9,-1.000001 3.6,-2.400001 3.9,-3.100001 0.4,-1.4 -3.333709,-2.362913 -4.233709,-0.862913 -1.1,1.8 -23.7753006,0.205632 -31.734835,0.525826 l -6.331456,-2.462913 -0.3,-8.500001 c -0.2,-6.8 0.2,-9.9 2.1,-15.5 3.5,-10.2 6.410501,-17.654664 16.4105013,-22.454664 8.3,-3.9 14.2894987,-1.745336 19.0894987,0.454639 4.6,2.1 9.7,2.5 9.7,0.9 0,-0.7 -1.5,-2.4 -3.4,-3.8 -3.2,-2.3 -3.603125,-3.953125 -12.303125,-3.953125 -6.3999999,0 -11.07812488,-0.396875 -11.87812488,0.403125 z"
id="path24-5"
style="fill:#e6e6ec;stroke:none;stroke-width:0.1;fill-opacity:1"
sodipodi:nodetypes="scccsccccccccccscsss" /></g></g></svg>

After

Width:  |  Height:  |  Size: 75 KiB

File diff suppressed because it is too large. Load diff
+897
View File
@@ -0,0 +1,897 @@
<!DOCTYPE html>
<!--
footstrap devkit — SOURCE template. Do not open this file in a browser; it has no stylesheet and
no data. The shipping page is docs/devkit.html, built by tools/devkit-build.mjs (npm run devkit),
which inlines the real cascade.css and injects window.FS_DEVKIT (tokens parsed from
styles/02-tokens.css, components sliced from docs/gallery.html). Edit chrome/prose HERE; edit
widget markup in docs/gallery.html; edit token values in styles/02-tokens.css.
This page's OWN chrome reads --fs-* on purpose — it is part of the footstrap repo, not a guest
luci-app. An actual app must NOT read --fs-* (that is the whole lesson of the Tokens section).
-->
<html lang="en" data-darkmode="false" data-theme="light" data-bs-theme="light" data-palette="footstrap">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>footstrap devkit — style a LuCI app that fits any theme</title>
<style>/*__CASCADE__*/</style>
<style>
/* devkit chrome only — never part of the theme, never something an app should copy */
:root { --dk-gap: 22px; }
* { box-sizing: border-box; }
body { background: var(--fs-bg); color: var(--fs-text); margin: 0;
font-family: Manrope, system-ui, sans-serif; }
a { color: var(--fs-accent); }
.dk-top { position: sticky; top: 0; z-index: 900; display: flex; gap: 14px; align-items: center;
padding: 12px 22px; background: var(--fs-bar-bg); backdrop-filter: blur(var(--fs-blur));
border-bottom: 1px solid var(--fs-border); flex-wrap: wrap; }
.dk-top .dk-brand { font-size: 16px; font-weight: 700; margin: 0; flex: 1 1 auto; letter-spacing: -.01em; }
.dk-top .dk-brand b { color: var(--fs-accent); }
.dk-top .dk-ctl { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.dk-top input[type=range] { width: 92px; vertical-align: middle; }
.dk-shell { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 34px;
max-width: 1200px; margin: 0 auto; padding: 26px 22px 120px; }
.dk-nav { position: sticky; top: 74px; align-self: start; display: flex; flex-direction: column;
gap: 2px; font-size: 13px; }
.dk-nav a { color: var(--fs-dim); text-decoration: none; padding: 6px 10px; border-radius: var(--fs-radius-s);
border-left: 2px solid transparent; }
.dk-nav a:hover { color: var(--fs-text); background: var(--fs-accent-soft); }
.dk-nav a.active { color: var(--fs-accent); border-left-color: var(--fs-accent); font-weight: 700; }
@media (max-width: 780px) { .dk-shell { grid-template-columns: 1fr; } .dk-nav { display: none; } }
.dk-sec { scroll-margin-top: 78px; margin: 0 0 52px; }
.dk-sec > h2 { font-size: 22px; margin: 0 0 4px; letter-spacing: -.02em; }
.dk-sec > .dk-lead { color: var(--fs-dim); font-size: 14px; margin: 0 0 20px; max-width: 62ch; line-height: 1.5; }
.dk-h3 { font-size: 13px; letter-spacing: .07em; text-transform: uppercase; color: var(--fs-dim);
font-weight: 700; margin: 26px 0 12px; }
.dk-card { background: var(--fs-panel); border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
padding: 16px 18px; }
.dk-grid { display: grid; gap: var(--dk-gap); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
/* a copyable code chip / block */
.dk-copy { position: relative; cursor: copy; }
.dk-copy pre, .dk-copy code.blk { margin: 0; overflow-x: auto; }
.dk-copy .dk-cbtn { position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity var(--fs-dur);
font-size: 11px; padding: 3px 8px; border-radius: var(--fs-radius-s);
background: var(--fs-glass); border: 1px solid var(--fs-border); color: var(--fs-dim); }
.dk-copy:hover .dk-cbtn { opacity: 1; }
.dk-copy:hover { outline: 1px solid var(--fs-accent-line); outline-offset: 2px; border-radius: var(--fs-radius-s); }
code.dk-inline { cursor: copy; background: var(--fs-accent-soft); padding: 1px 6px; border-radius: 5px;
font-family: 'JetBrains Mono', monospace; font-size: .88em; }
/* fact tiles (Start here) */
.dk-fact { display: flex; flex-direction: column; gap: 6px; }
.dk-fact .k { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fs-dim); font-weight: 700; }
.dk-fact .v { font-family: 'JetBrains Mono', monospace; font-size: 14px; word-break: break-all; }
.dk-callout { border-left: 3px solid var(--fs-accent); background: var(--fs-accent-fill);
padding: 12px 16px; border-radius: 0 var(--fs-radius) var(--fs-radius) 0; margin: 18px 0; font-size: 14px; }
/* tokens */
.dk-tok { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; cursor: copy; }
.dk-tok .sw { height: 54px; display: flex; align-items: flex-end; justify-content: flex-end; padding: 6px; }
.dk-tok .ink { font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 4px; }
.dk-tok .lbl { padding: 7px 9px; font-family: 'JetBrains Mono', monospace; font-size: 11.5px;
border-top: 1px solid var(--fs-border); background: var(--fs-panel); word-break: break-all; }
.dk-tokfam { margin-bottom: 20px; }
.dk-tokfam h4 { margin: 0 0 8px; font-size: 13px; }
.dk-tokfam .note { color: var(--fs-dim); font-size: 12px; margin: 0 0 10px; }
.dk-tokrow { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
/* component cards */
.dk-comp { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; margin-bottom: var(--dk-gap); }
.dk-comp > header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-bottom: 1px solid var(--fs-border); background: var(--fs-panel); }
.dk-comp > header h4 { margin: 0; font-size: 14px; flex: 1 1 auto; }
.dk-tabs { display: flex; gap: 4px; }
.dk-tabs button { font-size: 12px; padding: 3px 10px; border-radius: var(--fs-radius-s); border: 1px solid transparent;
background: transparent; color: var(--fs-dim); cursor: pointer; }
.dk-tabs button.on { background: var(--fs-accent-soft); color: var(--fs-accent); border-color: var(--fs-accent-line); }
.dk-panel { padding: 16px; }
.dk-panel.code { padding: 0; }
.dk-panel[hidden] { display: none; }
.dk-preview > * { max-width: 100%; }
/* do / don't */
.dk-dd { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; margin-bottom: 18px; }
@media (max-width: 680px) { .dk-dd { grid-template-columns: 1fr; } }
.dk-dd .box { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; }
.dk-dd .box > .hd { padding: 8px 14px; font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.dk-dd .do > .hd { background: var(--fs-good-fill); color: var(--fs-good); border-bottom: 1px solid var(--fs-good-line); }
.dk-dd .dont > .hd { background: var(--fs-danger-fill); color: var(--fs-danger); border-bottom: 1px solid var(--fs-danger-line); }
.dk-dd .box pre { margin: 0; border: 0; border-radius: 0; }
.dk-dd .box .cap { padding: 10px 14px; font-size: 13px; color: var(--fs-dim); }
.dk-ruletitle { font-size: 16px; margin: 30px 0 4px; }
.dk-ruletitle + p { margin: 0 0 14px; color: var(--fs-dim); font-size: 14px; max-width: 68ch; }
/* checklist */
.dk-check { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.dk-check li { display: flex; gap: 10px; align-items: flex-start; padding: 10px 14px; border: 1px solid var(--fs-border);
border-radius: var(--fs-radius); font-size: 14px; cursor: pointer; }
.dk-check li.done { opacity: .55; text-decoration: line-through; }
.dk-check li .bx { flex: 0 0 auto; width: 18px; height: 18px; border: 2px solid var(--fs-accent); border-radius: 5px;
display: grid; place-items: center; font-size: 12px; color: var(--fs-on-accent); margin-top: 1px; }
.dk-check li.done .bx { background: var(--fs-accent); }
/* fix my styles */
.dk-howto { margin: 0 0 18px; }
.dk-howto ol { margin: 0 0 14px; padding-left: 20px; display: grid; gap: 7px; font-size: 13.5px; line-height: 1.5; }
.dk-howto ol b { color: var(--fs-text); }
.dk-howto-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 680px) { .dk-howto-cols { grid-template-columns: 1fr; } }
.dk-howto-cols > div { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 10px 14px;
font-size: 12.5px; color: var(--fs-dim); line-height: 1.7; }
.dk-howto-cols .k { display: block; font-size: 11px; letter-spacing: .05em; text-transform: uppercase;
font-weight: 700; margin-bottom: 5px; }
.dk-grab { margin-bottom: 16px; border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 4px 14px; }
.dk-grab summary { cursor: pointer; font-weight: 700; font-size: 13.5px; padding: 8px 0; color: var(--fs-accent); }
.dk-grab p { font-size: 13.5px; color: var(--fs-dim); line-height: 1.55; margin: 4px 0 12px; }
.dk-grab pre { margin: 0; max-height: 120px; overflow: auto; }
.dk-grab code.blk { white-space: pre-wrap; word-break: break-all; font-size: 11px; }
.dk-fixbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
.dk-fix-count { font-size: 13px; color: var(--fs-dim); }
.dk-fixgrid { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
@media (max-width: 760px) { .dk-fixgrid { grid-template-columns: 1fr; } }
.dk-fixarea { width: 100%; min-height: 260px; resize: vertical; font-family: 'JetBrains Mono', monospace;
font-size: 12.5px; line-height: 1.55; padding: 12px 14px; border: 1px solid var(--fs-border);
border-radius: var(--fs-radius); background: var(--fs-panel); color: var(--fs-text); white-space: pre; overflow: auto; }
.dk-fixarea:focus { outline: 2px solid var(--fs-accent); outline-offset: 1px; }
.dk-report { margin-top: 20px; display: grid; gap: 8px; }
.dk-finding { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 10px 14px; font-size: 13.5px; }
.dk-finding .tag { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
padding: 3px 8px; border-radius: 999px; white-space: nowrap; align-self: center; }
.dk-finding.fix .tag { background: var(--fs-good-fill); color: var(--fs-good); }
.dk-finding.warn .tag { background: var(--fs-warn-fill); color: var(--fs-warn); }
.dk-finding .msg b { color: var(--fs-text); }
.dk-finding .why { color: var(--fs-dim); display: block; margin-top: 3px; }
.dk-finding code { font-family: 'JetBrains Mono', monospace; background: var(--fs-accent-soft);
padding: 1px 5px; border-radius: 4px; font-size: .92em; }
.dk-report-empty { color: var(--fs-good); font-weight: 700; font-size: 14px; padding: 12px 0; }
.dk-report h4 { margin: 18px 0 4px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--fs-dim); }
/* per-occurrence diagnostics — point at each problem where it is */
.dk-report { margin-top: 18px; display: grid; gap: 10px; }
.dk-diag { border: 1px solid var(--fs-border); border-left-width: 3px; border-radius: var(--fs-radius); padding: 10px 14px; }
.dk-diag.fix { border-left-color: var(--fs-good); }
.dk-diag.warn { border-left-color: var(--fs-warn); }
.dk-diag-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.dk-diag-top .ln { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--fs-dim);
background: var(--fs-panel2); padding: 2px 7px; border-radius: 999px; }
.dk-diag-top .tag { font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; }
.dk-diag.fix .tag { background: var(--fs-good-fill); color: var(--fs-good); }
.dk-diag.warn .tag { background: var(--fs-warn-fill); color: var(--fs-warn); }
.dk-diag-top .ti { font-size: 13.5px; font-weight: 700; }
.dk-diag-top .ti code { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: .92em; }
.dk-diag-code { margin: 0 0 8px; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.5;
background: var(--fs-panel2); border-radius: var(--fs-radius-s); padding: 8px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
.dk-diag-code mark { background: var(--fs-warn-fill); color: var(--fs-danger); font-weight: 700; padding: 0 2px; border-radius: 3px; }
.dk-diag-edit { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; flex-wrap: wrap; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.dk-diag-edit .was { color: var(--fs-danger); text-decoration: line-through; opacity: .8; }
.dk-diag-edit .arr { color: var(--fs-dim); }
.dk-diag-edit .now { background: var(--fs-good-fill); color: var(--fs-good); padding: 2px 8px; border-radius: 5px; cursor: copy; }
.dk-diag-edit .now:hover { outline: 1px solid var(--fs-good); }
.dk-diag-why { font-size: 13px; color: var(--fs-dim); line-height: 1.5; }
.dk-diag-why code { font-family: 'JetBrains Mono', monospace; background: var(--fs-accent-soft); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
/* copy toast */
.dk-toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--fs-text); color: var(--fs-bg); padding: 8px 18px; border-radius: 999px; font-size: 13px;
font-weight: 700; opacity: 0; pointer-events: none; transition: opacity var(--fs-dur-fade), transform var(--fs-dur-move); z-index: 999; }
.dk-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body class="lang_en">
<div class="dk-top">
<h1 class="dk-brand"><b>footstrap</b> devkit — style a LuCI app that fits any theme</h1>
<div class="dk-ctl">
<label style="font-size:12px;color:var(--fs-dim)">tint
<input type="range" min="1" max="360" value="255" id="dk-tint"></label>
<button class="cbi-button" id="dk-pal">palette</button>
<button class="cbi-button" id="dk-dark">dark</button>
</div>
</div>
<div class="dk-shell">
<nav class="dk-nav" id="dk-nav">
<a href="#start">Start here</a>
<a href="#tokens">Colour tokens</a>
<a href="#components">Components</a>
<a href="#fix">Fix my styles</a>
<a href="#rules">The rules</a>
<a href="playground/" style="color:var(--fs-accent);font-weight:700">Playground ↗</a>
<a href="#checklist">Ship checklist</a>
</nav>
<main>
<!-- ─────────────────────────── START HERE ─────────────────────────── -->
<section class="dk-sec" id="start">
<h2>Start here</h2>
<p class="dk-lead">You are writing a <code class="dk-inline">luci-app-*</code> package. footstrap
is one of the themes it may run under. You do <b>not</b> install footstrap, import it, or depend
on it — you render stock LuCI widgets and read one colour contract, and the theme does the rest.
This page is everything you need to copy. Click any code to copy it.</p>
<div class="dk-callout" style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span>🎛️ <b>Just want to feel it?</b> The playground is footstrap's real chrome with every Appearance
control live — drag the tint, accent and rounding, flip the palette and layout, watch it repaint.</span>
<a href="playground/" style="margin-left:auto;color:var(--fs-accent);font-weight:700;text-decoration:none;border:1px solid var(--fs-accent-line);padding:6px 14px;border-radius:var(--fs-radius-s);white-space:nowrap">Open the playground ↗</a>
</div>
<div class="dk-grid">
<div class="dk-card dk-fact dk-copy" data-copy="Footstrap">
<span class="k">Theme name (luci.themes)</span><span class="v">Footstrap</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact dk-copy" data-copy="/luci-static/footstrap">
<span class="k">mediaurlbase</span><span class="v">/luci-static/footstrap</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact dk-copy" data-copy="L.env.media">
<span class="k">Detect it is active (JS)</span><span class="v">L.env.media === '/luci-static/footstrap'</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact">
<span class="k">Depends on footstrap?</span><span class="v">no — never</span>
<span style="font-size:12px;color:var(--fs-dim)">Your app depends on <code class="dk-inline">luci-base</code>. It works on every theme.</span>
</div>
</div>
<div class="dk-callout">
<b>The one golden rule.</b> Your view is swapped into a <code class="dk-inline">#view</code> you
share with the theme and every other app. Under a modern SPA theme the document is never reloaded,
so anything you leak outside your own subtree — CSS in <code class="dk-inline">&lt;head&gt;</code>,
a class you did not prefix, a <code class="dk-inline">:root</code> variable — outlives your page and
paints someone else's. Stay inside your subtree and you cannot break anything.
</div>
<div class="dk-h3">The 30-second version</div>
<div class="dk-copy dk-card" data-copy-from="dk-quickstart">
<span class="dk-cbtn">copy</span>
<pre><code class="blk" id="dk-quickstart">return E([], [
// 1. CSS, if any, goes INSIDE the tree you return — it dies with your view.
E('style', [ '.myapp-card { background: var(--background-color-low, #fff); }' ]),
// 2. Namespace everything. Read colours from the export tier, never a #hex, never --fs-*.
E('div', { 'class': 'myapp-card' }, [
E('h3', {}, _('My app')),
E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save'))
])
]);</code></pre>
</div>
</section>
<!-- ─────────────────────────── TOKENS ─────────────────────────── -->
<section class="dk-sec" id="tokens">
<h2>Colour tokens — the whole contract</h2>
<p class="dk-lead">Every LuCI theme exposes the same colour custom-properties. Read them and you follow
light/dark, the palette switch and the tint slider for free — try the controls up top and watch the
swatches move. This is the <b>only</b> footstrap surface you may read. Click a swatch to copy its
<code class="dk-inline">var()</code>. Always give a literal fallback: <code class="dk-inline">var(--text-color-high, #333)</code>.</p>
<div id="dk-tokens"></div>
<div class="dk-callout">
<b>Do not read <code class="dk-inline">--fs-*</code>.</b> Open the stylesheet and you will find ~80
<code class="dk-inline">--fs-accent</code>, <code class="dk-inline">--fs-panel2</code>… with nicer
names. They are private, renamed at will, and no other theme has them. Reading one couples your app
to this build and breaks on the next. The <code class="dk-inline">--*-color-*</code> names above are
the contract; nothing else is.
</div>
<div class="dk-callout" style="border-left-color:var(--fs-warn);background:var(--fs-warn-fill)">
Text on a coloured fill takes its ink from the matching <code class="dk-inline">--on-*-color</code>
— never a hardcoded <code class="dk-inline">#fff</code>. A white-on-fill badge fell to 1.69:1 on seven
dark palettes before these inks were made per-palette. The family is <code class="dk-inline">warn</code>,
not <code class="dk-inline">warning</code>; <code class="dk-inline">--text-color</code> (no level) does
not exist.
</div>
</section>
<!-- ─────────────────────────── COMPONENTS ─────────────────────────── -->
<section class="dk-sec" id="components">
<h2>Components — copy the markup</h2>
<p class="dk-lead">Every widget stock LuCI (and any luci-app) can emit, with the real class names
<code class="dk-inline">ui.js</code>/<code class="dk-inline">cbi.js</code> produce. Each card has three
tabs: the live <b>Preview</b>, the <b>HTML</b>, and the <b>E()</b> form — the
<code class="dk-inline">L.dom</code> factory you actually call in a view. Copy either. Build these from
<code class="dk-inline">'require ui'</code> where a real widget class exists (a
<code class="dk-inline">ui.Dropdown</code>, <code class="dk-inline">ui.FileUpload</code>); the markup
here is what those render to.</p>
<div id="dk-components"></div>
</section>
<!-- ─────────────────────────── FIX MY STYLES ─────────────────────────── -->
<section class="dk-sec" id="fix">
<h2>Fix my styles</h2>
<p class="dk-lead">Paste a chunk of your app — CSS, a <code class="dk-inline">&lt;style&gt;</code> block,
markup with inline styles, a snippet copied straight out of DevTools — and press <b>Fix</b>. The
mechanical mistakes are rewritten in place; the structural ones (that can't be auto-fixed without
risking your code) are flagged with the manual fix. Every colour keeps your original as a
<code class="dk-inline">var(--token, <i>original</i>)</code> fallback, so a rewrite can never change
how your app renders on a theme that lacks the token — worst case, it does exactly what it did before.</p>
<div class="dk-howto">
<ol>
<li><b>Get the code in.</b> Paste your CSS / <code class="dk-inline">&lt;style&gt;</code> / markup
into the left box — or, if it's injected from JS and you can't find it, use the console grabber
below (or a plain <b>Save Page As → HTML only</b>).</li>
<li><b>Press <span style="color:var(--fs-good)">Fix styles</span>.</b> The right box fills with the
rewritten code and the report appears underneath.</li>
<li><b>Take the fixed copy, then clear the manual list.</b> Copy the right box back into your app,
then work through <b>Fix these by hand</b> — each links to <a href="#rules">the rule</a> that
explains it. You're done when that list is empty.</li>
</ol>
<div class="dk-howto-cols">
<div><span class="k" style="color:var(--fs-good)">Rewritten for you</span>
colour literals → tokens (original kept as fallback) · <code class="dk-inline">--warning-*</code>→<code class="dk-inline">--warn-*</code> ·
<code class="dk-inline">--text-color</code>→<code class="dk-inline">--text-color-high</code> · private <code class="dk-inline">--fs-*</code>→export token ·
stray <code class="dk-inline">!important</code> · runaway <code class="dk-inline">z-index</code> · <code class="dk-inline">&lt;font color&gt;</code></div>
<div><span class="k" style="color:var(--fs-warn)">Flagged for you</span>
CSS in <code class="dk-inline">&lt;head&gt;</code> · <code class="dk-inline">:root{}</code> / <code class="dk-inline">*{}</code> ·
un-prefixed classes · <code class="dk-inline">window.onload</code> · <code class="dk-inline">prefers-color-scheme</code> ·
hardcoded editor theme · stock selectors without a scope</div>
</div>
</div>
<details class="dk-grab">
<summary>Grab it straight off a running app →</summary>
<p>Can't find the CSS, or it's injected from JS? Open <b>your app's page</b> in the browser,
press <kbd>F12</kbd> → <b>Console</b>, paste this line and hit <kbd>Enter</kbd>. It harvests <b>every</b>
injected sheet, every inline <code class="dk-inline">style=</code> in <code class="dk-inline">#view</code>
and every <code class="dk-inline">&lt;font color&gt;</code> to your clipboard — <b>nothing is dropped</b>, so
it can't hide a bug. Paste below, press Fix.</p>
<p><b>Whose sheet is whose.</b> A live LuCI page also carries CSS that is <b>not yours</b>: the theme's own
stylesheet (skipped — it's a filtered <code class="dk-inline">&lt;link&gt;</code>), LuCI base's injected bits
(<code class="dk-inline">status/cpu.js</code>, <code class="dk-inline">package-manager.js</code>), and every
<i>other</i> app on the page. So each harvested <code class="dk-inline">&lt;head&gt;</code> sheet is <b>tagged with
its first selector</b> — if it doesn't start with your class prefix, it isn't yours; delete it before you
Fix. Type your prefix at the prompt (optional) and yours get marked <code class="dk-inline">YOURS</code>.
(A <b>Save Page As → HTML only</b> dump works too.)</p>
<div class="dk-copy dk-card" data-copy-from="dk-grab-src">
<span class="dk-cbtn">copy</span>
<pre><code class="blk" id="dk-grab-src">(function(){var P=(prompt("footstrap devkit — optional: your app's class/id prefix (e.g. podkop) to mark YOUR sheets. Blank is fine — nothing is dropped either way:")||"").trim().toLowerCase();var v=document.querySelector('#view')||document.body,t=(window.L&amp;&amp;L.env&amp;&amp;L.env.media)||'x',o=[];function tag(c){var sel=((c.match(/([^{}]+)\{/)||['',''])[1]||'').trim().slice(0,70);var mine=P&amp;&amp;c.toLowerCase().indexOf(P)&gt;=0;return '/* &lt;head&gt; sheet — first selector: '+sel+(mine?' — YOURS':(P?' — probably NOT yours, delete if so':' — is this yours? delete if not'))+' */';}document.querySelectorAll('head style').forEach(function(n){var c=(n.textContent||'').trim();if(c)o.push(tag(c)+'\n&lt;head&gt;&lt;style&gt;\n'+c+'\n&lt;/style&gt;&lt;/head&gt;');});document.querySelectorAll('head link[rel=stylesheet]').forEach(function(n){if(n.href.indexOf(t)&lt;0&amp;&amp;n.href.indexOf('cascade.css')&lt;0)o.push('&lt;head&gt;&lt;link rel="stylesheet" href="'+n.href+'"&gt;&lt;/head&gt;');});var s=new Set();v.querySelectorAll('[style]').forEach(function(e){var x=e.getAttribute('style');if(x&amp;&amp;!s.has(x)){s.add(x);o.push(e.tagName.toLowerCase()+'[inline]{'+x+'}');}});v.querySelectorAll('style').forEach(function(e){var c=(e.textContent||'').trim();if(c)o.push(c);});v.querySelectorAll('font[color]').forEach(function(f){o.push('&lt;font color="'+f.getAttribute('color')+'"&gt;x&lt;/font&gt;');});var b=o.join('\n\n');try{copy(b)}catch(e){console.log(b)}console.log('%c[footstrap devkit] '+o.length+' snippet(s) copied — paste into Fix my styles','color:#2563eb;font-weight:700');})();</code></pre>
</div>
</details>
<div class="dk-fixbar">
<button class="cbi-button cbi-button-positive" id="dk-fix-run">Check ↓</button>
<button class="cbi-button" id="dk-fix-sample">Load a broken example</button>
<button class="cbi-button" id="dk-fix-clear">Clear</button>
<button class="cbi-button" id="dk-fix-applyall" hidden>Apply every fix &amp; copy</button>
<span class="dk-fix-count" id="dk-fix-count"></span>
</div>
<div class="dk-h3" style="margin-top:0">Paste here</div>
<textarea id="dk-fix-in" class="dk-fixarea" spellcheck="false" placeholder="/* paste your CSS / <style> / markup, or grab it above */"></textarea>
<div id="dk-fix-report"></div>
<p style="font-size:13px;color:var(--fs-dim);margin-top:16px">Each problem is pointed at where it is — the
line, the exact fragment, and the fix. This is a conservative checker, not a compiler: it is sure about
the ones it marks <b>auto-fix</b> and only flags the <b>manual</b> ones — it does not prove your app is
correct. Read each against <a href="#rules">the rules</a>.</p>
</section>
<!-- ─────────────────────────── RULES ─────────────────────────── -->
<section class="dk-sec" id="rules">
<h2>The rules — and why each one exists</h2>
<p class="dk-lead">Every rule below is a bug found in a real, popular app, on a theme its author never
tested. Do / Don't pairs; the reason is the point.</p>
<h3 class="dk-ruletitle">1. CSS lifetime — where you put a <code class="dk-inline">&lt;style&gt;</code> decides who it hits</h3>
<p>Return it inside your tree and it dies with your view. Append it to <code class="dk-inline">&lt;head&gt;</code>
and under an SPA theme it lives forever and restyles every later page.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — in the view tree</div>
<pre>return E([], [
E('style', [ css ]), // scoped to this view by construction
E('div', { 'class': 'myapp-root' }, …)
]);</pre>
<div class="cap">Stock <code class="dk-inline">package-manager.js</code>, <code class="dk-inline">nftables.js</code>, <code class="dk-inline">aria2/log.js</code> all do this.</div>
</div>
<div class="box dont"><div class="hd">✗ Don't — append to head</div>
<pre>document.head.appendChild(style);
// .cbi-button-save { display:none !important }
// → every config page in the session is now unsavable</pre>
<div class="cap">Real: <code class="dk-inline">luci-app-filemanager</code> hid the stock Save/Apply/Reset this way. To drop stock buttons, set <code class="dk-inline">handleSaveApply = null</code>.</div>
</div>
</div>
<h3 class="dk-ruletitle">2. Namespace everything you declare</h3>
<p>You share <code class="dk-inline">:root</code> and the class-name space with the theme and every other app.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — prefix with your package</div>
<pre>.myapp-card { … }
#cbi-myapp-section { … }
.myapp-root { --myapp-accent: … } /* on YOUR root */</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't — claim shared names</div>
<pre>.hidden, .toast, .label, .skeleton { … }
:root { --accent: … } /* repaints the theme */
* { margin: 0; padding: 0 } /* flattens the chrome */</pre>
<div class="cap">Each one is a live collision seen in the top-30 apps. A hostile <code class="dk-inline">:root</code> repainted 312 of 336 theme elements before the tokens were split private/export.</div>
</div>
</div>
<h3 class="dk-ruletitle">3. Colour — the tier, never a literal</h3>
<p>A hardcoded colour is right on exactly the one configuration you looked at. It is wrong the instant the
user picks the high-contrast palette or drags the tint.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — read the export tier</div>
<pre>color: var(--text-color-high, #333);
background-color: var(--background-color-low, #f5f5f5);
color: var(--error-color-medium, #f44336);
/* text on a fill: */
background: var(--success-color-high, #2e7d32);
color: var(--on-success-color, #fff);</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't — hardcode or read privates</div>
<pre>background: #f7f7f7; /* black-on-black in dark */
background: white; /* white box on a dark page */
color: var(--fs-accent); /* private tier */
color: var(--warning-color-medium); /* no such name */
&lt;font color="green"&gt; /* vanishes on half the palettes */</pre>
</div>
</div>
<p style="font-size:14px;color:var(--fs-dim)"><b>The good half:</b> because the theme reads only its own
private tokens, your <code class="dk-inline">:root</code> can no longer repaint it — and inside your own
subtree your unlayered CSS already outranks every theme layer. If you have an <code class="dk-inline">!important</code>
to beat the theme, delete it; it was never what was winning.</p>
<h3 class="dk-ruletitle">4. Dark mode — <code class="dk-inline">prefers-color-scheme</code> is the wrong question</h3>
<p>That media query reports the operating system, not the theme. A user who forces the theme dark on a
light OS gets your light card on their dark page.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — ask the theme, or don't ask at all</div>
<pre>function isDark() {
var r = document.documentElement;
if (r.dataset.darkmode) return r.dataset.darkmode === 'true';
if (r.dataset.theme) return r.dataset.theme === 'dark';
if (r.dataset.bsTheme) return r.dataset.bsTheme === 'dark';
// fallback: body luminance, works on any theme
}
// BEST: read every colour from the tier and never detect at all.</pre>
<div class="cap">footstrap stamps all three attributes on <code class="dk-inline">:root</code> before first paint, so any check works.</div>
</div>
<div class="box dont"><div class="hd">✗ Don't</div>
<pre>@media (prefers-color-scheme: dark) { … }
editor.setTheme('dracula'); /* black editor on a light page */</pre>
<div class="cap">Pick the editor theme from <code class="dk-inline">isDark()</code>, not hardcoded.</div>
</div>
</div>
<h3 class="dk-ruletitle">5. Layout &amp; SPA</h3>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do</div>
<pre>/* key on your container, not the viewport */
@container (max-width: 700px) { … }
// do work in render(), clear your own pollers,
// put nodes in your view tree</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't</div>
<pre>@media (max-width: 768px) { … } /* sidebar eats 224px */
window.onload = … /* fires once per document */
document.body.appendChild(toast) /* survives the view swap */
z-index: 2147483647; /* above the theme's overlays */</pre>
</div>
</div>
</section>
<!-- ─────────────────────────── CHECKLIST ─────────────────────────── -->
<section class="dk-sec" id="checklist">
<h2>Ship checklist</h2>
<p class="dk-lead">Tick these before you tag. Click to mark done (saved locally).</p>
<ul class="dk-check" id="dk-check">
<li data-k="c1"><span class="bx">✓</span><span><code class="dk-inline">&lt;style&gt;</code> returned inside the view tree, not appended to <code class="dk-inline">&lt;head&gt;</code></span></li>
<li data-k="c2"><span class="bx">✓</span><span>Every class, id and custom property prefixed with the app name</span></li>
<li data-k="c3"><span class="bx">✓</span><span>No <code class="dk-inline">:root { … }</code>, no <code class="dk-inline">* { … }</code>, no imported CSS framework</span></li>
<li data-k="c4"><span class="bx">✓</span><span>No stock <code class="dk-inline">.cbi-*</code> / <code class="dk-inline">table</code> / <code class="dk-inline">pre</code> selector styled outside your own subtree</span></li>
<li data-k="c5"><span class="bx">✓</span><span>All colours from the <code class="dk-inline">--*-color-*</code> export names with a literal fallback; <code class="dk-inline">warn</code>, not <code class="dk-inline">warning</code></span></li>
<li data-k="c6"><span class="bx">✓</span><span>No <code class="dk-inline">--fs-*</code> read anywhere — that is the private tier</span></li>
<li data-k="c7"><span class="bx">✓</span><span>Text on a coloured fill takes its ink from <code class="dk-inline">--on-*-color</code>, never <code class="dk-inline">#fff</code></span></li>
<li data-k="c8"><span class="bx">✓</span><span>No <code class="dk-inline">!important</code> to beat the theme — your CSS already outranks every layer</span></li>
<li data-k="c9"><span class="bx">✓</span><span>Dark mode from <code class="dk-inline">data-theme</code> / <code class="dk-inline">data-bs-theme</code> / luminance, never <code class="dk-inline">prefers-color-scheme</code></span></li>
<li data-k="c10"><span class="bx">✓</span><span>Editor theme chosen from the page's mode, not hardcoded</span></li>
<li data-k="c11"><span class="bx">✓</span><span>Layout keyed to the container, not the viewport</span></li>
<li data-k="c12"><span class="bx">✓</span><span><code class="dk-inline">handleSaveApply = null</code> instead of hiding stock buttons</span></li>
<li data-k="c13"><span class="bx">✓</span><span>No <code class="dk-inline">window.onload</code>, no body-level leftovers, no <code class="dk-inline">popstate</code> hijack, no <code class="dk-inline">z-index: 2147483647</code></span></li>
</ul>
<p style="margin-top:22px;font-size:14px;color:var(--fs-dim)">Reference apps that get it right:
<code class="dk-inline">luci-app-nikki</code> (zero injected CSS, zero literals),
stock <code class="dk-inline">luci-app-ttyd</code> (the iframe done right),
<code class="dk-inline">luci-app-internet-detector</code> (namespaced <code class="dk-inline">:root</code> vars, dark keyed off the theme's attribute).</p>
</section>
</main>
</div>
<div class="dk-toast" id="dk-toast">copied</div>
<script>/*__DATA__*/</script>
<script>
(function () {
'use strict';
var D = window.FS_DEVKIT || { tiers: [], components: [] };
var $ = function (s, r) { return (r || document).querySelector(s); };
/* ---- clipboard + toast ---- */
var toast = $('#dk-toast'), tt;
function flash(msg) { toast.textContent = msg || 'copied'; toast.classList.add('show');
clearTimeout(tt); tt = setTimeout(function () { toast.classList.remove('show'); }, 1100); }
function copy(text, label) {
navigator.clipboard.writeText(text).then(function () { flash(label || 'copied'); },
function () { flash('copy failed'); });
}
document.addEventListener('click', function (e) {
var el = e.target.closest('[data-copy]');
if (el) { copy(el.getAttribute('data-copy'), 'copied ' + el.getAttribute('data-copy')); return; }
var from = e.target.closest('[data-copy-from]');
if (from) { copy($('#' + from.getAttribute('data-copy-from')).textContent); return; }
var inl = e.target.closest('code.dk-inline');
if (inl && !e.target.closest('a')) { copy(inl.textContent); }
});
/* ---- tokens ---- */
var NOTES = {
background: 'Elevation, not emphasis: high = raised (a card), low = recessed (the page).',
border: 'high = the visible rule, low = the hairline that fades into the surface.',
text: 'Four levels. -low equals -medium on purpose — a fainter grey would fail AA.',
primary: 'The accent ramp. Chroma at constant lightness — every level is legible as text.',
success: 'high / medium / low are three real colours, not aliases.',
warn: 'The family is warn, NOT warning. --warning-color-* does not exist.',
error: ''
};
function readableInk(fam) { return fam ? 'var(--on-' + fam + '-color)' : '#fff'; }
(function renderTokens() {
var mount = $('#dk-tokens'); if (!mount) return;
D.tiers.forEach(function (t) {
var fam = document.createElement('div'); fam.className = 'dk-tokfam';
var h = document.createElement('h4'); h.textContent = t.family; fam.appendChild(h);
if (NOTES[t.family]) { var n = document.createElement('p'); n.className = 'note'; n.textContent = NOTES[t.family]; fam.appendChild(n); }
var row = document.createElement('div'); row.className = 'dk-tokrow';
t.levels.forEach(function (name) {
var v = 'var(--' + name + ')';
var card = document.createElement('div'); card.className = 'dk-tok'; card.setAttribute('data-copy', v);
var sw = document.createElement('div'); sw.className = 'sw'; sw.style.background = v;
if (t.ink) { var ink = document.createElement('span'); ink.className = 'ink';
ink.style.background = 'var(--' + t.ink + ')'; ink.style.color = v; ink.textContent = 'ink'; sw.appendChild(ink); }
var lbl = document.createElement('div'); lbl.className = 'lbl'; lbl.textContent = '--' + name;
card.appendChild(sw); card.appendChild(lbl); row.appendChild(card);
});
if (t.ink) {
var v2 = 'var(--' + t.ink + ')';
var card2 = document.createElement('div'); card2.className = 'dk-tok'; card2.setAttribute('data-copy', v2);
var sw2 = document.createElement('div'); sw2.className = 'sw'; sw2.style.background = 'var(--' + t.family + '-color-high)';
var chip = document.createElement('span'); chip.className = 'ink'; chip.style.color = v2; chip.textContent = 'Aa'; chip.style.fontSize = '15px';
sw2.appendChild(chip);
var lbl2 = document.createElement('div'); lbl2.className = 'lbl'; lbl2.textContent = '--' + t.ink;
card2.appendChild(sw2); card2.appendChild(lbl2); row.appendChild(card2);
}
fam.appendChild(row); mount.appendChild(fam);
});
})();
/* ---- DOM → E(): the L.dom factory an app actually calls. Prints indented, string child when
an element holds only text, attrs object omitted when empty. Matches the live preview node. ---- */
function domToE(node, indent) {
var pad = new Array(indent + 1).join(' ');
var tag = node.tagName.toLowerCase();
var attrs = {};
for (var i = 0; i < node.attributes.length; i++) attrs[node.attributes[i].name] = node.attributes[i].value;
var attrStr = Object.keys(attrs).length
? '{ ' + Object.keys(attrs).map(function (k) { return "'" + k + "': '" + attrs[k].replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; }).join(', ') + ' }'
: '{}';
var kids = Array.prototype.filter.call(node.childNodes, function (c) {
return c.nodeType === 1 || (c.nodeType === 3 && c.textContent.trim());
});
if (!kids.length) return pad + "E('" + tag + "', " + attrStr + ')';
if (kids.length === 1 && kids[0].nodeType === 3)
return pad + "E('" + tag + "', " + attrStr + ", '" + kids[0].textContent.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "')";
var childCode = kids.map(function (c) {
if (c.nodeType === 3) return pad + " '" + c.textContent.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
return domToE(c, indent + 1);
}).join(',\n');
return pad + "E('" + tag + "', " + attrStr + ', [\n' + childCode + '\n' + pad + '])';
}
function fragToE(container) {
var els = Array.prototype.filter.call(container.children, function (c) { return c.nodeType === 1; });
if (els.length === 1) return domToE(els[0], 0) + ';';
return 'E([], [\n' + els.map(function (el) { return domToE(el, 1); }).join(',\n') + '\n]);';
}
/* ---- components: three tabs off the one rendered preview ---- */
(function renderComponents() {
var mount = $('#dk-components'); if (!mount) return;
D.components.forEach(function (c, idx) {
var comp = document.createElement('div'); comp.className = 'dk-comp';
var head = document.createElement('header');
var h = document.createElement('h4'); h.textContent = c.title;
var tabs = document.createElement('div'); tabs.className = 'dk-tabs';
head.appendChild(h); head.appendChild(tabs); comp.appendChild(head);
var preview = document.createElement('div'); preview.className = 'dk-panel dk-preview';
preview.innerHTML = c.markup;
var htmlWrap = document.createElement('div'); htmlWrap.className = 'dk-panel code dk-copy'; htmlWrap.hidden = true;
var htmlPre = document.createElement('pre'); var htmlCode = document.createElement('code'); htmlCode.className = 'blk';
htmlCode.textContent = c.markup.replace(/\t/g, ' ').trim();
htmlPre.appendChild(htmlCode); htmlWrap.appendChild(htmlPre);
var hb = document.createElement('span'); hb.className = 'dk-cbtn'; hb.textContent = 'copy'; htmlWrap.appendChild(hb);
htmlWrap.addEventListener('click', function () { copy(htmlCode.textContent); });
var eWrap = document.createElement('div'); eWrap.className = 'dk-panel code dk-copy'; eWrap.hidden = true;
var ePre = document.createElement('pre'); var eCode = document.createElement('code'); eCode.className = 'blk';
eCode.textContent = fragToE(preview);
ePre.appendChild(eCode); eWrap.appendChild(ePre);
var eb = document.createElement('span'); eb.className = 'dk-cbtn'; eb.textContent = 'copy'; eWrap.appendChild(eb);
eWrap.addEventListener('click', function () { copy(eCode.textContent); });
comp.appendChild(preview); comp.appendChild(htmlWrap); comp.appendChild(eWrap);
[['Preview', preview], ['HTML', htmlWrap], ['E()', eWrap]].forEach(function (pair, i) {
var b = document.createElement('button'); b.textContent = pair[0]; if (i === 0) b.className = 'on';
b.addEventListener('click', function () {
tabs.querySelectorAll('button').forEach(function (x) { x.classList.remove('on'); });
b.classList.add('on');
[preview, htmlWrap, eWrap].forEach(function (p) { p.hidden = true; });
pair[1].hidden = false;
});
tabs.appendChild(b);
});
mount.appendChild(comp);
});
})();
/* ---- controls: dark / palette / tint ---- */
var root = document.documentElement;
function setDark(on) {
root.setAttribute('data-darkmode', on ? 'true' : 'false');
root.setAttribute('data-theme', on ? 'dark' : 'light');
root.setAttribute('data-bs-theme', on ? 'dark' : 'light');
}
$('#dk-dark').addEventListener('click', function () { setDark(root.getAttribute('data-darkmode') !== 'true'); });
$('#dk-pal').addEventListener('click', function () {
root.setAttribute('data-palette', root.getAttribute('data-palette') === 'hicontrast' ? 'footstrap' : 'hicontrast');
});
$('#dk-tint').addEventListener('input', function () {
root.style.setProperty('--fs-tint-h', this.value); // property BEFORE attribute (the head.ut ordering rule)
root.setAttribute('data-tint', 'on');
});
/* ---- checklist (localStorage) ---- */
var CK = 'dk-check';
var done = {};
try { done = JSON.parse(localStorage.getItem(CK) || '{}'); } catch (e) { done = {}; }
$('#dk-check').querySelectorAll('li').forEach(function (li) {
var k = li.getAttribute('data-k');
if (done[k]) li.classList.add('done');
li.addEventListener('click', function () {
li.classList.toggle('done'); done[k] = li.classList.contains('done');
try { localStorage.setItem(CK, JSON.stringify(done)); } catch (e) {}
});
});
/* ---- fix my styles: a conservative in-browser codemod ----
Rewrites only what is provably safe (every colour keeps the original as a var() fallback, so a
rewrite can never change rendering on a theme without the token) and FLAGS the structural
mistakes it must not touch. Text-based on purpose: the input is a paste — CSS, a <style>, markup
with inline styles, a DevTools copy — not a parseable document. ---- */
(function fixTool() {
var inEl = $('#dk-fix-in'), rep = $('#dk-fix-report'), cnt = $('#dk-fix-count');
if (!inEl) return;
function parseHex(h) {
h = h.replace('#', '');
if (h.length === 3) h = h.split('').map(function (c) { return c + c; }).join('');
if (h.length < 6 || /[^0-9a-f]/i.test(h.slice(0, 6))) return null;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
var NAMED = { white: [255, 255, 255], black: [0, 0, 0], red: [255, 0, 0], green: [0, 128, 0],
blue: [0, 0, 255], orange: [255, 165, 0], gray: [128, 128, 128], grey: [128, 128, 128],
whitesmoke: [245, 245, 245], silver: [192, 192, 192], lime: [0, 255, 0], navy: [0, 0, 128],
teal: [0, 128, 128], maroon: [128, 0, 0], yellow: [255, 255, 0] };
function rgbOf(c) {
c = c.trim().toLowerCase();
if (c[0] === '#') return parseHex(c);
if (NAMED[c]) return NAMED[c].slice();
var m = c.match(/^rgba?\(([^)]+)\)/);
if (m) { var p = m[1].split(',').map(parseFloat); return [p[0], p[1], p[2]]; }
return null;
}
/* (property-kind, colour) → the export token that role should read. Chromatic colours map to a
status family by dominant hue; greys map by luminance on an elevation/text ladder. */
function classify(kind, color) {
var rgb = rgbOf(color); if (!rgb) return null;
var r = rgb[0], g = rgb[1], b = rgb[2],
lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255, sat = Math.max(r, g, b) - Math.min(r, g, b);
if (sat > 50) {
var fam;
if (g >= r && g >= b) fam = 'success';
else if (b >= r && b >= g) fam = 'primary';
else fam = (g > 110 ? 'warn' : 'error'); // red dominant; strong green too → orange/amber
return kind === 'bg' ? fam + '-color-high' : fam + '-color-medium';
}
if (kind === 'bg') return lum > 0.92 ? 'background-color-high' : 'background-color-low';
if (kind === 'border') return 'border-color-medium';
return lum < 0.5 ? 'text-color-high' : 'text-color-medium';
}
var COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|\b(?:white|black|red|green|blue|orange|gray|grey|whitesmoke|silver|lime|navy|teal|maroon|yellow)\b/;
function kindOf(prop) {
prop = prop.toLowerCase();
if (/^background(-color)?$/.test(prop)) return 'bg';
if (/^border(-(top|right|bottom|left))?(-color)?$/.test(prop)) return 'border';
if (prop === 'color' || prop === 'fill' || prop === 'stroke') return 'text';
return null;
}
var FS_MAP = { 'fs-accent': 'primary-color-high', 'fs-danger': 'error-color-high',
'fs-good': 'success-color-high', 'fs-warn': 'warn-color-high', 'fs-panel': 'background-color-high',
'fs-panel2': 'background-color-low', 'fs-bg': 'background-color-low', 'fs-text': 'text-color-high',
'fs-dim': 'text-color-medium', 'fs-border': 'border-color-medium' };
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
/* Point at each problem where it is: return per-occurrence findings with a line number, the source
line, the exact offending fragment (to highlight) and — for the mechanical ones — the precise
before→after edit. run() also computes the whole-input rewrite for the "apply everything" path. */
function run(src) {
var F = [];
function locate(idx) {
var pre = src.slice(0, idx), s = pre.lastIndexOf('\n') + 1, e = src.indexOf('\n', idx);
if (e < 0) e = src.length;
return { line: pre.split('\n').length, text: src.slice(s, e) };
}
function fix(idx, mark, title, why, before, after) {
var l = locate(idx); F.push({ level: 'fix', line: l.line, text: l.text, mark: mark, title: title, why: why, before: before, after: after });
}
function warn(idx, mark, title, why) {
var l = locate(idx); F.push({ level: 'warn', line: l.line, text: l.text, mark: mark, title: title, why: why });
}
var m, re;
// 1. colour literals → tokens (original kept as fallback)
re = /([-\w]+)\s*:\s*([^;{}]+)/g;
while ((m = re.exec(src))) {
var kind = kindOf(m[1]); if (!kind || /var\(/.test(m[2])) continue;
var cm = m[2].match(COLOR_RE); if (!cm) continue;
var tok = classify(kind, cm[0]); if (!tok) continue;
var before = cm[0].trim(), after = 'var(--' + tok + ', ' + before + ')';
fix(m.index + m[0].indexOf(cm[0]), before, 'Hard-coded colour',
'A literal is right on the one theme/mode you looked at. Read the export token so it follows light/dark, the palette and the tint; your value stays as the fallback.', before, after);
}
// 2. wrong token names
re = /--warning-color-(high|medium|low|highest)\b/g;
while ((m = re.exec(src))) fix(m.index, m[0], 'Wrong token name', 'The family is <code>warn</code>, not <code>warning</code>. <code>--warning-color-*</code> is defined by nothing and silently falls through to your fallback.', m[0], '--warn-color-' + m[1]);
re = /--text-color(?![-\w])/g;
while ((m = re.exec(src))) fix(m.index, m[0], 'Wrong token name', '<code>--text-color</code> (no level) does not exist on the tier — only <code>-highest/-high/-medium/-low</code>.', '--text-color', '--text-color-high');
// 3. private tier reads
re = /--fs-([\w-]+)/g;
while ((m = re.exec(src))) {
if (FS_MAP['fs-' + m[1]]) fix(m.index, m[0], 'Reads the private tier', 'The <code>--fs-*</code> tier is internal, renamed at will, absent on every other theme. Only <code>--*-color-*</code> is a contract.', m[0], '--' + FS_MAP['fs-' + m[1]]);
else warn(m.index, m[0], 'Reads a private <code>--fs-*</code> with no colour equivalent', 'This is a private token (radius, z-index, spacing) with no public contract. Hardcode a value of your own, or drop the dependency on the theme.');
}
// 4. !important
re = /!important/g;
while ((m = re.exec(src))) fix(m.index, '!important', 'Needless <code>!important</code>', 'Your app CSS is unlayered and already beats every cascade layer the theme has — the flag was never what was winning. Remove it.', '!important', '');
// 5. absurd z-index
re = /z-index\s*:\s*(\d{5,})/gi;
while ((m = re.exec(src))) { if (+m[1] > 9999) fix(m.index, m[0], 'Runaway <code>z-index</code>', "A theme's own overlays can't coexist with <code>z-index: 2147483647</code>. Nothing you render needs to sit above everything ever.", m[0], 'z-index: 1000'); }
// 6. <font color>
re = /<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/gi;
while ((m = re.exec(src))) { var ft = classify('text', m[1]) || 'text-color-high'; fix(m.index, m[0], '<code>&lt;font color&gt;</code>', 'A hardcoded font colour vanishes on half the palettes. Use a tokenised element.', m[0], '<span style="color:var(--' + ft + ', ' + m[1].trim() + ')">'); }
// ---- manual: structural, per occurrence ----
re = /document\.head\.(?:appendChild|append|insertBefore)/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'CSS injected into <code>&lt;head&gt;</code> from JS', 'Under an SPA theme it outlives your view and restyles every later page. Return the <code>&lt;style&gt;</code> inside the tree your <code>render()</code> returns, or set <code>handleSaveApply=null</code> to drop stock buttons.');
re = /<head[\s>][\s\S]*?<(?:style|link\b[^>]*stylesheet)/gi;
while ((m = re.exec(src))) { warn(m.index, '<head>', 'A <code>&lt;style&gt;</code> / <code>&lt;link&gt;</code> in <code>&lt;head&gt;</code>', 'On a full reload it dies with the document; under an SPA theme it never does — it paints every later page. Return the CSS inside your view tree; never <code>&lt;link&gt;</code> an app stylesheet from <code>&lt;head&gt;</code>. (If this sheet is the theme’s or another app’s, it isn’t yours to fix.)'); if (re.lastIndex === m.index) re.lastIndex++; }
re = /:root\s*\{/g;
while ((m = re.exec(src))) warn(m.index, ':root', 'A <code>:root { … }</code> block', 'A shared global scope every app writes into. Declare your custom properties on <b>your</b> root element (<code>.myapp-root { --myapp-… }</code>), never <code>:root</code>.');
re = /(?:^|[^.\w#-])(\*\s*\{)/gm;
while ((m = re.exec(src))) warn(m.index + m[0].indexOf('*'), '*', 'A universal <code>*</code> reset', 'It flattens the theme’s own chrome. Never ship a document-wide reset.');
re = /\.(hidden|toast|skeleton|centered|dialog-title|flex-row|log-line|description)\b\s*\{/g;
while ((m = re.exec(src))) warn(m.index, '.' + m[1], 'Un-prefixed dictionary class', 'A live collision with the theme and every other app. Prefix it with your package name (<code>.myapp-' + m[1] + '</code>).');
re = /\b(?:window\.onload|DOMContentLoaded)\b/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'Runs once per document', 'Fires once and never again after the first SPA navigation. Do your work in <code>render()</code> instead.');
re = /document\.body\.(?:appendChild|append)\b/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'Node appended to <code>document.body</code>', 'It survives the view swap and leaks onto the next page. Put it in your view tree.');
re = /@media[^{]*prefers-color-scheme/gi;
while ((m = re.exec(src))) warn(m.index, 'prefers-color-scheme', 'Wrong dark-mode signal', 'Reports the OS, not the theme. Read the colour tokens (they already follow the theme) or sniff <code>data-theme</code> / <code>data-bs-theme</code>.');
re = /\.setTheme\s*\(\s*["'](?:dracula|monokai|tomorrow[-_]?night\w*|twilight|cobalt|material)/gi;
while ((m = re.exec(src))) warn(m.index, m[0], 'Hardcoded editor theme', 'A permanently dark editor on a light page. Pick the editor theme from <code>isDark()</code>.');
re = /(?:^|[{}])\s*(\.cbi-[\w-]+|table\s+th|pre)\s*\{/gm;
while ((m = re.exec(src))) warn(m.index + m[0].indexOf(m[1]), m[1], 'Stock LuCI selector without a scope', 'An unlayered rule on <code>' + esc(m[1]) + '</code> beats every theme layer, on every page. Scope it under your own root: <code>.myapp-root ' + esc(m[1]) + ' { … }</code>.');
F.sort(function (a, b) { return a.line - b.line || (a.level === b.level ? 0 : a.level === 'fix' ? -1 : 1); });
return { findings: F, fixed: computeFixed(src) };
}
/* the whole-input rewrite, for "apply every fix & copy" — the same edits, applied at once */
function computeFixed(src) {
var out = src.replace(/([-\w]+)\s*:\s*([^;{}]+)/g, function (mm, prop, val) {
var kind = kindOf(prop); if (!kind || /var\(/.test(val)) return mm;
var cm = val.match(COLOR_RE); if (!cm) return mm;
var tok = classify(kind, cm[0]); if (!tok) return mm;
return prop + ': ' + val.replace(COLOR_RE, 'var(--' + tok + ', ' + cm[0].trim() + ')');
});
out = out.replace(/--warning-color-(high|medium|low|highest)\b/g, '--warn-color-$1')
.replace(/--text-color(?![-\w])/g, '--text-color-high')
.replace(/--fs-([\w-]+)/g, function (mm, name) { return FS_MAP['fs-' + name] ? '--' + FS_MAP['fs-' + name] : mm; })
.replace(/\s*!important/g, '')
.replace(/(z-index\s*:\s*)(\d{5,})/gi, function (mm, p, v) { return +v > 9999 ? p + '1000' : mm; })
.replace(/<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/gi, function (mm, col) { return '<span style="color:var(--' + (classify('text', col) || 'text-color-high') + ', ' + col.trim() + ')">'; })
.replace(/<\/font>/gi, '</span>');
return out;
}
var lastFixed = '';
function render(res) {
lastFixed = res.fixed;
var fixes = res.findings.filter(function (x) { return x.level === 'fix'; }).length,
warns = res.findings.length - fixes;
cnt.textContent = res.findings.length ? (res.findings.length + ' problem' + (res.findings.length > 1 ? 's' : '') + ' — ' + fixes + ' auto-fixable, ' + warns + ' manual') : '';
$('#dk-fix-applyall').hidden = !fixes;
if (!res.findings.length) { rep.innerHTML = '<div class="dk-report-empty">Nothing flagged — this already follows the rules. ✓</div>'; return; }
var CAP = 200, shown = res.findings.slice(0, CAP);
rep.innerHTML = '<div class="dk-report">' + shown.map(card).join('') +
(res.findings.length > CAP ? '<p style="color:var(--fs-dim);font-size:13px">…and ' + (res.findings.length - CAP) + ' more — fix these and re-check.</p>' : '') + '</div>';
}
function hl(text, mark) {
var L = esc(text.trim()), M = esc(String(mark).trim());
var i = M ? L.indexOf(M) : -1;
return i < 0 ? L : L.slice(0, i) + '<mark>' + M + '</mark>' + L.slice(i + M.length);
}
function card(x) {
var isFix = x.level === 'fix';
var h = '<div class="dk-diag ' + x.level + '">' +
'<div class="dk-diag-top"><span class="ln">line ' + x.line + '</span>' +
'<span class="tag">' + (isFix ? 'auto-fix' : 'manual') + '</span>' +
'<span class="ti">' + x.title + '</span></div>' +
'<pre class="dk-diag-code">' + hl(x.text, x.mark) + '</pre>';
if (isFix && x.after != null) {
h += '<div class="dk-diag-edit"><span class="was">' + (esc(x.before) || '∅') + '</span>' +
'<span class="arr">→</span>' +
'<code class="now" data-copy="' + escAttr(x.after) + '" title="click to copy the fix">' + (esc(x.after) || '(remove)') + '</code></div>';
}
return h + '<div class="dk-diag-why">' + x.why + '</div></div>';
}
var SAMPLE = [
'/* paste from DevTools — this example trips most rules */',
'.myapp-card {',
' background: #f7f7f7;',
' color: #333;',
' border: 1px solid #ddd !important;',
'}',
'.hidden { display: none }',
':root { --accent: #0d6efd; }',
'.log { background: #1e1e1e; color: #22c55e; }',
'.status { color: var(--warning-color-medium, orange); }',
'.brand { color: var(--fs-accent); border-radius: var(--fs-radius); }',
'.cbi-value { padding: 20px }',
'@media (prefers-color-scheme: dark) { .myapp-card { background: #222 } }',
'',
'// in the view module:',
'document.head.appendChild(style);',
'window.onload = init;',
'editor.setTheme("dracula");'
].join('\n');
$('#dk-fix-run').addEventListener('click', function () { render(run(inEl.value)); });
$('#dk-fix-sample').addEventListener('click', function () { inEl.value = SAMPLE; render(run(SAMPLE)); });
$('#dk-fix-clear').addEventListener('click', function () { inEl.value = ''; rep.innerHTML = ''; cnt.textContent = ''; $('#dk-fix-applyall').hidden = true; });
$('#dk-fix-applyall').addEventListener('click', function () { if (lastFixed) copy(lastFixed, 'copied the whole thing, fixed'); });
})();
/* ---- nav scroll-spy ---- */
var links = Array.prototype.slice.call(document.querySelectorAll('.dk-nav a'));
var secs = links.map(function (a) { return $(a.getAttribute('href')); });
var spy = new IntersectionObserver(function (ents) {
ents.forEach(function (en) {
if (!en.isIntersecting) return;
links.forEach(function (a) { a.classList.toggle('active', a.getAttribute('href') === '#' + en.target.id); });
});
}, { rootMargin: '-40% 0px -55% 0px' });
secs.forEach(function (s) { if (s) spy.observe(s); });
})();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,327 @@
# Styling a LuCI app so it works under any theme
*Русская версия: [luci-app-styling-guide_ru.md](luci-app-styling-guide_ru.md).*
This is written for authors of `luci-app-*` packages. It is not footstrap-specific advice: every
rule below comes from a pattern found in a real, popular app, and every one of them is a bug on
*some* theme — usually a theme the author never tested against. Where footstrap does something
specific, it is called out.
The survey behind it: 20 apps read line by line (OpenClash, passwall/passwall2, ssr-plus, mosdns,
vssr, ssclash, podkop, justclash, homeproxy, nikki, alist, dockerman, diskman, istore, AdGuardHome,
netspeedtest, partexp, taskplan, plus stock `openwrt/luci`), and the top-starred apps rendered on a
live router. Evidence lives in [third-party-apps.md](third-party-apps.md).
---
## 1. CSS lifetime: where you put a `<style>` decides who else it hits
A LuCI view is rendered client-side and swapped in and out of `#view`. A modern theme may be an
SPA: it never reloads the document. So the question "when does my CSS die?" has three different
answers, and only one of them is safe.
**DO — put the `<style>` in the tree you return.** It dies with your view, exactly like the rest
of your markup. Stock LuCI does this in `package-manager.js`, `nftables.js`, `aria2/log.js`.
```js
return E([], [
E('style', [ css ]), // scoped to this view's lifetime by construction
E('div', { 'class': 'myapp-root' }, …)
]);
```
**DON'T — `document.head.appendChild(style)`.** On a full page load it looks harmless, because the
document is thrown away on the next click. Under an SPA theme it stays forever and restyles every
page the user visits afterwards. `luci-app-filemanager` hides the stock Save/Apply/Reset buttons
this way (`.cbi-button-save { display: none !important }`) and every config page in the session
becomes unsavable. `luci-app-banip` and `luci-app-adblock` do it with a `<link>`.
**DON'T — inject at module eval.** `<style>` added at the top level of your module (or by a
library you load, as ACE does with its 14 KB `ace_editor.css`) is created **once per document** and
can never be re-created: your module is cached. A theme that cleans up after you cannot give it
back, and your widget renders structureless. If you must, then at least be idempotent and expect
your sheet to be there for the life of the page.
**How footstrap reacts** (so you can predict it). Nothing is ever deleted. A sheet in `<head>` is
left alone — and SPA navigation keeps working — as long as it cannot paint a page that is not yours:
* it names only your own classes (`.ace_*`, `.hexview`, `.myapp-card`), or
* it names a stock widget but *pins* the rule to your markup with a name of your own —
`#cbi-myapp-section > .cbi-section-remove` and `.myapp-table th.sortable.active` are both fine,
because without your section, or your table, they match nothing. (A `:not()` argument does not
count as a pin: `.cbi-button-save:not(.my-save-button)` still matches every stock save button on
every page.) Or
* it is a bare selector that declares nothing but your own custom properties —
`:root { --myapp-accent: … }` is inert; `:root { color-scheme: light dark }` is not.
Anything else — `.cbi-button-save { display: none }`, `.error { … }`, `h4 { … }`,
`div > label + select { … }`, `svg text { … }` — makes the document unusable for the SPA: the next
navigation falls back to a full page load. Your app still works; the user pays one ordinary page
load. All five of those examples are real, from apps in the top 30 by stars.
---
## 2. Namespace everything you declare
You are a guest in a document you share with the theme and with every other app.
- **Classes and ids:** prefix with your package name — `.myapp-card`, `#cbi-myapp-…`. Do not claim
dictionary words. Seen in the wild and each one a live collision: `.hidden`, `.label`,
`.description`, `.centered`, `.skeleton`, `.toast`, `.dialog-title`, `.flex-row`, `.log-line`,
and `luci-lib-taskd`'s `[hidden] { display: none !important }`.
- **Custom properties:** declare them on your root element, not on `:root`. `:root` is a shared
global scope: `luci-app-filemanager`'s hex editor puts `--clr-background`, `--clr-border`,
`--clr-highlight` there, and the file manager itself writes `color-scheme` there. A theme whose
tokens happen to share a name is silently repainted. (OpenClash gets this right: its ~90
properties are scoped to `.oc`.)
- **Never ship a global reset.** `* { margin: 0; padding: 0 }` (OpenClash, twice) flattens the
theme's own chrome. Neither is a CSS framework you `@import` (`pure.css` in vssr) — it is not
yours to install document-wide.
- **Never restyle stock LuCI selectors from outside your page.** `.cbi-button-up`, `.cbi-value`,
`.cbi-section-table-titles`, `ul.dropdown`, `table th` — the theme owns those, and your rule is
unlayered, so it beats every cascade layer the theme has. Inside your own page, scope it:
`.myapp-root .cbi-value { … }`. (That same fact is also in your favour — see §3.2 below: inside
your own subtree you outrank the theme automatically, and never need `!important`.)
---
## 3. Colour: use the export tier, not literals
Every LuCI theme exposes the same custom-property contract. Read it, with a literal fallback:
```css
color: var(--text-color-high, #333);
background-color: var(--background-color-low, #f5f5f5);
border-color: var(--border-color-medium, #ddd);
/* status colours: primary / success / warn / error, each in high / medium / low */
color: var(--error-color-medium, #f44336);
/* text ON a filled surface has its own ink: */
background-color: var(--success-color-high, #2e7d32);
color: var(--on-success-color, #fff);
```
### 3.1. The export surface is exactly 26 names, and it is colour-only
This is the whole contract. Nothing else is one.
| Family | Names | Notes |
|---|---|---|
| Surfaces | `--background-color-high` / `-medium` / `-low` | an **elevation** axis: `high` = raised (a card), `low` = recessed (the page) |
| Rules | `--border-color-high` / `-medium` / `-low` | `high` = the visible rule, `low` = the hairline |
| Text | `--text-color-highest` / `-high` / `-medium` / `-low` | four levels, not three |
| Accent | `--primary-color-high` / `-medium` / `-low` + `--on-primary-color` | |
| OK | `--success-color-high` / `-medium` / `-low` + `--on-success-color` | |
| Warning | `--warn-color-high` / `-medium` / `-low` + `--on-warn-color` | the family is **`warn`**, not `warning` |
| Error | `--error-color-high` / `-medium` / `-low` + `--on-error-color` | |
**Names that do not exist — anywhere — and therefore always fall through to your literal:**
`--warning-color-*`, `--text-color` (no level), `--secondary-color-*`, `--font-sans`, `--font-mono`,
`--disabled-opacity`. If you are reading one of those, you are not on the tier: you have a
hardcoded colour with extra steps.
- **`--warn-color-medium` is CORRECT.** podkop's `var(--warn-color-medium, orange)` (7 uses) resolves
on footstrap and paints the themed amber. Do not "fix" it to `--warning-color-medium` — that
name is defined by nothing, and the rename would silently drop every one of those 7 declarations
into the `orange` fallback. (An earlier version of this guide said the opposite. It was wrong.)
- **`--text-color` (justclash, 6 uses) really does not exist.** It is `--text-color-high`.
### 3.2. `--fs-*` is footstrap's PRIVATE tier — do not read it
Open `cascade.css` and you will find well over a hundred `--fs-*` custom properties with much better names than the
export tier: `--fs-accent`, `--fs-panel2`, `--fs-radius`, `--fs-dim`, the whole z-index scale. **They
are not yours.** They are internal, they are renamed and re-derived whenever the theme wants to, and
no other LuCI theme has them at all. Only the `--*-color-*` names above are a contract.
The two-tier split is not tidiness — it is the fix for a measured bug, and it is the same bug §2
warns you about from the other side. `:root` is a **shared global scope**, and an app's stylesheet is
**unlayered**, so it outranks every cascade layer the theme has. One app writing
`:root { --accent: … }` — or `--text-color-high`, which is a LuCI *convention* and therefore the
likelier name for an app to declare — used to repaint the theme itself: **312 of 336 elements
changed colour** under a single hostile `:root` block. So footstrap now reads *only* its private
`--fs-*` names internally and derives the export names *from* them. Same measurement after the split:
**0 elements**. (`styles/02-tokens.css`; a CI gate fails the build if any theme rule reads an export
name.)
The practical consequence for you is the good half: **because the theme cannot be repainted by your
`:root` any more, you can override anything the theme draws inside your own subtree — and you never
need `!important` to do it.** Unlayered beats every layer, unconditionally. If you have an
`!important` in your app's CSS to win against the theme, delete it; it was never what was winning.
### 3.3. What the tier guarantees (and what to do with the guarantee)
footstrap proves all of this in CI (`tools/export-tier.mjs`) across the full matrix of
{light, dark} × {default, hicontrast, bootstrap, 2020, forum} × seven tint values — 70 combinations:
- **Every level of `text` / `primary` / `success` / `warn` / `error` clears WCAG AA as TEXT** on all
three `--background-color-*` surfaces. So you may paint any of them as a `color:` and not check.
- **The matching `--on-*-color` clears AA *on top of* its colour used as a fill.** So: **to put text
on a coloured fill, take the ink from `--on-*-color`. Do not invent one.** A hardcoded `#fff` on a
filled badge is the classic failure — it fell to **1.69:1** on seven of the eight dark-palette fills
before footstrap made the inks per-palette and per-mode.
- **`high` / `medium` / `low` are three genuinely different colours**, not three aliases. If you paint
"no data" in `--primary-color-low`, it *will* read quieter than a live value. (Two caveats worth
knowing: `--background-color-*` runs the other way, `high` = raised; and `-low` is genuinely the
faintest step a palette can afford — every colourway holds it above the AA floor, so text painted
in it is legible but has no room to be made fainter still.)
### 3.4. The palette and tint axes come free — but only on the tier
footstrap is not just light/dark. `:root[data-palette="hicontrast"]` swaps the entire colour set, and
a Tint slider re-hues every surface (`:root[data-tint]` with `--fs-tint-h` at any hue 1–360). Both
axes move the export tier with them — which is why the CI matrix above sweeps exactly those axes.
An app on the tier follows all of it for free and stays legible. A hardcoded "close enough" colour
does not move: a `#f7f7f7` card chosen to match the default panel is wrong the instant the user picks
hicontrast, and a hand-picked amber is wrong the instant they drag the tint 40°. **This is the real
argument for this whole section** — not that literals are ugly, but that they are only right on the
one configuration you happened to look at.
### 3.5. The rest
- **Never hardcode a surface.** `background: white` on a `position: fixed` modal (passwall) is a
white box on a dark page. `#1e1e1e` on a log terminal (justclash) is a black box on a light page.
- **Never put text of colour C on a translucent tint of C.** The tint drags the background toward
the text and eats its own contrast, and being translucent, the result depends on whatever is
underneath — so no percentage is safe everywhere. Give the chip an opaque surface and let the
border carry the colour.
- `<font color="green">` (dockerman, ssr-plus, OpenClash) is a status readout that vanishes on half
the palettes. Use `--success-color-medium`.
---
## 4. Dark mode: `prefers-color-scheme` is the wrong question
`@media (prefers-color-scheme: dark)` reports the operating system, not the theme. Every modern
LuCI theme has its own light/dark switch, and a user who forces dark on a light OS gets your light
card on their dark page. netspeedtest, vssr, passwall and stock `ustreamer` all have this bug.
Ask the theme instead, in this order:
```js
function isDark() {
const root = document.documentElement;
if (root.dataset.darkmode) return root.dataset.darkmode === 'true'; // OpenClash + luci-app-internet-detector use this
if (root.dataset.theme) return root.dataset.theme === 'dark'; // footstrap, justclash's convention
if (root.dataset.bsTheme) return root.dataset.bsTheme === 'dark'; // Bootstrap-5 convention
const m = getComputedStyle(document.body).backgroundColor.match(/\d+/g); // works on any theme
return m && m.length >= 3
? (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255 < 0.5
: false;
}
```
There are four dialects in the wild, and footstrap stamps three of them — `data-darkmode`,
`data-theme`, `data-bs-theme` — on `:root` before first paint, so any of these checks works.
`luci-app-internet-detector` keys its dark block off `:root[data-darkmode="true"]` and therefore
looks right here with no changes at all.
In CSS, the same thing:
```css
.myapp-card { background: var(--background-color-low, #fff); } /* follows the theme already */
:root[data-theme="dark"] .myapp-card { /* only if you truly need a dark-specific override */ }
```
- footstrap stamps `data-darkmode`, `data-theme` and `data-bs-theme` on `:root`, before first
paint, precisely so all three dialects work.
- The luminance fallback works everywhere — but only because the theme keeps `body`'s background
**opaque**. Do not assume any single attribute exists.
- **Best of all: do not detect at all.** If every colour you use comes from the export tier, dark
mode is already handled for you — that is what the tier is for. `luci-app-nikki` reads zero
theme state and looks right everywhere.
---
## 5. Editors and other heavy widgets
- **Do not hardcode the editor's colour theme.** mosdns and AdGuardHome pin CodeMirror to
`dracula`, ssclash pins ACE to `tomorrow_night_bright`: a permanently black editor on a light
page. Pick the editor theme from `isDark()` above (OpenClash does this correctly:
`themeExtension(isDark)`).
- **Do not install a shared library into a shared path.** AdGuardHome ships its CodeMirror to
`/luci-static/resources/codemirror/`; two apps shipping different versions overwrite each other.
Put it under `/luci-static/resources/view/<yourapp>/`.
- **Iframes:** stock `luci-app-ttyd` is the reference — size it `width: 100%; min-height: …;
resize: vertical`, take the port from UCI, and mirror the page's protocol. Do not hardcode a port
(dockerman: `:7682`) and do not paint the iframe `background: #fff` (netspeedtest) — the embedded
page decides its own colours.
- **`z-index`:** stay inside a sane range. passwall's dropdown sits at `2147483647` and OpenClash's
fullscreen editor at `999999 !important` — a theme's own overlays and dropdowns cannot coexist
with that. Nothing you render needs to be above everything ever.
- Never `body { pointer-events: none }` (passwall, while its modal is open): if anything navigates
while the class is on, the entire UI is dead.
---
## 6. Layout: the viewport is not your column
A theme puts a sidebar next to your page. On footstrap that is 224 px, and on a 1024 px screen your
content column is ~740 px wide while `@media (max-width: 768px)` still says "desktop". Every app in
the survey that stacked its cards by a viewport query (justclash 56rem, podkop 900 px, netspeedtest
768 px) breaks in exactly that band.
- Prefer `@container` queries, or measure your own container and set a class.
- Do not force `table-layout: fixed` plus a `min-width` on every cell (filemanager) — that defeats
any responsive card-stacking a theme does.
- Avoid fixed px widths on containers; `width: 100%; max-width: …` is what you meant.
---
## 7. Be SPA-friendly (and it costs you nothing on a classic theme)
A modern theme may re-instantiate your view without ever reloading the document.
- `window.onload` and `DOMContentLoaded` fire **once per document**. A handler registered there
(diskman, passwall's `optimize_cbi_ui.htm`) silently stops working after the first navigation.
Do your work in `render()`.
- Clear your own `setInterval`s and pollers when the view goes away; register pollers through
`L.Poll` and LuCI will do it for you.
- Do not append persistent nodes to `document.body` (podkop's toast container, taskd's dialog):
they survive the view swap. Put them in your view tree.
- Do not add a document-level `popstate` handler that navigates (istore) — you will race the
theme's router and win, wrongly.
- **Do not hide the stock buttons with CSS.** Set `handleSave`, `handleSaveApply` and `handleReset`
to `null` in your view (as `luci-app-partexp` and `luci-app-ttyd` do). That is the supported way,
it needs no `!important`, and it does not leak onto the next page.
---
## 8. Checklist
Tick every line before you publish:
- [ ] `<style>` returned inside the view tree, not appended to `<head>`
- [ ] Every class, id and custom property prefixed with the app name
- [ ] No `:root { … }`, no `* { … }`, no imported CSS framework
- [ ] No stock `.cbi-*` / `table` / `pre` selector styled outside the app's own subtree
- [ ] All colours from the 26 `--*-color-*` export names, with a literal fallback; `warn`, not `warning`
- [ ] No `--fs-*` read anywhere — that is footstrap's private tier, not a contract
- [ ] Text on a coloured fill takes its ink from `--on-*-color`, never a hardcoded `#fff`
- [ ] No `!important` used to beat the theme; your CSS is unlayered and already outranks every layer
- [ ] Dark mode read from `data-theme` / `data-bs-theme` / body luminance, never `prefers-color-scheme`
- [ ] Editor theme chosen from the page's mode, not hardcoded
- [ ] Layout keyed to the container, not the viewport
- [ ] `handleSaveApply = null` instead of hiding stock buttons
- [ ] No `window.onload`, no body-level leftovers, no `popstate` hijack, no `z-index: 2147483647`
Apps to read as references: **`luci-app-nikki`** (zero injected CSS, zero colour literals, stock forms
only), stock **`luci-app-ttyd`** (the iframe done right), **`luci-app-smartdns`** (its `<style>` is
returned inside the `.cbi-map` it belongs to) and **`luci-app-internet-detector`** (namespaced
`:root { --app-id-* }` custom properties, and a dark block keyed off the theme's attribute).
---
## 9. How much of this is actually broken today
Two rounds of the survey, 30 apps, rendered on a live router under this theme:
* **Most apps are fine.** Of the ten next-most-starred apps (quickfile, tailscale-community,
smartdns, internet-detector, 3ginfo-lite, cloudflarespeedtest, aliddns, koolproxy, ech-workers,
sms-tool), all ten keep SPA navigation and none injects anything that can paint another page.
* **The breakage is concentrated in the big, old ones**, and it is nearly always the same two
mistakes: CSS put in `<head>` instead of the view tree, and colour hardcoded instead of taken from
the export tier.
* **Legacy Lua/CBI apps** (OpenClash, passwall, dockerman, diskman, AdGuardHome, koolproxy, sms-tool)
are server-rendered, so their `<style>` lives and dies with their own page — but their hardcoded
light-mode surfaces (`#f7f7f7` cards, `background: white` dialogs) still look wrong on a dark page,
and no theme can fix that from the outside.
@@ -0,0 +1,339 @@
# Как стилизовать приложение LuCI, чтобы оно работало под любой темой
*English version: [luci-app-styling-guide.md](luci-app-styling-guide.md). Это единственная
страница `docs/`, у которой есть русское зеркало: остальная документация — на английском.*
Написано для авторов пакетов `luci-app-*`. Это не советы «под footstrap»: каждое правило ниже взято
из паттерна, найденного в реальном популярном приложении, и каждое из них — баг на *какой-нибудь*
теме, обычно на той, против которой автор не проверял. Где footstrap делает что-то своё, это
оговорено отдельно.
Основание: 20 приложений прочитаны построчно (OpenClash, passwall/passwall2, ssr-plus, mosdns,
vssr, ssclash, podkop, justclash, homeproxy, nikki, alist, dockerman, diskman, istore, AdGuardHome,
netspeedtest, partexp, taskplan, плюс стоковые `openwrt/luci`), а самые звёздные — отрисованы на
живом роутере. Фактура — в [third-party-apps.md](third-party-apps.md).
---
## 1. Время жизни CSS: куда положен `<style>`, тот и решает, кого он заденет
Вьюха LuCI рисуется на клиенте и подменяется внутри `#view`. Современная тема может быть SPA — она
не перезагружает документ. Поэтому у вопроса «когда мой CSS умрёт?» три разных ответа, и безопасен
только один.
**НАДО — класть `<style>` в то дерево, которое вы возвращаете.** Он умрёт вместе с вьюхой, ровно как
остальная ваша разметка. Стоковый LuCI так и делает в `package-manager.js`, `nftables.js`,
`aria2/log.js`.
```js
return E([], [
E('style', [ css ]), // по построению живёт ровно столько, сколько вьюха
E('div', { 'class': 'myapp-root' }, …)
]);
```
**НЕЛЬЗЯ — `document.head.appendChild(style)`.** На полной загрузке выглядит безобидно: документ всё
равно выбрасывается на следующем клике. Под SPA-темой он остаётся навсегда и перекрашивает каждую
страницу, куда пользователь зайдёт дальше. `luci-app-filemanager` именно так прячет стоковые
Save/Apply/Reset (`.cbi-button-save { display: none !important }`) — и все config-страницы за сессию
становятся несохраняемыми. `luci-app-banip` и `luci-app-adblock` делают то же через `<link>`.
**НЕЛЬЗЯ — инжектить на eval модуля.** `<style>`, добавленный на верхнем уровне модуля (или
библиотекой, которую вы грузите, — так делает ACE со своим 14-килобайтным `ace_editor.css`),
создаётся **один раз на документ** и не может быть создан заново: модуль закэширован. Тема, которая
за вами приберёт, вернуть его не сможет, и ваш виджет отрисуется без структуры. Если иначе никак —
хотя бы будьте идемпотентны и рассчитывайте, что ваш лист проживёт всю жизнь страницы.
**Как реагирует footstrap** (чтобы это можно было предсказать). Не удаляется ничего никогда. Лист в
`<head>` остаётся нетронутым — и SPA-навигация продолжает работать, — пока он не может покрасить
**чужую** страницу:
* он называет только ваши классы (`.ace_*`, `.hexview`, `.myapp-card`), или
* он называет стоковый виджет, но прибивает правило к вашей разметке собственным именем:
`#cbi-myapp-section > .cbi-section-remove` и `.myapp-table th.sortable.active` оба в порядке,
потому что без вашей секции или вашей таблицы они не матчат ничего. (Аргумент `:not()` пином
**не** считается: `.cbi-button-save:not(.my-save-button)` по-прежнему матчит каждую стоковую
кнопку сохранения на каждой странице.) Или
* это голый селектор, объявляющий только ваши собственные кастомные свойства:
`:root { --myapp-accent: … }` инертен, а `:root { color-scheme: light dark }` — нет.
Всё остальное — `.cbi-button-save { display: none }`, `.error { … }`, `h4 { … }`,
`div > label + select { … }`, `svg text { … }` — делает документ непригодным для SPA: следующая
навигация уходит в полную перезагрузку страницы. Приложение продолжает работать; пользователь платит
одну обычную загрузку. Все пять примеров настоящие, из приложений первой тридцатки по звёздам.
---
## 2. Именуйте всё, что объявляете
Вы гость в документе, который делите с темой и со всеми остальными приложениями.
- **Классы и id:** префикс — имя вашего пакета: `.myapp-card`, `#cbi-myapp-…`. Не занимайте
словарные слова. Встречено в дикой природе, и каждое — живая коллизия: `.hidden`, `.label`,
`.description`, `.centered`, `.skeleton`, `.toast`, `.dialog-title`, `.flex-row`, `.log-line`,
а также `[hidden] { display: none !important }` у `luci-lib-taskd`.
- **Кастомные свойства:** объявляйте их на своём корневом элементе, а не на `:root`. `:root` —
общая глобальная область: hex-редактор из `luci-app-filemanager` кладёт туда `--clr-background`,
`--clr-border`, `--clr-highlight`, а сам файл-менеджер пишет туда `color-scheme`. Тема, у которой
токен назван так же, молча перекрашивается. (OpenClash здесь прав: его ~90 свойств заскоуплены
на `.oc`.)
- **Никогда не везите глобальный ресет.** `* { margin: 0; padding: 0 }` (OpenClash, дважды)
расплющивает собственный хром темы. То же с CSS-фреймворком через `@import` (`pure.css` в vssr) —
не вам ставить его на весь документ.
- **Никогда не перестилизуйте стоковые селекторы LuCI за пределами своей страницы.**
`.cbi-button-up`, `.cbi-value`, `.cbi-section-table-titles`, `ul.dropdown`, `table th` — ими
владеет тема, а ваше правило вне слоёв, то есть бьёт любой каскадный слой темы. Внутри своей
страницы — заскоупьте: `.myapp-root .cbi-value { … }`. (Тот же факт работает и в вашу пользу — см.
§3.2: внутри своего поддерева вы выигрываете у темы автоматически и `!important` вам не нужен
никогда.)
---
## 3. Цвет: берите экспортный ярус, а не литералы
Каждая тема LuCI отдаёт наружу один и тот же контракт кастомных свойств. Читайте его — с литеральным
фолбэком:
```css
color: var(--text-color-high, #333);
background-color: var(--background-color-low, #f5f5f5);
border-color: var(--border-color-medium, #ddd);
/* статусные цвета: primary / success / warn / error, каждый в high / medium / low */
color: var(--error-color-medium, #f44336);
/* у текста ПОВЕРХ заливки свои чернила: */
background-color: var(--success-color-high, #2e7d32);
color: var(--on-success-color, #fff);
```
### 3.1. Экспортная поверхность — ровно 26 имён, и она только про цвет
Это весь контракт. Больше контрактом не является ничто.
| Семейство | Имена | Замечания |
|---|---|---|
| Поверхности | `--background-color-high` / `-medium` / `-low` | ось **высоты**: `high` — приподнятое (карточка), `low` — утопленное (страница) |
| Линии | `--border-color-high` / `-medium` / `-low` | `high` — видимая линия, `low` — волосяная |
| Текст | `--text-color-highest` / `-high` / `-medium` / `-low` | четыре уровня, не три |
| Акцент | `--primary-color-high` / `-medium` / `-low` + `--on-primary-color` | |
| Успех | `--success-color-high` / `-medium` / `-low` + `--on-success-color` | |
| Предупреждение | `--warn-color-high` / `-medium` / `-low` + `--on-warn-color` | семейство называется **`warn`**, не `warning` |
| Ошибка | `--error-color-high` / `-medium` / `-low` + `--on-error-color` | |
**Имена, которых не существует нигде и которые поэтому всегда проваливаются в ваш литерал:**
`--warning-color-*`, `--text-color` (без уровня), `--secondary-color-*`, `--font-sans`,
`--font-mono`, `--disabled-opacity`. Если вы читаете одно из них — вы не на ярусе: у вас захардкоженный
цвет, только длиннее.
- **`--warn-color-medium` — ПРАВИЛЬНОЕ имя.** У podkop `var(--warn-color-medium, orange)` (7
использований) резолвится на footstrap и красит тематической охрой. Не «чините» его на
`--warning-color-medium`: такое имя не определено ничем, и переименование молча уронило бы все семь
объявлений в фолбэк `orange`. (Ранняя редакция этого гайда утверждала обратное. Это была ошибка.)
- **`--text-color` (justclash, 6 использований) действительно не существует.** Правильное —
`--text-color-high`.
### 3.2. `--fs-*` — ПРИВАТНЫЙ ярус footstrap, не читайте его
Откройте `cascade.css` — там найдётся больше сотни кастомных свойств `--fs-*` с именами куда приятнее
экспортных: `--fs-accent`, `--fs-panel2`, `--fs-radius`, `--fs-dim`, вся шкала z-index. **Они не
ваши.** Они внутренние, тема переименовывает и перевыводит их когда захочет, и ни у одной другой темы
LuCI их нет вообще. Контракт — только имена `--*-color-*` выше.
Разделение на два яруса — не аккуратизм, а починка измеренного бага, причём того же самого, о котором
§2 предупреждает с другой стороны. `:root` — **общая глобальная область**, а таблица стилей
приложения вне слоёв, то есть бьёт любой каскадный слой темы. Одно приложение, написавшее
`:root { --accent: … }` — или `--text-color-high`, которое является *конвенцией* LuCI и потому
объявляется приложением охотнее, — перекрашивало саму тему: под одним враждебным блоком `:root`
**меняли цвет 312 элементов из 336**. Поэтому footstrap теперь читает внутри себя только
приватные `--fs-*`, а экспортные имена выводит из них. Тот же замер после разделения: **0
элементов**. (`styles/02-tokens.css`; CI-гейт валит сборку, если хоть одно правило темы прочитает
экспортное имя.)
Практическое следствие для вас — хорошая половина: **раз тему больше нельзя перекрасить вашим
`:root`, вы можете переопределить внутри собственного поддерева всё, что рисует тема, — и
`!important` для этого не нужен никогда.** Внеслойное бьёт любой слой безусловно. Если в CSS вашего
приложения есть `!important`, чтобы победить тему, — удалите его: побеждал не он.
### 3.3. Что ярус гарантирует (и что с этой гарантией делать)
footstrap доказывает всё это в CI (`tools/export-tier.mjs`) по полной матрице
{светлая, тёмная} × {дефолт, hicontrast, bootstrap, 2020, forum} × семь значений тинта — 70 сочетаний:
- **Каждый уровень `text` / `primary` / `success` / `warn` / `error` проходит WCAG AA как ТЕКСТ** на
всех трёх поверхностях `--background-color-*`. То есть любой из них можно ставить в `color:` и не
проверять.
- **Парный `--on-*-color` проходит AA поверх своего цвета, использованного заливкой.** Отсюда:
**чтобы положить текст на цветную заливку, берите чернила из `--on-*-color`. Не выдумывайте свои.**
Захардкоженный `#fff` на залитом бейдже — классический провал: до того, как footstrap развёл
чернила по палитрам и режимам, он падал до **1.69:1** на семи из восьми тёмных заливок.
- **`high` / `medium` / `low` — три по-настоящему разных цвета**, а не три алиаса. Если вы красите
«нет данных» в `--primary-color-low`, оно действительно прочитается тише живого значения. (Две
оговорки: у `--background-color-*` ось идёт наоборот, `high` — приподнятое; и `-low` — это и есть
самая бледная ступень, которую палитра может себе позволить: во всех раскладках она держится выше
порога AA, так что текст в ней читается, но бледнее уже не будет.)
### 3.4. Оси палитры и тинта достаются даром — но только на ярусе
footstrap — это не просто светлая и тёмная. `:root[data-palette="hicontrast"]` подменяет весь набор
цветов, а слайдер Tint перекрашивает оттенок всех поверхностей (`:root[data-tint]` с `--fs-tint-h` на
любом оттенке 1–360°). Обе оси тянут экспортный ярус за собой — потому матрица CI выше и метёт именно
эти оси.
Приложение на ярусе следует за всем этим бесплатно и остаётся читаемым. Захардкоженный «примерно
такой же» цвет не двигается: карточка `#f7f7f7`, подобранная под дефолтную панель, становится неверной
в ту же секунду, как пользователь выбрал hicontrast, а вручную подобранная охра — как только он сдвинул
тинт на 40°. **Это и есть настоящий аргумент всего раздела**: дело не в том, что литералы некрасивы, а
в том, что они верны ровно на одной конфигурации — той, на которую вы смотрели.
### 3.5. Остальное
- **Никогда не хардкодьте поверхность.** `background: white` на `position: fixed`-модалке (passwall) —
это белая коробка на тёмной странице. `#1e1e1e` на терминале логов (justclash) — чёрная коробка на
светлой.
- **Никогда не кладите текст цвета C на полупрозрачный тинт того же C.** Тинт тянет фон к цвету
текста и съедает собственный контраст, а будучи полупрозрачным — зависит от того, что под ним, так
что безопасного процента не существует. Дайте чипу непрозрачную поверхность, а цвет пусть несёт
рамка.
- `<font color="green">` (dockerman, ssr-plus, OpenClash) — это индикатор статуса, исчезающий на
половине палитр. Используйте `--success-color-medium`.
---
## 4. Тёмная тема: `prefers-color-scheme` — не тот вопрос
`@media (prefers-color-scheme: dark)` сообщает о операционной системе, а не о теме. У любой
современной темы LuCI есть свой переключатель светлой и тёмной, и пользователь, включивший тёмную на
светлой ОС, получит вашу светлую карточку на своей тёмной странице. У netspeedtest, vssr, passwall и
стокового `ustreamer` этот баг есть.
Спрашивайте тему, в таком порядке:
```js
function isDark() {
const root = document.documentElement;
if (root.dataset.darkmode) return root.dataset.darkmode === 'true'; // так делают OpenClash и luci-app-internet-detector
if (root.dataset.theme) return root.dataset.theme === 'dark'; // footstrap, конвенция justclash
if (root.dataset.bsTheme) return root.dataset.bsTheme === 'dark'; // конвенция Bootstrap 5
const m = getComputedStyle(document.body).backgroundColor.match(/\d+/g); // работает на любой теме
return m && m.length >= 3
? (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255 < 0.5
: false;
}
```
В дикой природе четыре диалекта, и footstrap штампует три из них — `data-darkmode`, `data-theme`,
`data-bs-theme` — на `:root` до первой отрисовки, так что сработает любая из этих проверок.
`luci-app-internet-detector` вешает свой тёмный блок на `:root[data-darkmode="true"]` и потому
выглядит правильно вообще без единой правки.
В CSS то же самое:
```css
.myapp-card { background: var(--background-color-low, #fff); } /* уже следует за темой */
:root[data-theme="dark"] .myapp-card { /* только если вам правда нужен тёмно-специфичный оверрайд */ }
```
- footstrap штампует `data-darkmode`, `data-theme` и `data-bs-theme` на `:root` до первой
отрисовки — именно затем, чтобы работали все три диалекта.
- Фолбэк по яркости работает везде, но лишь потому, что тема держит фон `body` непрозрачным. Не
считайте, что какой-то один атрибут существует.
- **Лучше всего — не детектить вообще.** Если каждый ваш цвет взят с экспортного яруса, тёмная тема
уже обработана за вас — ярус ровно для этого и существует. `luci-app-nikki` не читает ни бита
состояния темы и выглядит правильно везде.
---
## 5. Редакторы и прочие тяжёлые виджеты
- **Не хардкодьте цветовую тему редактора.** mosdns и AdGuardHome прибивают CodeMirror к `dracula`,
ssclash прибивает ACE к `tomorrow_night_bright` — вечно чёрный редактор на светлой странице.
Выбирайте тему редактора по `isDark()` выше (OpenClash делает это правильно:
`themeExtension(isDark)`).
- **Не ставьте общую библиотеку в общий путь.** AdGuardHome везёт свой CodeMirror в
`/luci-static/resources/codemirror/`; два приложения с разными версиями перезапишут друг друга.
Кладите под `/luci-static/resources/view/<yourapp>/`.
- **Iframe:** эталон — стоковый `luci-app-ttyd`: `width: 100%; min-height: …; resize: vertical`, порт
из UCI, протокол зеркалит протокол страницы. Не хардкодьте порт (dockerman: `:7682`) и не красьте
iframe в `background: #fff` (netspeedtest) — встроенная страница решает свои цвета сама.
- **`z-index`:** держитесь вменяемого диапазона. Дропдаун passwall сидит на `2147483647`, полноэкранный
редактор OpenClash — на `999999 !important`; собственные оверлеи и выпадашки темы с этим сосуществовать
не могут. Ничему из того, что вы рисуете, не нужно быть выше всего на свете.
- Никогда `body { pointer-events: none }` (passwall, пока открыта его модалка): если при включённом
классе что-нибудь навигирует, весь интерфейс мёртв.
---
## 6. Раскладка: вьюпорт — это не ваша колонка
Тема ставит сайдбар рядом с вашей страницей. У footstrap это 224px, и на экране 1024px ваша
контентная колонка шириной ~740px, тогда как `@media (max-width: 768px)` всё ещё говорит «десктоп».
Каждое приложение из обзора, складывающее карточки по вьюпортному запросу (justclash 56rem, podkop
900px, netspeedtest 768px), ломается ровно в этой полосе.
- Предпочитайте `@container`-запросы — или меряйте свой контейнер сами и ставьте класс.
- Не навязывайте `table-layout: fixed` плюс `min-width` на каждую ячейку (filemanager) — это убивает
любое адаптивное сворачивание таблицы в карточки, которое делает тема.
- Избегайте фиксированных ширин в px у контейнеров; `width: 100%; max-width: …` — это то, что вы
имели в виду.
---
## 7. Дружите с SPA (на классической теме это ничего не стоит)
Современная тема может переинстанцировать вашу вьюху, ни разу не перезагрузив документ.
- `window.onload` и `DOMContentLoaded` срабатывают **один раз на документ**. Обработчик, повешенный
там (diskman, `optimize_cbi_ui.htm` у passwall), молча перестаёт работать после первой навигации.
Делайте работу в `render()`.
- Гасите свои `setInterval` и поллеры, когда вьюха уходит; регистрируйте поллеры через `L.Poll` — и
LuCI сделает это за вас.
- Не добавляйте долгоживущие узлы в `document.body` (контейнер тостов podkop, диалог taskd): они
переживают подмену вьюхи. Кладите их в дерево вьюхи.
- Не вешайте документный обработчик `popstate`, который навигирует (istore), — вы обгоните роутер
темы и выиграете, причём неправильно.
- **Не прячьте стоковые кнопки через CSS.** Ставьте `handleSave`, `handleSaveApply` и `handleReset`
в `null` в своей вьюхе (как делают `luci-app-partexp` и `luci-app-ttyd`). Это штатный способ, ему
не нужен `!important`, и он не протекает на следующую страницу.
---
## 8. Чек-лист
Отметьте каждый пункт перед публикацией:
- [ ] `<style>` возвращается внутри дерева вьюхи, а не добавляется в `<head>`
- [ ] Каждый класс, id и кастомное свойство с префиксом имени приложения
- [ ] Ни `:root { … }`, ни `* { … }`, ни импортированного CSS-фреймворка
- [ ] Ни один стоковый `.cbi-*` / `table` / `pre` не стилизуется вне своего поддерева
- [ ] Все цвета — из 26 экспортных имён `--*-color-*` с литеральным фолбэком; `warn`, не `warning`
- [ ] Нигде не читается `--fs-*` — это приватный ярус footstrap, а не контракт
- [ ] Текст на цветной заливке берёт чернила из `--on-*-color`, а не захардкоженный `#fff`
- [ ] `!important` не используется, чтобы победить тему: ваш CSS вне слоёв и уже бьёт любой слой
- [ ] Тёмная тема читается из `data-theme` / `data-bs-theme` / яркости body, а не из `prefers-color-scheme`
- [ ] Тема редактора выбирается по режиму страницы, а не захардкожена
- [ ] Раскладка завязана на контейнер, а не на вьюпорт
- [ ] `handleSaveApply = null` вместо прятания стоковых кнопок
- [ ] Ни `window.onload`, ни остатков на уровне body, ни перехвата `popstate`, ни `z-index: 2147483647`
Приложения, которые стоит прочесть как образцы: **`luci-app-nikki`** (ноль инжектнутого CSS, ноль
цветовых литералов, только стоковые формы), стоковый **`luci-app-ttyd`** (правильно сделанный
iframe), **`luci-app-smartdns`** (его `<style>` возвращается внутри той `.cbi-map`, которой он
принадлежит) и **`luci-app-internet-detector`** (кастомные свойства в своём неймспейсе
`:root { --app-id-* }` и тёмный блок на атрибуте темы).
---
## 9. Насколько всё это сломано на практике
Два круга обзора, 30 приложений, отрисованных на живом роутере под этой темой:
* **Большинство приложений в порядке.** Из десяти следующих по звёздам (quickfile,
tailscale-community, smartdns, internet-detector, 3ginfo-lite, cloudflarespeedtest, aliddns,
koolproxy, ech-workers, sms-tool) все десять сохраняют SPA-навигацию, и ни одно не инжектит
ничего, чем можно покрасить чужую страницу.
* **Поломки сосредоточены в больших и старых**, и это почти всегда одни и те же две ошибки: CSS
положен в `<head>` вместо дерева вьюхи, и цвет захардкожен вместо взятого с экспортного яруса.
* **Легаси-приложения на Lua/CBI** (OpenClash, passwall, dockerman, diskman, AdGuardHome, koolproxy,
sms-tool) рендерятся сервером, поэтому их `<style>` живёт и умирает вместе с их собственной
страницей — но их захардкоженные светлые поверхности (карточки `#f7f7f7`, диалоги
`background: white`) всё равно выглядят неправильно на тёмной странице, и снаружи этого не починит
ни одна тема.
+492
View File
@@ -0,0 +1,492 @@
# The package: source tree, Makefile, install scripts
Reference for what is where in `luci-theme-footstrap/`, what the Makefile does that a template
theme would not, and what runs on the router at install and removal time.
How the packages are actually built and published: [ci.md](ci.md).
## Source tree
```
luci-theme-footstrap/
├── Makefile
├── build-css.sh styles/ → cascade.css (cat + awk, no node)
├── mangle-tokens.sh shorten the private --fs-* names in a BUILT sheet
├── strip-templates.sh drop the comments from .ut (template and whole-line code)
├── strip-shell.sh drop whole-line # from root/**.sh
├── build-apk.sh SDK build, kept so the theme stays buildable without owfeed
├── dev-sync.sh deploy to a HARDWARE router over ssh (containers use owlab)
├── update-po.sh regenerate/verify the translation catalogue
├── luci-upstream.pin pinned openwrt/luci commit + sha256 of the borrowed tools
├── styles/ CSS SOURCE. Not shipped — luci.mk does not copy it
├── po/ translation catalogue; luci.mk turns it into luci-i18n-* packages
│ ├── templates/footstrap.pot
│ ├── ru/footstrap.po
│ └── es/footstrap.po
├── htdocs/luci-static/ → /www/luci-static/
│ ├── footstrap/ cascade.css (GENERATED, gitignored), logo.svg,
│ │ manifest.json + app-icon-192.png (ONE raster: every browser that
│ │ installs a page picks the largest icon and downscales, and iOS reads
│ │ the apple-touch-icon LINK, which points at the same file)
│ │ (the raster is COMMITTED and made by tools/build-icons.mjs — the
│ │ buildbot has no browser; `npm run icons` proves it matches logo.svg)
│ │ (pattern.svg and fonts/ are NOT here: they are symlinks uci-defaults
│ │ makes to /etc/footstrap/, which the admin uploads or installs)
│ └── resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
├── root/ → /
│ ├── etc/uci-defaults/30_luci-theme-footstrap
│ ├── etc/config/footstrap empty stub, written at runtime
│ ├── lib/upgrade/keep.d/luci-theme-footstrap what sysupgrade carries across a flash
│ └── usr/share/rpcd/acl.d/luci-theme-footstrap.json
└── ucode/template/themes/footstrap/ → /usr/share/ucode/luci/template/…
├── header.ut footer.ut sysauth.ut
└── partials/{head,brand,logout,notices,notice,search,icon,footer}.ut
```
`luci.mk` installs by directory presence — no install recipes needed:
| Source | Installed to |
|---|---|
| `ucode/*` | `/usr/share/ucode/luci/` |
| `htdocs/*` | `/www/` |
| `root/*` | `/` |
| `root/etc/uci-defaults/*` | picked up as `LUCI_DEFAULTS` |
Only `src/ luasrc/ htdocs/ root/ ucode/ po/` are copied verbatim. `styles/` is not in that list,
which is why `cascade.css` is generated in `Build/Prepare` straight into the build tree and is
absent from git.
**Never edit `cascade.css`.** Colours go in `styles/03-palettes.css`, scales and tokens in
`styles/02-tokens.css`. See [css.md](css.md).
**The web manifest is a static file, and that is a constraint, not a shortcut.** A theme may not
register a dispatcher node — it would outlive the theme that registered it — so there is nothing that
could render the manifest per request. Two values are fixed by that: `start_url`/`scope` are
`/cgi-bin/luci/`, uhttpd's own default, and `background_color`/`theme_color` are the DEFAULT palette's
page colour, since the manifest is read once at install time and cannot follow a live Appearance
change (it paints the splash and the installed window's chrome, never the page). Chrome's install
prompt needs a secure context, so over plain HTTP what this buys is iOS's Add to Home Screen — which
reads the `apple-touch-icon` link rather than the manifest — plus the icon itself. Regenerate it with
`node tools/build-icons.mjs` whenever `logo.svg` changes; `npm run icons` fails if you forget.
The committed file is **quantised to a 32-colour palette**, which is 4.6 KB where the browser's own
RGBA screenshot is 15.7 KB: the picture is a flat background, one ink colour and the ramp between
them, so a palette is the right encoding and the worst channel moves by 18 of 255 on 0.2% of the
pixels. That step is the one thing in this repo that wants **ImageMagick**, and only when
regenerating — `npm run icons` compares PIXELS in the browser it already runs, so a different
ImageMagick version is not a failure and a redrawn logo still is. Nothing in luci-base could stand in
for the icon, which is worth stating: it ships functional glyphs (interfaces, signal bars, ports) and
no logo or raster of any kind, and every theme carries its own.
## Makefile: what differs from a template theme
```makefile
PKG_NAME:=luci-theme-footstrap
LUCI_NAME:=luci-theme-footstrap # pin: luci.mk keys the Build/Prepare hook name on LUCI_NAME,
# which defaults to the checkout directory — the CSS build
# would silently not run in a renamed checkout
FOOTSTRAP_VERSION?= # CI injects it from the tag; locally the version is git-derived
LUCI_TITLE:=Footstrap Theme
LUCI_DEPENDS:=+luci-base # the WHOLE dependency list
LUCI_PKGARCH:=all # noarch: one build for every target
LUCI_MAINTAINER / LUCI_URL # otherwise the package claims "OpenWrt LuCI community"
LUCI_MINIFY_CSS:=0 # see below
PKG_LICENSE:=Apache-2.0 # the theme, and nothing else: it carries no webfonts
include $(TOPDIR)/feeds/luci/luci.mk # ABSOLUTE, not ../../luci.mk: CI rsyncs the package into
# package/, not into the feed
```
**Do not set `PKG_VERSION`.** `luci.mk` derives it from git; CI injects `FOOTSTRAP_VERSION` from
the tag, because an SDK build has no `.git` to derive from.
### Minification: CSS off, JS on two paths
Two different tools; confusing them is expensive.
- **`LUCI_MINIFY_CSS:=0` is mandatory.** luci.mk's CSS minifier is **csstidy**, old enough to
mangle `:has()`, `color-mix()` and nested `calc()`: the package installs and the layout falls
apart. `build-css.sh` minifies instead — a string-aware awk pass of its own.
- **`LUCI_MINIFY_JS` has two paths.** A release CI build pre-minifies with **terser**
(`tools/minify-js.mjs`, which can mangle identifiers — jsmin cannot) and sets
`FOOTSTRAP_PREMIN=1`, which turns `LUCI_MINIFY_JS` to `0`; jsmin on top of terser output would
reopen the `return /re/` trap on forms terser legitimately emits. A build **without** node (SDK
user, buildbot) keeps the default `1`, and jsmin minifies the untouched source. Both paths
matter: comments are ~60% of the JS source, and uhttpd serves `/www` **uncompressed**, so those
are bytes on the wire and in flash.
The source therefore has to stay jsmin-safe — see the regex rule in
[conventions.md](conventions.md).
### `Build/Prepare` — six steps, in this order
The hook (its name keys on `LUCI_NAME`) runs right after luci.mk copies the sources into
`PKG_BUILD_DIR`, and edits the **copy**:
1. **Copy `LICENSE`** into `PKG_BUILD_DIR` — `PKG_LICENSE_FILES` resolves against *that*, and
luci.mk does not copy the package root.
2. **`build-css.sh`** → `cascade.css` in the build tree. `cat`/`awk` only, so it runs on the
OpenWrt buildbot with no host toolchain.
3. **`mangle-tokens.sh`** — shorten the private `--fs-*` names, 16% of the sheet.
**Before** step 4 on purpose: the reserved set is derived by reading the JS and the templates,
so it must see them whole — and step 4 is what strips the template comments. It reads them from the **source** tree, never from
`PKG_BUILD_DIR` — in CI the build tree's JS has already been through terser, its comments are
gone, and five names that only appear in a comment would stop being reserved. That made the
shipped sheet depend on *who* built it.
4. **`strip-templates.sh`** — the comments out of the `.ut` files: `{# … #}` template comments, and
`/* … */` code comments **that own their lines**, wherever they sit (ucode inside `{% … %}`,
JavaScript inside an inline `<script>`, CSS inside an inline `<style>`). 63 KB of templates
become 21 KB, which is **−7.4 KB of the compressed package** — it went from 72.4 KB to 65.2 KB —
and −7 KB on every page the router serves, since uhttpd serves `/www` uncompressed.
The rule is what makes this safe, and it is not "remove `/* … */`": a comment is removed only
when `/*` is the first non-blank thing on its line and `*/` the last on its (possibly later)
line. To eat live code, a string literal would have to span lines *and* contain a line that is
nothing but a comment; measured across every `.ut` here, 18362 of 18362 comment bytes are
whole-line, none are inline, and no multi-line template literal contains a line-leading `/*`.
Anything that does not fit the rule is left in place and counted — the tree has exactly one such
case today, the glob `` `/usr/lib/lua/luci/i18n/*.${lang}.lmo` ``, whose `/*` is in a string.
Verified on a live router, not only by reading: the pages the stripped templates serve are
byte-identical to the ones the source templates serve, once the comments are removed from both
(the only remaining difference is the session id).
5. **`strip-shell.sh`** — whole-line `#` out of the shell under `root/`.
6. **Stamp `FS_VERSION`** into `fs-version.js` with `sed`. The path is part of the contract —
`dev-sync.sh` and `tools/stage.sh` run the same substitution, so moving the constant means
fixing three places.
### The catalogue lives in `po/`, and luci.mk owns it
`LUCI_LANGUAGES` in luci.mk is `$(wildcard po/*)`, so a `po/` directory makes it bake a
`luci-i18n-footstrap-<lang>` package per language — the ordinary arrangement for everything in the
luci tree, and the only one [Weblate](https://hosted.weblate.org/engage/openwrt/) can translate,
which `CONTRIBUTING.md` names as *the* way to translate LuCI. Nothing in this package's own
`Build/Prepare` touches the catalogue.
It was `i18n/` from v0.8.5 to v0.12.x, which is what stopped the language packages being
generated. The reason was issue #6: the self-update script people had installed **at the time** picked
its asset with `grep -E '\.apk$' | head -1`, GitHub returns assets **sorted by name**, and
`luci-i18n-…` sorts before `luci-theme-…` — so the Update button installed a 6 KB catalogue
instead of the theme, reported success, and offered the same update forever. A script already on
somebody's router cannot be fixed remotely, so the *release* was fixed instead.
That script is retired, and the release is built by owfeed. From 0.14.4 the owfeed build emits the
same set luci.mk would: **`luci-theme-footstrap`, plus one `luci-i18n-footstrap-<lang>` per
language**, with the catalogue at `footstrap.<lang>.lmo` and a `uci-defaults` line registering the
language in LuCI's own menu. `tools/stage.sh` builds the per-language staging trees, `owfeed.yml`
declares one package each, and `tools/i18n-packages.mjs` fails the build when those three lists
disagree.
Between v0.12.x and 0.14.3 the catalogues rode **inside** the theme under the basename
`footstrap-theme.<lang>.lmo`. That cost every router 10,992 B of flash and 4,821 B of the `.apk`,
including the majority reading English, and it was the bundling that forced the odd basename in the
first place — a router still owning `footstrap.<lang>.lmo` through the old language package would
otherwise have hit a file conflict. Split back out under luci.mk's own names, that conflict is an
ordinary upgrade of the same package instead. Measured on both stands: upgrading the theme takes the
old `footstrap-theme.*.lmo` away with it, and installing `luci-i18n-footstrap-ru` puts
`footstrap.ru.lmo` down in its place, with `luci.languages.ru` registered and the chrome rendering
in Russian.
**A package manager cannot read `uci luci.main.lang`, so the catalogue has to install itself.**
`depends` points from the catalogue to the theme, which means installing or upgrading the THEME
pulls in nothing: measured on a stand, a Russian router on 0.14.3 taking 0.14.4 through the feed lost
every `.lmo` with the old package and gained no new one — English theme, nothing said why (issue
#41). Three mechanisms cover the three ways a router can arrive at the new shape, and none of them
covers all of it:
| path | what carries it |
|---|---|
| `install.sh`, install or re-run | reads `luci.main.lang` and fetches the catalogue, from the feed or — when the feed has not got it yet — from the signed release, pinned to the tag the installed theme came from |
| `apk upgrade` on 25.12+ | `install-if: luci-theme-footstrap={version} luci-i18n-base-<lang>` in `owfeed.yml`: apk installs the catalogue by itself once the theme is at that version and the router already carries that language's LuCI base. The pin re-fires the rule on every theme upgrade |
| `opkg upgrade` on 24.10 | nothing. opkg's `Recommends:` is executed but unconditional — it would put Russian on every 24.10 router — and a postinst cannot call opkg, which holds an exclusive lock for the whole run. This leg is `install.sh` or nothing |
`install-if` needs at least two entries with one pinned by `=` (apk-package(5)); `{version}` is
expanded by owfeed to the version being built, so a release does not hand-edit the pin.
Where none of the three fired, the Appearance tab says so: it asks `_('Layout', 'footstrap')` — a
string every catalogue carries — and, when the answer comes back untranslated on a non-English
router, names the package. That check is why `Appearance` is the wrong string to test: it is
obsolete in the catalogues (`#~ msgid`) and reported "not translated" on a router whose Russian
catalogue was installed and working.
`install.sh` reads `luci.main.lang` after installing the theme and fetches the matching package
best-effort, so the split does not silently un-translate a router on the upgrade that introduces it.
A language with no catalogue, `en`, and `auto` are all no-ops there. **A feed that does not carry
the catalogue is not the end of that path**: a new package reaches owfeed-packages through a pull
request against it, so `luci-i18n-footstrap-ru` and `-es` were absent from the feed for as long as
that took while the signed assets sat in the release — the installer falls back to the release,
through the same verified chain, and pins it to the tag the INSTALLED theme came from rather than to
`latest`, because the feed trails the release by up to a day and a catalogue knows only the strings
of its own version. `tools/check-packages.sh`
asserts one theme package per format, one catalogue per language package, and none in the theme.
## uci-defaults: registration
`root/etc/uci-defaults/30_luci-theme-footstrap` is the **single source of truth** for
registration (`dev-sync.sh` runs the same file; nothing else registers the theme).
- Registers **one** entry: `luci.themes.Footstrap=/luci-static/footstrap`. Layout, palette, mode
and rounding are **client** switches on the Footstrap tab.
- The key in `themes.<Name>` is CamelCase without hyphens — a uci option-name limitation.
- Links the three admin uploads into `/www`: `bg`, `pattern.svg` and the `fonts/` directory all
live in `/etc/footstrap`, because uhttpd serves `/www` only and `/etc` is what a sysupgrade
keeps. The pattern keeps its `.svg` name — uhttpd types a response by extension.
**It runs TWICE per install, not once.** Our `postinst` calls it, and OpenWrt's stock
`default_postinst` separately runs and then deletes every `/etc/uci-defaults/*` in the package.
The script is idempotent, so this is harmless.
**Fresh install vs upgrade is decided by the registration itself.** `mediaurlbase` is written only
in the run that first added `luci.themes.Footstrap`; on an upgrade the entry is already there, so
nothing moves a router off the theme it is on. `[ "$PKG_UPGRADE" != 1 ]` is checked beside it, as
the other themes in the tree do, but carries nothing on its own: apk never exports the variable and
neither does our postinst.
**It migrates nothing from older footstraps, on purpose.** A router is expected to `sysupgrade`
rather than upgrade single packages, so leftovers go with the image; what remains worth cleaning is
config, and the one config key this package owns is the theme entry. That was not always so — the
script used to delete eight legacy theme names, re-point four legacy media paths, carry the old
top-bar layout into `luci.main.footstrap_layout`, sweep two downloaded wallpapers and a pre-0.12.1
`fonts/` directory, and fall back to bootstrap if the active theme's files were missing. All of it
served installs that predate the first version published in the LuCI tree. Requested in review:
start from the assumption that a user of the official package started there.
## postinst / postrm
`postinst` re-runs uci-defaults and clears the LuCI caches. It does **NOT** `rpcd reload` — nor
does `postrm`, nor `dev-sync.sh` — and the reason is its own section below.
`postrm` does three things, and exits early on an upgrade (`case "$1" in *upgrade*`) because opkg
runs the OLD package's postrm mid-upgrade — reverting `mediaurlbase` there is what once flipped
every updating 24.10 user back to bootstrap:
- deletes `luci.themes.Footstrap`;
- if our theme is still active, moves `mediaurlbase` back to bootstrap on a **two-part** check
(both the media directory *and* the ucode template must exist: a one-sided check would hand the
UI to a half-removed bootstrap, which is the white page this branch was written for);
- removes `/etc/footstrap` — the admin's uploads, kept out of the package so an upgrade preserves
them, and a real removal is the one time they should go.
## Why postinst/postrm never call `rpcd reload`
They used to — `reload`, never `restart`, because `restart` logs out every LuCI session (rpcd
holds them in memory) while `reload` (`SIGHUP`) is supposed to survive them. Measured on an
owrt2512 stand, that survival claim is true but beside the point: `reload` is `exec_self()`, a
full re-exec that re-scans the plugin dirs and `dlopen()`s every `.so`. A plugin file absent or
mid-write at that instant is **dropped**, past one stderr line, and does **not** come back on its
own:
```
mv /usr/lib/rpcd/file.so /tmp/ ; rpcd reload -> `ubus list` loses `file`
file restored, no reload -> still missing
reload (or restart) -> back
```
This was first found against the `luci` ucode plugin — a reload racing another package's file
replacement left `luci/getFeatures`, `luci/getTimezones` and `luci/getMountPoints` all answering
`-32000 Object not found` until the next reload, reported from the field on a SNAPSHOT router and
cleared only by a reboot. The same race against `file` is worse: it is what makes System ->
Backup/Flash Firmware's "Reset to defaults" row silently vanish, because `view/system/flash.js`
reads `/proc/mtd` and `/proc/mounts` through `fs.trimmed()` wrapped in
`L.resolveDefault(..., '')` — a `file` object gone reads as "no overlay" rather than as an error
(OpenWrt forum thread 251930, posts 97/109 — "fixed" by installing an unrelated package whose own
postinst reload happened to re-register `file`).
None of that risk buys this package anything, because:
- it registers **no rpcd object of its own** — no `/usr/libexec/rpcd/*` script, no
`/usr/lib/rpcd/*.so`, only `root/usr/share/rpcd/acl.d/luci-theme-footstrap.json` — so there is
nothing here for a reload to refresh, only every *other* plugin's dlopen risk to run for free;
- rpcd reads `acl.d/*.json` **at login**, not only at start or on `SIGHUP`. Measured: narrow
`rpcd.@login[0].read`/`write` from `*` to an explicit group, remove the ACL file with rpcd
already running -> `session access` for `uci footstrap write` answers `false`; put the file back
with **no reload**, log in again -> `true`. A fresh login already sees a just-installed grant;
- on a stock router, root's rpcd login grants `read='*' write='*'` — blanket. `session access`
answers `true` even for an **invented** object (measured against `uci totally_made_up_pkg_xyz`),
so the theme's ACL group never mattered to a root session in the first place.
The one gap the login-time read does not cover: a session that logged in **before** the install,
on a **restricted** (non-`*`) rpcd login — its in-memory ACL set predates the new grant, and only
a fresh login re-reads `acl.d`. `postinst` closes that gap without touching rpcd at all, ending a
session only when re-authenticating would actually hand it the theme's scope: it must be **denied**
`uci`/`footstrap`/`write` right now, **and** its own username's `rpcd.@login[]` entry must list the
theme's ACL group in its `read` or `write` list — otherwise the login was never given the group at
all (a second admin account, a monitoring script's login), a fresh login would deny it exactly the
same, and ending the session would cost that session for nothing:
```sh
for s in $(ubus call session list 2>/dev/null | grep -o '"ubus_rpc_session": "[a-f0-9]*"' | cut -d'"' -f4); do
case "$s" in *[!0]*) ;; *) continue ;; esac
ubus call session access "{\"ubus_rpc_session\":\"$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"write\"}" 2>/dev/null | grep -q true && continue
u=$(ubus call session get "{\"ubus_rpc_session\":\"$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4)
[ -n "$u" ] || continue
# Enumerated from `uci show`, not a `uci -q get` index count: the latter stops at the FIRST
# login section with no username option and never sees any section past it. `while read`, not
# `for`, because a username may contain a space and word-splitting would skip that section; the
# subshell's answer is echoed out and captured instead of set in a variable.
grant=$(uci show rpcd 2>/dev/null | grep -F .username= | while read -r kv; do
sect=${kv%%.username=*}
un=${kv#*.username=}
un=${un#\'}
un=${un%\'}
[ "$un" = "$u" ] || continue
for grp in $(uci -q get "$sect.read") $(uci -q get "$sect.write"); do
[ "$grp" = luci-theme-footstrap ] && { echo 1; break 2; }
done
done)
[ -n "$grant" ] || continue
ubus call session destroy "{\"ubus_rpc_session\":\"$s\"}" >/dev/null 2>&1 || true
done
```
Guarded on the `session` ubus object existing at all, and the `case` line skips the all-zero
session id — the unauthenticated default, never denied anything by an ACL, so this sweep is not
its business. Verified verbatim on the stand: a blanket-root session survived, a stale restricted
session (whose login lists the group) was destroyed, the unauthenticated all-zero session was left
untouched, and a fresh login answered `true`. The one visible price is the narrowest one available:
an admin on a restricted account whose login already lists the theme's group, installing the theme
from LuCI's own Software page, logs themselves out and must log back in once — every root session,
every session that already has the scope, and every denied session whose login was never given the
group in the first place, is left alone.
The same reasoning holds one step harder for `dev-sync.sh`: it copies theme files onto a live
router the same way a package install would, the theme still registers no rpcd object, and a
developer's ssh session is root with the stock blanket `read='*' write='*'` grant — so its
post-copy `rpcd reload` was dropped too, leaving only the LuCI cache removal.
## `/etc/config/footstrap` must be a conffile
The package ships `root/etc/config/footstrap` as an **empty stub that is written at runtime**:
"Save as default" has rpcd uci-set the router-wide axes into that very file
(`saveAsDefault()` in `fs-prefs.js`).
With no `conffiles` define, the package manager owns it as an ordinary file and **replaces it on
upgrade** — so the admin's saved defaults were wiped by the theme's own one-click Update, silently,
and reported as success. Measured on a live router: it held eight options, was package-owned, and
had no `.conffiles` entry beside base-files' and dnsmasq's.
Nothing observable fails when this regresses — the wipe happens on somebody else's router, months
later — so `npm run conffiles` gates it: every shipped `/etc/config/*` must be declared.
The uploaded background is the sibling case with the other answer. `/etc/footstrap/login-bg` is
written at runtime too, but it lives outside `/etc/config`, so a package upgrade never touches it
and a conffile entry would be rejected (the package does not ship that path). What *would* eat it is
a firmware **sysupgrade**, which keeps only what is listed — hence
`root/lib/upgrade/keep.d/luci-theme-footstrap`.
Three things now take that route, and `/etc/footstrap/` is where all of them live: the background,
the wallpaper pattern, and `/etc/footstrap/fonts/` — the webfonts `fonts/set-font.sh` installs,
along with the `@font-face` sheet it generates beside them. Each is exposed by a symlink under
`/www/luci-static/footstrap/` that uci-defaults recreates on **every** install and upgrade rather
than shipping, because `/www` is repopulated from firmware on a sysupgrade.
The fonts one links a **directory**, and that has two traps which end identically — `ln` exits 0 and
the link lands at `…/fonts/fonts`, one level too deep, so nothing serves and nothing complains:
- the path is already a symlink to a directory, and `ln -sf` follows it. `-n` is the fix;
- the path is a **real directory** — which every footstrap before 0.12.1 shipped here, full of the
woff2 files it carried — and `-n` does not help. Measured on a 25.12 router: exit 0, link created
inside. So the path is cleared first unless it already is the symlink we want, in uci-defaults and
in `set-font.sh` alike.
No ACL entry goes with the fonts, and none should: `set-font.sh` is root on the router with a shell,
not a browser going through rpcd. Nothing in the UI writes those three options.
## ACL
`root/usr/share/rpcd/acl.d/luci-theme-footstrap.json` grants what the Footstrap tab needs to persist
a router-wide default and a login background:
- `uci` `set`/`commit`, scoped to the `footstrap` config — "Save as default";
- `cgi-io upload` plus `file` `write`/`remove` on `/etc/footstrap/login-bg` and
`/etc/footstrap/pattern.svg` — the wallpaper photo and the tiled pattern;
- `file exec` on two literal commands, `/bin/chmod 644 /etc/footstrap/login-bg` and the same for
`/etc/footstrap/pattern.svg` — an upload that is not world-readable is one uhttpd answers with 403.
Those two `file.exec` grants are the only ones the theme ships, and each is one fixed
argument-complete command.
There is no grant for self-update, because there is no self-update: the theme upgrades through the
package feed the installer adds.
rpcd **skips an unreadable file in `acl.d` and says nothing**, so a stray comma means the grant
is issued to nobody and nothing else notices. `npm run acl` (`tools/check-acl.sh`, also a step in
CI's `check` job) parses every shipped `acl.d/*.json` and additionally rejects a document that
parses but grants nothing — a list instead of an object, or an entry with neither `read` nor
`write`, both of which rpcd accepts just as quietly.
A related trap the page had to solve: `rpc.js` only raises on the ubus status code when the
declaration asks it to (`reject: true`). Without it, a per-config ACL refusal — `uci` granted,
`footstrap` not — **resolves** with status 6 (permission denied) and every `.then()` runs as if the
file had been written.
## `luci-upstream.pin`
The single source for the pinned `openwrt/luci` commit and the sha256 of the two borrowed tools
that CI **downloads and RUNS** as gates:
- `luci-base/src/jsmin.c` — decides whether the shipped JS is safe;
- `build/i18n-scan.pl` — decides whether the catalogue is complete. It lexes `.ut` (rewriting the
template into JS before xgettext) and picks up the title from the rpcd ACL; a `grep` for
`_('…')` does neither.
Taken from a moving `master`, these gates would be "whatever upstream pushed last"; the sha256
says so out loud. The same file pins `USIGN_PIN` (so the signer in CI and the verifier in the field
are the same code) and `OPENWRT_KEYRING_PIN` — the commit of `openwrt/keyring` the release SDK's
signing keys are read from, pinned from a *different* host than the tarball they verify.
It deliberately pins **no ucode**. The template compile-check moved into the `verify` containers,
where the router's own interpreter runs it against the installed templates on both release lines
([ci.md](ci.md)) — so there is no interpreter to build and no commit to keep current, and the file
says so in place to stop the pin coming back.
## The same package, twice: this tree and the luci tree
The theme **is in** [openwrt/luci](https://github.com/openwrt/luci) as
`themes/luci-theme-footstrap` (merged 2026-08-20), and the copy that lives there is **not** this
directory copied across. `tools/sync-luci-fork.sh <path-to-luci>` materialises it, and the
difference is one decision made twice.
**How a change gets there now.** Their `CONTRIBUTING.md` is the authority and the shape is
ordinary: a feature branch off a fresh `upstream/master`, one PR per change, subject
`luci-theme-footstrap: <lowercase description>`, a body that says why, and a `Signed-off-by` with a
real first and last name (a GitHub noreply address is refused). `git push -f` is explicitly the way
to update a PR — inside your own branch, never on master. Release branches (`openwrt-25.12`, …)
take bug and security fixes only; a new package never lands on one, which is why the theme reaches
users of a *release* through owfeed and reaches everyone else with the next OpenWrt release.
**Two things the sync cannot carry, and both have bitten once.** The far side's `Makefile` is
hand-maintained (`include ../../luci.mk`, `PKG_MAINTAINER`), so `postinst`, `postrm` and
`conffiles` have to be changed there as well — a postrm cleaned up here and not there is exactly
the kind of divergence nothing else notices. And `po/` belongs to **Weblate** over there: upstream
forbids editing catalogues by hand, so the sync no longer sends them, and a msgid change is its own
deliberate PR against `po/templates/`. `npm run fork-drift` lists every shipped file the two trees
disagree about and names those two separately; it is a report rather than a gate, because an
unproposed change is a legitimate difference.
**That tree gets the built stylesheet; this one keeps the layers.** Here, `styles/` is thirty-nine
files in four cascade layers whose *order* is the design, and `cascade.css` is a build artefact
this repository does not even track. There, the other four themes each commit one `cascade.css`
and have no build step at all — a theme arriving with its own build system asks a reviewer to
audit that before they can read a stylesheet. So the sheet is generated on this side and
committed on that one, and `styles/` plus the four shell scripts do not travel.
**Nothing else is optimised on the way.** Measured against the stock tree, no package in
`openwrt/luci` ships anything pre-minified: the four themes' stylesheets run 17–20 bytes per line
and `luci-base`'s own JS 28–29, i.e. ordinary source with indentation. So the copy keeps its
`--fs-*` names unmangled, its templates and shell keep their comments, and the JS goes over
untouched for `luci.mk` to run **jsmin** across at package time — which is what every other
package in that tree gets. The release path here does more (terser, `mangle-tokens.sh`, comment
stripping) and the packages differ by about 14% because of it: 76 321 bytes against 66 825.
The one place the copy still stands out is the sheet itself, at 128 bytes per line against the
stock 17–20, and the arithmetic is why it stays: unminified it is **467 615 bytes**, eight times
the largest stock theme, because this repository keeps the *why* beside each rule and in source
form those comments are 70% of the file. The choice there is not "like everyone else" versus
"minified" — it is 136 kB of minified sheet against 468 kB of prose.
Two more things the copy changes, both in the Makefile, which is the one file maintained by hand
on the far side and never overwritten by the sync:
- `include ../../luci.mk`, not `$(TOPDIR)/feeds/luci/luci.mk` — in-tree, that is the path.
- `PKG_MAINTAINER` rather than `LUCI_MAINTAINER`: 96 of 101 apps in that tree use the former, and
the formality bot reads it.
Re-run the sync after any change under `styles/`, or the committed sheet is a stale artefact of a
source that has moved.
+299
View File
@@ -0,0 +1,299 @@
# Releasing
The mandatory pre-release checklist, the changelog contract, and the runbook. **No release goes out
until the automatic gates are green and the live checks are done for every issue the release diff
touches.**
How CI builds and signs the packages: [ci.md](ci.md).
## Why there is a checklist at all
Every closed bug left a guard behind — a gate or a live check that catches its return. This page
is the register: one row per issue, the mechanism, the command, and whether it is automated.
A bug with no guard comes back silently. **If you fix something that has no row here, add a row.**
## Step 1 — scope: what can regress in this release
A regression can only come from what the diff touches.
```sh
git status --short | awk '{print $2}' | sed 's#/.*##' | sort -u
git status --short | grep -E 'styles/|menu-footstrap|\.ut$|fs-sheets|fs-select|fs-fit' \
|| echo 'no CSS / renderer / template / fence changes'
```
- The diff does not touch `styles/`, the renderer, the templates or the fence → the visual and
fence issues (#1–#5, #7–#11) cannot regress from it. Their gates being green is enough; a live run
is allowed but not required.
- The diff touches the matching area → the live check for those issues is mandatory.
**Always mandatory regardless of the diff:** the whole automatic section, plus issue #6 (packaging
and asset selection) — because #6 breaks at the release level rather than in the code, and
nothing fails until a router in the field pulls the update.
## Step 2 — the automatic gates
One run closes most of the guards. It must exit 0.
```sh
npm run check
```
| Issue | What went wrong | The guard inside `check` |
|---|---|---|
| #3, #8 | theme styles stopped applying on a foreign package's page (filemanager, OpenClash `*{padding:0!important}`) | `chrome-fence` — the fence/pin/dark-guard still match the chrome; `fs-sheets` re-hosts the foreign sheet |
| #5 | the `low`/`medium`/`high` ramp was three aliases of one colour, so "no data" lit up like a live value | `export-tier` (three DIFFERENT colours), `audit --strict` |
| forum 82-83 | the `placeholder` attribute and the unset dropdown row were drawn in the body inks, so an option's DEFAULT read as a value the reader had typed | `placeholder-ink` (a hint is ≥40% light / ≥30% dark of the ink-to-field range away from the value, and still 3:1, with every combination re-run under `prefers-contrast: more`) |
| #6 | localisation as separate `luci-i18n-*` packages, so the self-update script shipped at the time pulled a catalogue instead of the theme | `i18n` (catalogue current and complete); `tools/check-packages.sh` asserts exactly one theme package per format, but runs in CI's build job rather than inside `check` |
| #9, #11 | config-table row labels and a data-table column vanished or were squeezed | `css-dup` + `mirror` (`@mirror table-card/*`) |
| all | a rule lost its `!important` or started depending on source order | `css-metrics`, `audit --strict`, `css-orphans` |
| — | the template pre-paint drifted from the live appearance appliers | `axes` |
| — | `[Unreleased]` without a bold lead, or the RU mirror out of sync → an empty release note | `changelog` |
`tools/jsmin-verify.mjs` is not in `check` (it needs a jsmin binary built from the pin) — CI runs
it. Locally the cause is covered by eslint's `wrap-regex`, inside `lint`.
`/security-review` runs beside it, on the diff this release carries, before the tag. It is not a
gate and cannot be — it reads a diff rather than a tree — which is why it is named here instead:
the pass is worth the minute on the five things a theme can actually get wrong (the installer's
signature chain, new shell running over a build tree, the unauthenticated login template, sinks in
the browser JS, the packaging pipeline), and a maintainer has asked outright whether one was done.
## Step 3 — asset selection (issue #6), separately and always
This is the most fragile part of every release and the only one that fails silently: the release
notes and the asset choice are evaluated at tag time, in the field, months later.
- **The CI gate** (`build` job): exactly N assets per format, each package resolving through its own
**name-anchored** regex `^<name>[-_][^/]*\.EXT$` to exactly one. A `.sig` ends in `.EXT.sig` and
does not match `\.EXT$`.
- **The reader in the field** is `install.sh`, and it never guesses: on a router the feed cannot
serve it reads the signed `manifest.txt` and matches `$2` against the package name exactly, which
is what makes a release carrying `luci-theme-footstrap` and two `luci-i18n-footstrap-<lang>`
packages safe where #6 was not. The release must still resolve to one file per format **per
name**, for a by-hand install and for the Pages mirror. Simulate it against the shape of the
coming release:
```sh
# on a dev router, RELJSON = the release JSON of the intended shape
for EXT in apk ipk; do
n=$(jsonfilter -i "$RELJSON" -e '@.assets[*].browser_download_url' \
| grep -Ec "/luci-theme-footstrap[-_][^/]*\.$EXT\$")
echo "$EXT -> n=$n (must be 0 or 1)"
done
```
## Step 4 — live checks for the areas the diff touched
Run on both releases (25.12/apk and 24.10/opkg). Compare against stock bootstrap
wherever you are unsure — it is the reference for LuCI behaviour.
**A release runs the WIDE version of the automatic live gates first:**
**and `npm run live -- …` cannot carry those flags**: `live` is six `npm run` calls joined by `&&`,
and npm appends the arguments after the LAST of them, so `--all --pages-all` reached `anchor` alone
and every gate before it ran its default set. Measured on 2026-09-01: the composed line the shell
echoes ends `… && npm run anchor --all --pages-all`, and `live-audit` logged
`node tools/live-audit.mjs` with no arguments. Each gate is therefore called by hand:
```sh
node tools/live-audit.mjs --all --pages-all # every OpenWrt router owlab boots, every page
node tools/spa-parity.mjs --all --pages-all
node tools/table-tick.mjs --all
node tools/scroll-anchor.mjs --all
node tools/upstream-contract.mjs --all # what the theme assumes of luci-base, asked on each
```
**And the package on both formats, before the tag.** `npm run live` measures a *synced* tree; a
release is *installed*, and that is a different claim — two `owlab test` invocations, one per
format, never one run with two `--release` flags (`--install` is a host-side glob evaluated per
router, so `dist/*/…` hands the apk box an ipk too: `0 of 2 routers passed`). The five assertions
and the exact flags are in [development.md](development.md#proving-it-on-a-router-owlab-test); the
fifth is the `ucode -T -c` sweep over every installed template.
```sh
./tools/stage.sh && owfeed build
owlab test --release 25.12.4 --install 'dist/noarch/luci-theme-footstrap-*.apk' --assert …
owlab test --release 24.10.8 --install 'dist/all/luci-theme-footstrap_*.ipk' --assert …
```
Both of these are long runs: start them detached with `tools/bg.sh` and read the logs out of
`../tmp/`. **A release is not cut until every log above has been read** — a detached run nobody
opened is not a green gate, and the tier a check sits in never changes what the release requires.
**23.05 used to have a line of its own here, and no longer does.** It was the oldest release the
theme claimed and the only one whose `luci-base` was missing a widget the theme uses:
`ui.RangeSlider` arrived in 24.10, and its absence took the ENTIRE Appearance tab down — the panel
is built inside one try/catch, so the miss cost a console line and an empty tab, reported from the
field by a user on 23.05.5 rather than by a gate. Support for that release ended at 0.14.2: it is
EOL, and openwrt/luci declined to carry the compatibility code in the tree the theme now lives in
(#8978). `install.sh` pins that version for a 23.05 router and says it is the last one; the stand
and the contract entry that guarded it are gone with the fallback they guarded.
Day to day those gates measure three routers and one page per shape, which is what makes them cheap
enough to run before a push (`docs/development.md`). A release is the one moment where the axes they
trade away are worth paying for: a page that shares a shape with another is only *probably* the same
to this theme. Read the reduction lines either way — they name every page that was stood in for.
ImmortalWrt is not measured by any gate (`tools/lib/stands.mjs`).
| Issue | Page | What to look at |
|---|---|---|
| #1 | a third-party app with a wide table, firewall zones | the table stacks into cards, does not overflow its section; the content width does not tear |
| #2 | Podkop → Monitoring, any `<select>` | the dropdown opens, host filtering works |
| #7 | `admin/system/package-manager` | the theme's favicon; buttons do not overlap the inputs; no stray unrounded table border |
| #9 | `admin/network/firewall/forwards`, `/snats` (GridSection) | config-table row labels visible (`.fs-stacked` cards / `@container 960`) |
| #10 | `admin/network/dhcp` (a page with hidden tabs) | no empty scroll below the content |
| #41, #75 | `admin/network/network`, `admin/system/startup` (a page with a tab strip) | switch tabs: the pane the reader LEFT keeps no `min-height`, and the tab they opened starts where the page starts. `floor-contract` asks it automatically — it clicks the strip and reads the inactive panes — because the fault is invisible on a page that is only loaded and, on a page that does not poll, never goes away by itself |
| the iPhone report | `admin/status/overview` on a narrow window, scrolled off the top | the page does not creep upward once per poll tick. `fit-quiet` asks the cause automatically — the bar may not be shorter than it settled at while it measures itself — because the symptom needs an engine with no scroll anchoring and does not reproduce headless |
| #11 | `admin/status/overview` (client list) | the "Network" column is not crushed, rows are not over-wide |
| #17 | the release assets, not a page | `manifest.txt` **and** `manifest.txt.sig` are present; `latest/download/manifest.txt` serves that file; `usign -V` passes with `release.pub`; `awk '$1=="pkg"'` yields one line per format; no install or update path touches `api.github.com`. **The manifest is now load-bearing for installs**, not only for readers: a router the feed cannot serve is installed from it (`install.sh` -> `install_from_release`), so a manifest that does not verify is an install that refuses |
**Why #17 is here and not "CI will catch it".** CI does check it (the `release` job compares the
served `latest/download/manifest.txt` against the built one), but a manifest failure is **invisible
on the page**: the theme looks fine while installation breaks for new users and the update badge
breaks for existing ones — on someone else's router, weeks later. That is exactly the failure shape
this file exists for.
## The changelog contract
The single source of truth for `CHANGELOG.md` and its Russian mirror `CHANGELOG_ru.md`.
**`npm run changelog` holds the mechanical half**: the set, order and uniqueness of sections, empty
sections, dates, `compare` links, mirror parity (versions, dates, sections, bullet count) and the
mandatory bold lead on `[Unreleased]` and a freshly cut version. It exists because `[Unreleased]`
once accumulated a **duplicate `### Changed`** over several commits: each commit looked fine on its
own, and `release-notes.sh` would have printed two "Changed" groups on the release page with nothing
failing, because the notes are generated at tag time, when the tag is already pushed.
**Everything about the prose below, the gate cannot check.** That part is on you.
### What we chose
- **Base: Keep a Changelog 1.1.0.** Fixed category names and order, `[Unreleased]` on top, newest
version first, ISO 8601 dates, the file at the repository root.
- **Extension: `Performance`** — a seventh category on top of KaC's six. A documented, legitimate
extension.
- **Voice: effect-lead** — neither strictly past tense nor strictly imperative:
`- **one-line effect.** then the reasoning`. A third option, but internally consistent; keep it
everywhere.
- **Commits follow Conventional Commits** (so the version bump can be derived), but the changelog is
**written by hand**. Generation from commits yields raw material only.
There are two competing standards and they disagree in three places — the category set, the verb
tense, and whether `[Unreleased]` should exist at all. **This project has already picked Keep a
Changelog on all three.** Do not "correct" entries toward the other standard.
### Categories, in this fixed order
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`, `Performance`.
- **One section of each type per release.** Two `### Fixed` under one version is a merge artefact.
- **The order does not follow the order the work was done in.**
- **No empty sections.** No bullets, no heading.
- Not all seven need to be present. A normal release is `Added`/`Changed`/`Fixed`, sometimes
`Performance`.
### The shape of an entry
```
- **A one-line effect that reads on its own.** Then the reasoning: why, the measurement,
what the rule protects against.
```
- **The bold lead IS the release note.** `tools/release-notes.sh` extracts only the bold lead of
each bullet and groups it by section. So: the lead is mandatory (a bullet without `**…**` silently
drops out of the release), and it must be a self-contained sentence, because it appears without
the reasoning behind it.
- **Write the effect, not the diff.** "Buttons had no focus indicator at all" beats "changed
`:hover` to `:focus` in `styles/base/70-buttons.css`".
- **Keep the number the change was made for.** "20 KB of font for 227 labels", "312 of 336 elements
recoloured by a hostile `:root`", "1.69:1 where AA wants 4.5" — the measurement is the difference
between a changelog and a marketing blurb.
- **Name what the rule protects against** if it is not obvious. Half this theme's invariants exist
because the obvious alternative was tried and was worse; an entry without the reason invites the
next person to undo it.
- **Do not cite commits** or retell a file's history. A changelog is not a git log.
- **Edit the existing entry; do not append a second one.**
- **Merge related commits into one entry.**
### `[Unreleased]`
**Every substantive commit writes into `## [Unreleased]` in the same commit as the code.** A
changelog written afterwards is written from the diff, and the diff is precisely what does not know
why. Commits that change nothing for a user or a maintainer (a typo in a comment, a CI-only
refactor) need no entry; when in doubt, write one.
### The bilingual mirror
`CHANGELOG.md` (English) and `CHANGELOG_ru.md` are **edited in one commit**. A mirror that lags is
worse than no mirror: the reader cannot tell which copy is stale. Same section set and order, same
numbers, versions and `compare` links — only the prose may differ, never the facts. The English file
is the primary source for the release-note generator.
### Anti-patterns
A dump of the commit log or the diff; copying a commit or PR verbatim; a partial changelog that
omits `Removed`/`Security`/breaking changes; duplicate sections under one version; an entry written
from the diff after the fact; a self-reference to a commit hash.
## The runbook
Only after everything above has passed:
1. Rename `[Unreleased]` to `## [x.y.z] — YYYY-MM-DD` in both `CHANGELOG.md` and
`CHANGELOG_ru.md`, and add the `compare/` link at the bottom of each. Sections in the canonical
order, every bullet with a `bold lead`.
2. `npm run changelog` — green.
3. Commit that change. Message in English, Conventional Commits, **no AI attribution**.
4. Tag `vx.y.z` on this commit. **Never tag first** — the tag must point at a commit that
already contains its own entry, or the release describes a version whose changelog does not yet
exist.
5. **Before pushing, read the recent runs on `main`**: `gh run list --limit 6` — on the maintainer's
Windows machine `gh` is not on `PATH` in Git Bash or WSL, and must be called by its full path,
`"/c/Program Files/GitHub CLI/gh.exe" run list --limit 6`. A run already red before your push
stays red after it, and `release` does not fire until whatever it currently gates on is green —
so a tag pushed onto a broken pipeline cannot publish. A pre-existing failure is not something to
push past: it is either an infrastructure condition to be fixed first, or a real regression that
has nothing to do with this release but will still hold it. `v0.14.12` was tagged onto exactly
this: the previous commit on `main` had already failed the same way the day before, on the same
upstream condition, and nobody looked before the tag went out.
6. Push the commit and the tag to `origin` — the only remote (the `git.vaka.work` mirror was removed
on 2026-07-25).
7. CI on `v*` builds both formats, signs them, and builds the release body from the changelog, but
`release` does not run until whatever it gates on is green, by design — deliberately, so that a
tag cannot publish a package no router has installed. The consequence is exactly what bit
`v0.14.12`: any one red leg holds the whole release, including a leg red for a reason that has
nothing to do with the diff — infrastructure or an upstream feed, not this release's code. **The
release is not cut until the tag's own run is read, job by job, not merely waited on**: `gh run
view <id>` for the job list, `gh run view --job <id> --log-failed` for the failing step's log; say
plainly whether the cause is this diff, an infrastructure condition, or an upstream feed. Only
then check the release carries the expected assets (plus a `.sig` for each): the theme resolving
to exactly one asset per format, the manifest, the installer, the notes.
8. **Publish the feed, and do not trust the bot to finish it.** The theme is installed from
owfeed-packages, so a release nobody can `apk upgrade` into is half a release. The hourly job
there opens the version-bump pull request and says it "will merge itself once the checks pass".
Measured on 0.14.10, it does neither on its own, and both halves are mechanical:
- its pull request is authored by `app/github-actions`, and that repository's
`fork-pr-contributor-approval` policy holds the `pull_request` check run in `action_required`
until a person approves it. The job dispatches its own run of the same workflow, which goes
green, but the automerge waits on the held one. **Approve it** (`gh api -X POST
repos/owfeed/owfeed-packages/actions/runs/<id>/approve`), or the PR sits open with a green
check beside it.
- after the merge, `Publish` does not start: it triggers on a push to `main`, and a push made
with `GITHUB_TOKEN` does not trigger workflows. **Dispatch it by hand**
(`gh workflow run publish.yml --repo owfeed/owfeed-packages`).
Then read the **served** index rather than the workflow log — `apk adbdump` on
`releases/25.12/<arch>/packages.adb`, `Packages.gz` on `releases/24.10/<arch>/` — and confirm the
new version is there. Tracked as [owfeed-packages#53](https://github.com/owfeed/owfeed-packages/issues/53);
when that is fixed, this step goes back to being one sentence.
A fresh empty `## [Unreleased]` comes back on top with the next substantive commit.
## Sources for the changelog rules
- [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — primary
- [Common Changelog](https://common-changelog.org/) — primary, and the standard we deliberately
diverge from
- [Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) — primary
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Capture anonymized README screenshots of stock LuCI pages on the dev router.
Renders each page with luci-theme-footstrap active, scrubs every piece of per-device
data OUT OF THE LIVE DOM before the shot (IPv4/IPv6, MAC, DHCP hostnames, DUID/IAID,
Wi-Fi SSID/BSSID), then writes desktop and/or mobile PNGs. The theme is only activated
for the run and always reverted.
The scrub reads the real values FROM the page and replaces them with random,
self-consistent fakes — nothing being anonymized is written in this file, so the
script is safe to commit. The password is taken from $LUCI_PW, never hardcoded.
Usage:
LUCI_PW=<router-root-pw> docs/screenshots/capture.py [--form desktop|mobile|both]
[--ssh-host router] [--out DIR]
Requires the project preview venv (Playwright + Chromium):
.claude/tooling/preview-venv/bin/python docs/screenshots/capture.py ...
"""
import argparse, os, subprocess, sys, time, pathlib
from playwright.sync_api import sync_playwright
MEDIA = "/luci-static/footstrap"
FORMS = {
# desktop uses the top-bar layout; mobile is narrow enough that the chrome
# collapses to the phone bar on its own (data-narrow), so the layout key only
# decides how the accordion behaves.
"desktop": dict(w=1440, h=1000, dsf=2, layout="top"),
"mobile": dict(w=390, h=844, dsf=2, layout="sidebar"),
}
# full=True -> full-page (the dashboard, to show every card anonymized);
# full=False -> viewport crop (config/list pages, which would be endless full-page).
JOBS = [
dict(path="admin/status/overview", name="overview", modes=("dark", "light"), full=True),
dict(path="admin/system/system", name="system", modes=("dark",), full=False),
dict(path="admin/system/package-manager", name="software", modes=("dark",), full=False),
]
# Runs in the page. Collects the real values from the DOM and swaps them for random,
# per-value-consistent fakes. No original is ever written here.
SCRUB = r"""
() => {
const R=(n)=>Math.floor(Math.random()*n);
const HEX='0123456789abcdef';
const hex=(n)=>Array.from({length:n},()=>HEX[R(16)]).join('');
const cache=new Map();
const memo=(k,f)=>{ if(!cache.has(k)) cache.set(k,f()); return cache.get(k); };
const randMac=(o)=>memo('mac|'+o.toLowerCase(),()=>{
const up=/[A-F]/.test(o), sep=o.includes('-')?'-':':';
let g=['02']; for(let i=0;i<5;i++) g.push(hex(2)); // 02: = locally administered
let s=g.join(sep); return up?s.toUpperCase():s;
});
const randIp4=(o)=>memo('ip4|'+o,()=>{
const cidr=o.match(/\/\d+$/); const first=[10,192,172][R(3)];
const b=first===192?168:first===172?(16+R(16)):R(255);
return first+'.'+b+'.'+R(255)+'.'+(1+R(253))+(cidr?cidr[0]:'');
});
const randIp6=(o)=>memo('ip6|'+o,()=>o.replace(/[0-9a-f]{1,4}/gi,m=>hex(m.length)));
const HOSTS=['galaxy-a54','pixel-8','macbook-pro','desk-pc','living-tv','ipad-mini',
'echo-dot','robovac','nas-01','laptop-02','printer','desktop-pc','tablet','nvr-cam'];
let hi=0;
const randHost=(o)=>memo('host|'+o,()=>{const n=HOSTS[hi%HOSTS.length]+(hi>=HOSTS.length?('-'+hi):'');hi++;return n;});
const SSIDS=['home-wifi','home-wifi_5g','guest-net']; let si=0;
const randSsid=(o)=>memo('ssid|'+o,()=>SSIDS[si++%SSIDS.length]);
const hostStrings=new Set(), ssidStrings=new Set();
// structured pass: identify hostname / host / duid / iaid columns by header
document.querySelectorAll('.table').forEach(t=>{
const titles=t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles');
if(!titles) return;
const heads=[...titles.querySelectorAll('.th, th')].map(e=>e.textContent.trim().toLowerCase());
t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)').forEach(row=>{
[...row.querySelectorAll(':scope > .td, :scope > td')].forEach((c,i)=>{
const h=heads[i]||'', txt=c.textContent.trim();
if(!txt||txt==='-'||txt==='?') return;
if(h==='hostname') hostStrings.add(txt);
else if(h==='host'){ const f=txt.split(/[\s(]/)[0]; if(f&&f!=='-') hostStrings.add(f); }
else if(h==='duid') c.textContent=memo('duid|'+txt,()=>hex(txt.length));
else if(h.includes('iaid')){ if((/^[0-9a-f]+$/i).test(txt)) c.textContent=memo('iaid|'+txt,()=>hex(txt.length)); }
});
});
});
document.querySelectorAll('strong').forEach(s=>{
if((/^SSID:?$/i).test(s.textContent.trim())){
const n=s.nextSibling;
if(n&&n.nodeType===3){ const v=n.textContent.trim(); if(v) ssidStrings.add(v); }
}
});
// longest-first so a "name-suffix" host is replaced whole before "name" bites its prefix
const hostArr=[...hostStrings].sort((a,b)=>b.length-a.length);
const ssidArr=[...ssidStrings].sort((a,b)=>b.length-a.length);
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode);
const MAC=/\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g;
// lookaround keeps a 4-part PACKAGE VERSION (e.g. "ack 1.2.3.4-r1") from reading as an IP:
// not preceded by word-char/dot, not followed by word-char/dot/hyphen.
const IP4=/(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\/\d{1,2})?(?![\w.-])/g;
const IP6=/[0-9a-fA-F]{0,4}(?::[0-9a-fA-F]{0,4}){2,}(?:\/\d{1,3})?/g;
for(const n of nodes){
let s=n.textContent;
if(!s.trim()) continue;
s=s.replace(MAC,m=>randMac(m));
s=s.replace(IP6,m=> m.includes('::')? randIp6(m): m);
s=s.replace(IP4,m=>randIp4(m));
for(const h of hostArr){ if(h && s.includes(h)) s=s.split(h).join(randHost(h)); }
for(const ss of ssidArr){ if(ss && s.includes(ss)) s=s.split(ss).join(randSsid(ss)); }
if(s!==n.textContent) n.textContent=s;
}
}
"""
def sh(host, cmd):
return subprocess.run(["ssh", host, cmd], capture_output=True, text=True, timeout=30)
def http_base(host):
out = subprocess.run(["ssh", "-G", host], capture_output=True, text=True, timeout=15).stdout
h = next((l.split()[1] for l in out.splitlines() if l.startswith("hostname ")), None)
if not h:
sys.exit("cannot resolve router hostname from ssh -G")
return f"http://{h}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--form", choices=["desktop", "mobile", "both"], default="both")
ap.add_argument("--ssh-host", default=os.environ.get("FOOTSTRAP_SSH", "router"))
ap.add_argument("--out", default=str(pathlib.Path(__file__).resolve().parent))
args = ap.parse_args()
pw = os.environ.get("LUCI_PW") or sys.exit("set LUCI_PW env (router root password)")
user = os.environ.get("LUCI_USER", "root")
forms = list(FORMS) if args.form == "both" else [args.form]
base = http_base(args.ssh_host)
outroot = pathlib.Path(args.out)
orig = sh(args.ssh_host, "uci get luci.main.mediaurlbase").stdout.strip() or "/luci-static/bootstrap"
print(f"router={base} original-theme={orig}")
try:
with sync_playwright() as p:
b = p.chromium.launch(args=["--no-sandbox"])
sh(args.ssh_host, f"uci set luci.main.mediaurlbase={MEDIA}; uci commit luci; rm -f /tmp/luci-indexcache*")
for form in forms:
cfg = FORMS[form]
outdir = outroot / form
outdir.mkdir(parents=True, exist_ok=True)
for job in JOBS:
for mode in job["modes"]:
ctx = b.new_context(viewport={"width": cfg["w"], "height": cfg["h"]},
device_scale_factor=cfg["dsf"], ignore_https_errors=True)
ctx.add_init_script(
f"try{{localStorage.setItem('fs-layout','{cfg['layout']}')}}catch(e){{}}"
f"try{{localStorage.setItem('fs-darkmode','{'true' if mode=='dark' else 'false'}')}}catch(e){{}}")
ctx.request.post(f"{base}/cgi-bin/luci/", form={"luci_username": user, "luci_password": pw})
pg = ctx.new_page()
pg.goto(f"{base}/cgi-bin/luci/{job['path']}", wait_until="networkidle")
try:
pg.wait_for_selector("#view .cbi-section, #view .table", timeout=9000)
except Exception:
pass
time.sleep(3.0)
pg.evaluate(SCRUB)
time.sleep(0.4)
fp = outdir / f"{job['name']}-{mode}.png"
pg.screenshot(path=str(fp), full_page=job["full"])
print("saved", fp)
ctx.close()
b.close()
finally:
sh(args.ssh_host, f"uci set luci.main.mediaurlbase={orig}; uci commit luci; rm -f /tmp/luci-indexcache*")
print("reverted theme ->", orig)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,403 @@
# Living with third-party `luci-app-*`
What foreign apps actually do to a shared LuCI document, and the rules the theme follows because
of it. Written for someone changing the theme.
**Rules for app authors live in [luci-app-styling-guide.md](luci-app-styling-guide.md)**, the
outward-facing document the README links. Where the two disagree, that one is the source of truth.
> Surveyed July 2026 against sources (GitHub tarballs) and on a live OpenWrt 25.12 router with
> Playwright: 20 apps — 6 proxy stacks (OpenClash, passwall, passwall2, ssr-plus, mosdns, vssr),
> 5 clash/mihomo wrappers (ssclash, podkop, justclash, homeproxy, nikki), 4 heavyweights
> (dockerman, diskman, istore, AdGuardHome) — plus stock `openwrt/luci` apps as a baseline.
>
> The prompt: `luci-app-ssclash`'s config editor drew as a black rectangle with no text and blew
> the page up to 2 007 346 px. The cause was not the app but the theme, and it forced a rewrite of
> the whole theme↔app contract.
## What a foreign app actually does
### 1. It injects CSS into `<head>`, and that survives an SPA navigation
A full page load throws such a `<style>` away with the document, so the app is right to treat it as
"mine for one page". Our router does not reload the document, and the CSS stays forever.
| App | What it puts in `<head>` | When |
|---|---|---|
| `luci-app-filemanager` (stock) | `.cbi-button-save/-apply/-reset { display:none !important }`; the sbwml fork adds bare `th`/`td`/`tr:hover`/`.table` | every render |
| `luci-app-filemanager` (newer) | `:root { color-scheme: light dark; --light-bg:… }` | every render |
| `HexEditor.js` (same package) | `:root { --clr-background:… }` | **module eval** |
| `luci-app-banip`, `luci-app-adblock` | a `<link>` to `custom.css` styling `.cbi-input-text`, `.cbi-input-select` | **module eval** |
| `luci-app-olsr-viz` | `.label { color:black; background:white }` | every render |
| `luci-app-openvpn` (stock) | `h4 { white-space:nowrap; … }` | every render |
| `luci-app-podkop` | 4 KB: `#cbi-podkop-*`, `.centered`, `.rotate`, `.skeleton`, `.toast*` | **every render, no dedup** |
| `luci-app-mosdns` | three `<link>`s to CodeMirror 5 | every render |
| ACE (`luci-app-ssclash` and anyone shipping `ace.js`) | `ace_editor.css` (14 KB), `error_marker.css` | **module eval** |
The distinction that matters is not `<style>` vs `<link>` — it is **who can put the CSS back**:
- injected in render (filemanager, podkop): the app will repeat it on the next render;
- injected **at module eval** (ACE, HexEditor, banip, adblock): it will never repeat, because
the module is cached for the life of the document. Deleting such a sheet breaks the app until a
page reload. That is what happened to SSClash.
Separately: a `<style>`/`<link>` **inside the view's tree** (package-manager, nftables, aria2,
v2raya, nlbwmon) is harmless — it dies with the contents of `#view`.
### 2. It sniffs dark mode, in four different dialects
There is no standard, so apps guess:
- **`[data-theme="dark"]` on `:root`** — `luci-app-justclash` (21 rules);
- **`:root[data-darkmode="true"]`** — `luci-app-internet-detector` (and OpenClash writes it);
- **`data-bs-theme`** (a Bootstrap 5 convention) — `luci-app-ssclash` checks it first;
- **the brightness of `getComputedStyle(document.body).backgroundColor`** — ssclash (fallback),
OpenClash (`isDarkBackground`), passwall (four copies of a YIQ function). OpenClash then **stamps
`data-darkmode` on our `:root`** itself;
- **`@media (prefers-color-scheme: dark)`** — netspeedtest, vssr, passwall, ustreamer. That is the
**OS**, not the theme: a user with a dark theme on a light OS gets light cards on a dark page.
Unfixable in CSS, by construction.
### 3. Everything else that breaks, or could
- **z-index wars**: passwall `2147483647`, OpenClash `999999 !important`, dockerman/diskman
overlays at `20000`, podkop's toast container at `9999` in `document.body`.
- **Global resets**: OpenClash `*{margin:0;padding:0}` (twice), vssr `@import "pure.css"`.
- **Generic class names claimed**: `.hidden`, `.label`, `.description`, `.centered`, `.skeleton`,
`.toast`, `.flex-row`, `[hidden]{display:none!important}` (`luci-lib-taskd`).
- **Viewport media queries** (justclash 56rem, podkop 900px, netspeedtest 768px): our content
column is narrower than the viewport by the sidebar's width, so their "mobile" layout switches on
later than it should.
- **Hard-coded dark editors**: mosdns and AdGuardHome nail CodeMirror to `dracula`, ssclash nails
ACE to `tomorrow_night_bright`. The editor stays black on a light page — and on bootstrap too, so
it is not our regression.
- **Legacy Lua/CBI is still the majority** (OpenClash, passwall(2), ssr-plus, vssr, dockerman,
diskman, AdGuardHome, istore). Full reload on every click, and all their CSS lives exactly on
their page.
## The theme's rules
### Rule 1. View CSS is NEVER deleted
Deletion is irreversible: a sheet imported at module eval does not come back. Symptom: a
structureless black ACE and a 2-million-pixel page.
### Rule 2. A document holding an "invasive" sheet hands the next page over by full load
A sheet is invasive if it could paint somebody else's page. Three tests, all on the facts rather
than on a list of names — the set of theme names and custom properties is read out of `cascade.css`
at runtime, 0.3 ms per transition:
1. **A bare selector** — no class, id or attribute (`pre`, `*`, `svg text`, `div > label + select`).
It hits stock markup by construction.
*Exception:* if every declaration is a custom property the theme does not read
(`:root { --app-temp-status-temp: … }`), there is nothing for it to paint — inert. But
`:root { color-scheme: light dark }` (stock filemanager) is a standard property, and it re-points
every UA widget in the document at the OS setting.
2. **A stock name with no pin** — a selector made entirely of names the theme knows
(`.cbi-button-save`, `.label`, `.error`, `.cbi-input-text`). It has nothing to hold it to the app,
so it matches the same widgets on every page.
*Not invasive:* `#cbi-podkop-section > .cbi-section-remove`,
`.bandix-table th.sortable.active`, `.device-toolbar .cbi-input-select` — the stock class is
**pinned** by the app's own name and cannot match without its markup. Functional pseudo-class
arguments are discarded when looking for the pin: `.cbi-button-save:not(.custom-save-button)`
mentions its own name **inside a negation**, which is an exclusion, not a scope, so the sheet
stays invasive.
3. **An unreadable sheet** (`<link>` still loading, 404, cross-origin) counts as invasive — the
conservative answer is slow, not broken.
How that lands on the router's actual apps:
| SPA preserved | Full load on leaving |
|---|---|
| ACE/ssclash (`.ace_*`) | filemanager (`.cbi-button-save:not(…)`) |
| podkop (`#cbi-podkop-*`) | stock openvpn (`h4 { white-space: nowrap }`) |
| bandix tables, hex editor (`.hexview`) | bandix (`.error { … }` — a global class) |
| the overview CPU include | wrtbwmon (`div > label + select { min-width }`) |
| temp-status: its `:root { --app-* }` | temp-status: its own `svg text { fill }` |
Implementation: `fs-sheets.js` → `themeNames()`, `inertDeclarations()`, `invasiveSheet()`,
`documentPoisoned()`. It covers both `<style>` and `<link rel=stylesheet>`; `[data-fs-shell]` and
everything inside `#view` are excluded. Full detail: [spa-router.md](spa-router.md).
`fs-sheets.js` also re-declares the layer order as a new `<style>`, first child of `<head>`,
because a foreign sheet landing ahead of `cascade.css` inverts the cascade layers — see
[css.md](css.md).
### Rule 3. The only safe deletion is a byte-identical duplicate
`dedupeViewSheets()`. The rules do not disappear (the first copy stays), and a library's "have I
imported this?" check still finds its sheet. Without it, podkop accumulates one `<style>` per visit.
### Rule 4. Dark mode is announced in three dialects
`data-darkmode` (read only by the theme itself) plus `data-theme` and `data-bs-theme` for outbound
compatibility. `styles/` may never read the latter two — the same boundary as the export tier.
`tools/axes.mjs` holds it: the pre-paint in `head.ut` and the live `stampDark()` must stamp the same
set.
### Rule 5. `body` must have an OPAQUE background of the right brightness
This is what every brightness sniffer reads. A transparent `body` (background moved to `:root` or a
pseudo-element) makes OpenClash decide "light" — its `/rgb\(/` regex does not even match
`rgba(0,0,0,0)` — and repaint our dark page in its light palette. `tools/export-tier.mjs` holds it
across the whole palette × mode × tint matrix.
### Rule 6. The export tier is a contract, and it has to be a ramp
Apps read `--primary-color-*`, `--text-color-*`, `--background-color-*`, `--border-color-*` with a
literal fallback. The export surface is exactly **26 names**, and the warning family is called
**`warn`**: `--warn-color-high/-medium/-low` and `--on-warn-color`.
Two findings that used to be filed as one:
- **podkop's `var(--warn-color-medium, orange)` (7 uses) WORKS.** The name is correct and resolves
to the theme's ochre. There is no `--warning-color-*` anywhere in the tree, so "fixing" podkop to
use that would drop all seven declarations into the `orange` fallback. An earlier revision of this
document claimed the opposite; that was wrong.
- **justclash's `var(--text-color, inherit)` (6 uses) is a real typo.** There is no level in the
name and no such property; the correct one is `--text-color-high`. Do not invent `--text-color`
on our side: that fixes somebody's typo at the price of a name we would then support forever.
The private `--fs-*` tier (~125 properties) is not part of the contract and is not addressed to
apps — which is exactly why it is separated. See [design-system.md](design-system.md).
### Rule 7. The chrome is fenced off, in three places that must agree
An app's unlayered `!important` outranks every cascade layer, so the chrome cannot be defended by
the cascade. It is defended by not matching, in three places — and `npm run chrome-fence` is
what keeps them agreeing. Proven rather than assumed: breaking the fence constant to
`.fs-sidebarTYPO` left the menu completely unprotected while `npm run check`, `jsmin-verify` and
eslint **all exited 0**.
1. **`header.ut` — the markup.** A chrome root marks itself with `data-fs-chrome`. The `<nav>`
is one; so are the skip link, the two sr-only elements and the command palette, none of which sits
inside it. The
mark is what the other two read.
2. **`fs-sheets.js` — the fence.** `CHROME_FENCE` is appended to a foreign selector's subject so it
can no longer match a chrome element. That is what beats a third party's `!important`: there
is nothing left to outrank.
3. **`theme/10-chrome.css` — the pin.** It closes the one way in a fence cannot: inheritance
from `html`/`body`, where no match is needed at all.
The gate **derives the mark from the markup** and never restates it: rename it in `header.ut` and
the gate re-derives it, then fails on the two copies still saying the old name. The failure it
prevents has no symptom — the fence silently stops fencing, every test stays green, and the menu
breaks on someone else's router months later.
The fence and the pin are each **one canonical string** and are compared whole rather than tested
for tokens. That is not pedantry: the token version's four independent `includes()` checks all
passed on `:where(:not(.fs-sidebar), .fs-sidebar *)` — a plausible botched edit that is the exact
**inverse** of a fence, targeting the chrome instead of sparing it.
The shapes both strings encode, each a measured bug:
- **`:where()` in both**, because it contributes zero specificity. Drop it from the fence and every
app rule silently gains a point, re-ordering the app's stylesheet against itself on its own page.
Drop it from the pin and the pin starts fighting the chrome's own rules on source order.
- **The fence must cover the root AND its subtree** (`[m], [m] *`); the root alone leaves every menu
element inside it exposed.
- **The pin must cover the root ALONE**, and it says so itself (`:not([m] *)`) rather than trusting
that nobody ever nests a mark. Pinning descendants was measured and it broke the chrome's own
inheritance — a direct declaration beats an inherited one even when the inherited one is ours,
costing `.fs-label` its `nowrap` and forcing `text-align` from `start` to `left` on 302 elements,
which breaks every RTL language LuCI ships.
- **The pin may carry only inherited properties.** A non-inherited one there is a style decision
wearing a guard's coat, and at zero specificity it would lose to everything anyway.
**The fence is CSSOM surgery, not a CSS parser** — which is what makes an app written in modern CSS
a non-event rather than the next round of cat-and-mouse. Every selector the fence reads comes out of
the browser's own parser (`CSSStyleRule.selectorText`), and every one it writes goes back in through
the same parser's setter, which is atomic: a selector it cannot parse leaves the rule exactly where
it was. The only text this file ever scans is one already-serialised selector, and only to answer
two questions on it — where the top-level commas are, and where the `::` is. Nesting, `:has()`,
`:is()`, `@scope`, `@container` and an app's own `@layer` are the parser's problem, not ours; the
recursion that reaches them (`r.cssRules`) is three lines and does not care which at-rule it just
descended into.
Measured rather than argued, on owrt2512 (25.12.4) against the live theme: **21 hostile sheets, one
per syntax, each carrying `padding: 0 !important` on an unpinned selector, injected into `<head>`
after the theme's own modules had loaded** — `*`; a selector list; nesting with `&`, without `&` and
two levels deep; `:has()`; `:is()`/`:where()`; `:not(:has())`; bare `@scope` and `@scope … to …`;
`@supports`; `@media`; the app's own `@layer`; an escaped leading digit and an escaped `@`; a comma
and an IPv6 `::` inside an attribute value; a pseudo-element; and two sheets built entirely through
`insertRule()` (empty text, so `textIsSheet()` refuses the `@layer` wrap and only the fence stands).
**0 of 247 chrome elements moved in any of the 21, on both Blink and WebKit.** The negative control
is the same payload marked `[data-fs-shell]` so the module skips it: **63 elements flattened** (59
for the nested variant), which is what makes the twenty-one zeroes a working fence rather than a
broken measurement. Gecko (Firefox 153) rewrites all of them identically on a synthetic page.
Two things that fell out of it and are worth knowing:
- **A nested rule under a pinned parent is fenced too, redundantly.** `#app-x .thing { &:hover { … } }`
comes back as `&:hover:where(:not([data-fs-chrome], …))` — `&:hover` names nothing the theme does
not know, so it reads as unpinned on its own. It costs nothing (the `&` still requires `#app-x`,
which can never be chrome, and `:where()` adds no specificity) and the safe default is the right
one: a nested rule whose parent is itself unpinned MUST be fenced, and from inside `fenceRules()`
the two cases look the same. Every other shape round-trips byte-identical.
- **The gap the fence does not cover is time, not syntax.** A first attempt at the measurement
injected on a fixed 2 s timer and reported 121 of 247 elements flattened on WebKit — the stand is
slow enough that the payload landed before `fs-sheets.js` had loaded and started watching `<head>`.
That is the flash the module's own comment already describes for server-rendered `<link>`s, it
affects only the page you are on, and it is not fixable from a module that has to be fetched. Any
future probe must wait for the theme's JS, or it measures the gap and calls it a fence failure.
The same gate holds the dark-mode guard to `stampDark`: that guard exists because third parties
write the attributes this theme publishes (`luci-app-openclash` does it in seven templates). Add a
fourth dialect to `stampDark` and forget the observer's `attributeFilter`, and that dialect is
unguarded — silently.
### Rule 8. A foreign table is claimed by its HEADER ROW, not by its class
`.table` is LuCI's class, and every table rule the theme has hangs off it — the frame, the cell
padding, the measured card stack. An app that emits a bare `<table>` matched none of it, so nothing
tagged it, nothing measured it and `.fs-main`'s `overflow-x: clip` silently CUT its right-hand
columns; the only thing that ever reached it was a phone-tier scrollbar. `tagDataTables()` now takes
any table under a content root — `#view` or the `#modal_overlay` an app's dialog is built in — that
has a header row in any of four markups, adds `.table.fs-dt`, and copies the
column headings into `data-title` so the card has something to print. A table with **no** header row
is left alone on purpose and scrolls: see [css.md](css.md#which-of-the-three-a-table-gets-and-what-decides-it).
**The claim is re-made on every fit pass, not once per element.** These tables are polled: the app
replaces the rows inside the element it already has, so a claim that ran once left every batch after
it without `.tr`/`.td` and without a caption — on a table that may by then be carding, where
`#view .table.fs-dt.fs-stacked { overflow: hidden }` clips the fresh rows with no scrollbar. Both
halves are additive, and the one JUDGEMENT — "does this table speak LuCI's row vocabulary, or is it
ours to rewrite?" — is still taken once, at claim time, and remembered on the element, so LuCI's own
markup is never rewritten by a later pass.
**Each half answers the common case before it walks anything**, because a fit pass is a mutation
pass, not a once-a-second one. The adoption returns on its memo. The captioning skips **per row**:
a row whose first cell already carries a caption has been walked, so on everything LuCI renders —
where `ui.Table` writes `data-title` as it builds — the pass costs one attribute read per row and
nothing else. Measured on Processes, 114 rows and 678 cells: 0.134 ms per pass for the full walk
against **0.053 ms** with the per-row skip. Same tagged, carded and captioned counts as before the
change, and no long task over eight poll ticks.
The question is asked of the ROW because no single row speaks for the table. A table-level probe has
to pick one, and every choice is wrong for some shape: asking the last row stalls forever on a
`<tfoot>` of per-column totals, which takes captions on the first pass and is never bare again while
a poll keeps replacing the `<tbody>` above it — reproduced on a live table, where the fresh rows came
back with no caption at all. Per row there is nothing to guess, and cells replaced inside a
surviving row arrive bare and are walked like any other.
This is deliberately unmeasurable here. A census of `#view table:not(.table):not(.cbi-section-table)`
over all **196** menu pages on the stand — openclash, justclash, ssclash, dashboard and statistics
included — found **zero**: everything anyone here emits already carries the class. The rule exists for
the shape a census cannot reach, which is what the coverage contract means
([conventions.md](conventions.md)). The three synthetic shapes are checked instead, on Blink and
WebKit at 1440 px and 390 px: `<thead><tr><th>` and `<tr><th>` both card with the right captions, a
header-less matrix stays a scrolling matrix at both widths.
### Rule 9. A table inside the app's OWN scroller is the app's business
The fit pass cards a data table when it needs more width than it has been given. Inside a box the
app scrolls itself, that question has already been answered by somebody else, and every answer the
ladder can give overrules it: within a 598px box a wide table overflows by definition, so the
remedy ends in a card stack however much room the page has around it.
Measured on `luci-app-filemanager`, which parks its listing in `#file-list-container.resizeable`
(`resize: both; overflow: auto`, and no width of its own, so the browser's default box is what the
reader gets): the listing came out as cards on a 1280px screen with 1224px of column beside it.
Carded on the 0.14.3 package too, so this is not a regression of the fit rework — it is a case the
rule never covered.
`fit.inScroller()` walks from the table to the content root and answers whether any ancestor scrolls
horizontally; `fs-select.js` asks BEFORE it applies the slot's remembered answer, so a table carded
by an older build is un-carded rather than carded again. The walk stops at `#view` and
`#modal_overlay` on purpose: the dialog's own scroller is the theme's (`base/60-modal.css`), and the
scroll fallback the theme gives a foreign table is on the TABLE itself (`theme/30-tables.css`), not
on an ancestor — neither may switch this rule on for everything.
Swept over all 95 menu pages of a stand at 390px: **one** table matches, the file manager's. No
stock page has a data table inside a scroller of its own, so nothing that cards today stops carding.
### What the theme cannot do, and should not try
- `@media (prefers-color-scheme)` in an app's CSS is overridden by nothing — it is an OS setting.
The only mitigation is that our default is Auto, which follows the OS.
- Hard-coded literals (`background: white` on a passwall modal, justclash's `#1e1e1e` terminal, 167
colours baked into `luci-app-statistics` PNGs) are not our bytes.
- An app's z-index on its own page is its own page.
- An app's viewport media queries do not know about our sidebar.
**And what the claim buys it, since 0.12.6.** A claimed table is measured like any other data table,
which means it gets the whole ladder: honest column floors while it is a table, the expendable
columns (`hide-xs`/`hide-sm`) dropped by measurement rather than at 767px, one column allowed to
shred before the table gives up on being a table, and the card as the last rung. A table that has
**no** header row is never claimed — it cannot card, because a card prints `data-title` and it has
none — and instead scrolls inside itself if it is measured to overflow, with its first column
pinned, a tab stop and an accessible name.
**A foreign table that holds a widget is refused the scroll**, and an app author should know why:
`overflow-x: auto` computes `overflow-y` to `auto` as well (css-overflow-3 §3.1), so a scrolling
table clips every absolutely positioned thing inside it, and `luci-base`'s `openDropdown()` sizes an
open list against the nearest scroll parent — which would be the table. If your table holds a
`ui.Dropdown`, a dynlist or a tooltip container, the theme leaves it alone rather than break the
control.
## How to check
```sh
npm run check # includes axes (rule 4) and export-tier (rules 5, 6)
```
On a live router, the scenario that would catch everything above:
1. open an app with ACE/CodeMirror (ssclash) → SPA to a neighbouring tab → back. The editor must
still be alive (rule 1);
2. open File Manager → go to System. Save/Apply/Reset must still be there (rule 2);
3. visit podkop three times over SPA. The number of `<style>` elements in `<head>` must not grow
(rule 3);
4. justclash in dark mode: its `[data-theme="dark"]` rules must match (rule 4).
## Appendix: the second ten by stars
quickfile, tailscale-community, smartdns, internet-detector, 3ginfo-lite (client JS — brought up on
the router and rendered) plus cloudflarespeedtest, aliddns, koolproxy, ech-workers, sms-tool
(legacy Lua — read).
**All ten preserve SPA navigation, and none injects anything that could paint someone else's page.**
- `luci-app-internet-detector` is the only one of the ten that injects into `<head>` at all, at
module eval: `:root { --app-id-* }` (its own prefix, so inert by our rule) plus its own `.id-*`
classes. Its dark block hangs on `:root[data-darkmode="true"]` — our attribute — so dark mode
works for it out of the box. A fourth detection dialect, and we already stamp it.
- `luci-app-smartdns` is the model: `E('style', [css])` returned inside `.cbi-map`, so it dies
with the view.
- Legacy Lua apps put `<style>` in the page template, including bare `table { … }`. It cannot leak
(the page is server-rendered and SPA never enters), but light surfaces stay light on a dark
theme — unfixable from outside.
The rules above cover the entire second ten with no theme change. Breakage is concentrated in the
big old apps, not in the mass.
## Appendix: `luci-app-podkop`, the app the fence was tested against
Checked against `itdoginfo/podkop@main` sources, not from memory: the injected CSS was assembled
from its template literals and **replayed on a live router** exactly the way the app does it.
**What it does right:**
- **One injection point**, one `<style>` in `<head>`. Not a `<link>` from the template, not
`insertRule()` in pieces.
- **Names in its own space**: 30 of 40 selectors carry the BEM prefix `pdk_dashboard-page__…`, the
rest are pinned to its own ids. Our "pinned to the app" signal is a name the theme does not know,
and it is present in nearly every rule.
- **It reads the export tier, not our private tokens**: `--background-color-low`,
`--success-color-medium`, `--warn-color-medium`, `--error-color-medium`, `--primary-color-high`,
`--primary-color-low`, `--text-color-high`. All seven are defined and follow the mode.
**Our verdict on its real CSS:** `documentPoisoned()` is `false`, 0 chrome elements damaged, the
sheet is not rewritten. So SPA is not disabled on podkop's pages and the theme leaves its styling
alone. That is the "we are not over-clamping" check: the judge was tightened, and the model app
still passes untouched.
**What stayed generic, and why it is survivable:** `.centered`, `.rotate`, `.skeleton`, `.toast*`
have no prefix. The theme does not style them, so by our test they count as pinned to the app and
are left alone. That is luck, not protection — had the app called a class `.center` or `.table`, it
would have landed in the shared vocabulary. Advice to authors: prefix every name, helpers
included.
**What we borrowed from it:** the loading placeholder. podkop draws a skeleton while its data
arrives; our SPA had the same hole on a cold route. See [spa-router.md](spa-router.md).