Files
small-packages/luci-theme-footstrap/docs/package.md
T
2026-09-20 01:22:33 +08:00

32 KiB
Raw Blame History

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.

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.

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

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.

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 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:

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) — 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 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.