update 2026-09-20 01:22:33
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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"><head></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"><style></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"><style></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"><font color></code></div>
|
||||
<div><span class="k" style="color:var(--fs-warn)">Flagged for you</span>
|
||||
CSS in <code class="dk-inline"><head></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"><font color></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"><link></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"><head></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&&L.env&&L.env.media)||'x',o=[];function tag(c){var sel=((c.match(/([^{}]+)\{/)||['',''])[1]||'').trim().slice(0,70);var mine=P&&c.toLowerCase().indexOf(P)>=0;return '/* <head> 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<head><style>\n'+c+'\n</style></head>');});document.querySelectorAll('head link[rel=stylesheet]').forEach(function(n){if(n.href.indexOf(t)<0&&n.href.indexOf('cascade.css')<0)o.push('<head><link rel="stylesheet" href="'+n.href+'"></head>');});var s=new Set();v.querySelectorAll('[style]').forEach(function(e){var x=e.getAttribute('style');if(x&&!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('<font color="'+f.getAttribute('color')+'">x</font>');});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 & 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"><style></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"><head></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 */
|
||||
<font color="green"> /* 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 & 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"><style></code> returned inside the view tree, not appended to <code class="dk-inline"><head></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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
|
||||
/* 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><font color></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><head></code> from JS', 'Under an SPA theme it outlives your view and restyles every later page. Return the <code><style></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><style></code> / <code><link></code> in <code><head></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><link></code> an app stylesheet from <code><head></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>
|
||||
|
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`) всё равно выглядят неправильно на тёмной странице, и снаружи этого не починит
|
||||
ни одна тема.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -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).
|
||||