Files
2026-10-29 18:46:39 +08:00

880 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Development Guide
A getting-started guide for contributors new to `luci-app-aurora-config`. It
covers the big picture, the parts that are easy to get wrong, and recipes for
common tasks.
> **What this is.** The configuration app for the Aurora theme
> (`luci-theme-aurora`). It adds a LuCI admin page where users tune theme
> colors, layout, branding, and the toolbar, plus a Configuration Marketplace page to
> browse, apply, and share complete configurations with other users. This
> repo only owns the *configuration UI* and *writes settings into UCI* — the
> actual rendering lives in the theme package.
---
## 1. One build step, and it is minification only
**Edit `.dev/src/resource/`. Never edit `htdocs/`** — it is generated, and the
next build overwrites it.
```bash
cd .dev && pnpm install # once
pnpm build # .dev/src/resource/ -> htdocs/luci-static/resources/
pnpm test # runs --check first, so a forgotten build fails here
```
There is no bundler and no transpiler. Each file is minified in place and stays
a separate resource, because LuCI's own loader is what resolves
`"require utils.hub-api as hubApi"` — it fetches
`/luci-static/resources/utils/hub-api.js` over HTTP. Merging files would break
that. The frontend uses LuCI's `E()` DOM API, not React/Vue; the backend is a
single rpcd shell script.
**Why the artifacts are committed.** uhttpd has no gzip — there is no
`Content-Encoding` anywhere in its source — so every byte is a flash read and a
socket write on the router. And the feed builds each package by cloning its
repo at a git ref and compiling from source
(`openwrt-cloud/feed/scripts/resolve-refs.sh`), so minifying only in our own
release workflow would compress the one channel nobody installs from. The
generated bytes have to be in git for anyone's build to get them.
`.gitattributes` marks `htdocs/luci-static/resources/**` as
`linguist-generated`, so diffs there fold away — read the source instead.
Everything in `.dev/scripts/` writes to `.dev/src/resource/`; `build-js.mjs` is
the only thing that writes to `htdocs/`. That way "are the artifacts fresh?" has
exactly one answer, and `--check` is the one place that answers it.
To test a change on a device, run `pnpm build`, then sync `htdocs/` and `root/`
to the matching paths and reload the page or restart rpcd.
---
## 2. Repository layout
```
.dev/ # Sources, tooling, tests. Not installed.
├── package.json # The only install root (pnpm). Pins luci-theme-tokens
├── scripts/build-js.mjs # terser -> htdocs. The ONLY writer of htdocs
├── tests/paths.mjs # repo() / srcPath() — tests never depend on cwd
└── src/resource/ # ★ EDIT HERE
├── view/aurora/
│ ├── studio.js # Theme Studio: color editor, layout, branding, toolbar (largest file)
│ └── marketplace.js # Configuration Marketplace: browse/apply, share, "my shares"
├── aurora/presets.json # Store card data: the 4 swatch colours + layout/typography per preset — GENERATED
└── utils/
├── color.global.js # Vendored colorjs.io (global `Color`)
├── color.global.js.license # Its three MIT notices; build-js re-attaches them as a banner
├── tokens.global.js # ★ Color derivation engine (global `AuroraTokens`) — GENERATED, do not edit
├── theme-preview.js # Shared light/dark split preview card
├── feed-check.js # Version comparison + package-page lookup (pure, unit-tested)
├── hub-api.js # rpc.declare wrappers for the hub_* RPC methods + list cache
└── asset-upload.js # Shared dropzone/upload widget (fonts, brand assets)
htdocs/luci-static/resources/ # GENERATED by `pnpm build` — committed, never hand-edited
root/
├── etc/uci-defaults/80_aurora # First-install setup + schema migration
├── etc/sysupgrade.conf.d/aurora-device # Preserves /etc/aurora/device.{key,hash} across sysupgrade
├── usr/libexec/rpcd/luci.aurora # Backend RPC (shell)
└── usr/share/aurora/
├── *.template # Five built-in presets (UCI fragments). Colour block GENERATED; the layout/typography block below it is hand-authored
├── color-tokens.conf # Ordered token key list for the backend — GENERATED
└── font-presets.conf # Font preset manifest (Fontsource packages + pinned versions) — GENERATED
.dev/scripts/sync-tokens.mjs # Vendors tokens.global.js + color-tokens.conf + aurora-presets.json (resolved preset hex) from @eamonxg/luci-theme-tokens, stamps TOKENS_ENGINE_VERSION into studio.js, then runs gen-presets
.dev/scripts/gen-presets.mjs # Injects the colour block into *.template, reads each template's layout/typography back out, and writes src/resource/aurora/presets.json (swatch colours only — §8.1)
.dev/scripts/aurora-presets.json # Resolved preset hex values, vendored by sync-tokens.mjs — GENERATED
.dev/scripts/gen-font-presets.mjs # Regenerates font-presets.conf (curated manifest lives in this file)
.dev/scripts/gen-pot.mjs # Scans src/resource for _() calls; its "#:" refs deliberately still name htdocs, which is where an installed reader finds the string
Makefile # OpenWrt package metadata (version lives here)
```
---
## 3. The color token system (core concepts)
This is the part most likely to trip you up. Read it before touching colors.
### 3.1 Inputs vs. derived tokens
The entire palette is driven by **10 editable inputs**; every other color is
**derived** from them.
| Kind | Count | Examples |
| --- | --- | --- |
| Inputs (user-editable) | 10 | `bg`, `surface`, `text`, `brand`, `on_brand`, `link`, `info`, `warning`, `success`, `danger` |
| Derived (computed) | 20 | `text_muted`, `surface_sunken`, `brand_hover`, `brand_subtle`, `focus_ring`, `*_surface`, `scrim`, `mega_menu_bg`, … |
The derivation rules (`mix` / `shade` / `set` / `alpha` / `const`) live in the
theme's token spec. **`utils/tokens.global.js` is the prebuilt browser bundle
from the `@eamonxg/luci-theme-tokens` npm package**, vendored by
`.dev/scripts/sync-tokens.mjs` — never edit it by hand (see §3.4).
### 3.2 Why the config app computes derived tokens itself
The theme authors derived tokens in `_tokens.css` **at build time** as flat
color literals — they no longer reference `var()` / `color-mix()`. The compiled
`main.css` is emitted as hex fallbacks plus `lab(...)` values for compatible
browsers. As a result, **overriding `--brand` alone does not cascade** into
`--brand-hover`, `--focus-ring`, and friends.
So the config app's strategy is: the user edits the 10 inputs → the frontend
expands them into all 30 values via `AuroraTokens.resolve()` → **all 30 are
written to UCI as hex/hex8 runtime colors** → the theme's template injects them,
overriding the baked defaults wholesale. Advanced users may also override
individual derived values; blank derived fields continue to follow the generated
values.
### 3.3 End-to-end data flow
```
User edits input colors in the UI
│
▼
studio.js: persistDerivedTokens()
runs AuroraTokens.resolve(mode, <10 inputs>) for light + dark
writes 30 light_<key> / dark_<key> hex entries into UCI (aurora.theme)
│ on save
▼
/etc/config/aurora ← UCI storage
│
▼
Theme template header.ut, at page render:
emits each light_* as :root { --<key> }
emits each dark_* as [data-darkmode=true] { --<key> }
│
▼
Overrides the baked defaults in _tokens.css → user's palette renders
```
> ⚠️ `header.ut` injects **every** `light_*` / `dark_*` key indiscriminately.
> Stale keys left in UCI become dangling overrides pointing at CSS variables
> that no longer exist. Pruning them is the job of schema migration (see §5).
### 3.4 Single source of truth
The token model lives in one npm package:
- **`@eamonxg/luci-theme-tokens`**
([repo](https://github.com/eamonxg/luci-theme-tokens)) — an independent repo
with its own semver, published from its own root. It owns the engine (the
five operators + spec-driven resolver, also exported standalone as
`@eamonxg/luci-theme-tokens/engine`), the aurora spec (`aurora/spec.js` /
`aurora/defaults.js` / `aurora/presets.js`), **and all derivation** — its
`build.mjs` resolves every derived token and every built-in preset at
*package* build time and ships the results as prebuilt `dist/aurora/`
artifacts. `luci-theme-aurora` and this repo are both just consumers of the
prebuilt output; neither derives anything locally anymore.
This repo **vendors** that package's `dist/aurora/` output by the exact
version pinned in `.dev/package.json` — `.dev/scripts/sync-tokens.mjs` copies three
files verbatim, no local derivation:
- `utils/tokens.global.js` ← `dist/aurora/tokens.global.js` — the browser
global, still used by `studio.js` for live preview / on-save recompute.
- `root/usr/share/aurora/color-tokens.conf` ← `dist/aurora/color-tokens.conf`
— the ordered key list the backend (`luci.aurora`) reads at runtime.
- `.dev/scripts/aurora-presets.json` ← `dist/aurora/presets.json` — every built-in
preset, fully resolved to runtime hex, for **all 5** presets × light/dark.
- `view/aurora/studio.js` — only the `TOKENS_ENGINE_VERSION` constant, stamped
from the vendored package's `package.json` version. `studio.js` appends it as
`?v=` when loading `tokens.global.js`, so a version bump busts the browser's
HTTP cache.
It then reruns `gen-presets.mjs`, which is a pure formatter: it reads
`.dev/scripts/aurora-presets.json` and injects `option light_<key>` /
`option dark_<key>` lines into `root/usr/share/aurora/*.template`, preserving
each template's non-color tail (`struct_*`, toolbar, etc.). It performs no
color math and does not load `tokens.global.js` — all values are already
resolved upstream.
`sync-tokens.mjs` resolves the package from three sources, in order:
1. `--local <path>` — a local `luci-theme-tokens` checkout, for iterating on
an unreleased spec (run `npm install` + `node build.mjs` there first).
2. **npm registry** — the version pinned in `.dev/package.json` devDependencies;
this is the normal path.
3. **sibling checkout** `../luci-theme-tokens` — fallback when the registry is
unreachable; logs a warning since the output may not match the pin exactly.
All vendored artifacts are committed, so nothing downstream (CI, SDK build,
device) needs npm or the luci-theme-tokens repo. The pinned version doubles as
the compatibility statement: it names the luci-theme-tokens release this app
targets.
Drift is caught in three layers:
1. `studio.js` joins its UI metadata with the registry at load time and throws
on missing/stale entries (`buildColorTokenTables`).
2. `.dev/tests/theme-token-sync.test.mjs` re-runs `sync-tokens.mjs --check` against
the registry (the check also covers the `TOKENS_ENGINE_VERSION` stamp in
`studio.js`, and a dedicated test asserts it matches the engine header).
3. `.github/workflows/token-sync-check.yml` runs the same check in CI (push/PR
and weekly); Renovate/Dependabot can bump the pin when luci-theme-tokens
releases.
Consumers of `tokens.global.js` at runtime:
1. **Frontend** — `studio.js` loads it for live preview, on-save computation,
and the ordered token tables (`COLOR_TOKENS` et al.).
(`.dev/scripts/gen-presets.mjs` no longer loads it — see above; the presets it
emits come straight from `.dev/scripts/aurora-presets.json`.)
---
## 4. Recipes
### A. Retune a preset / add a new one
A preset is a **whole look, not a palette**: its template carries the 62
colour options *and* a structural block — `nav_type`, `struct_spacing`,
`struct_radius_base`, `struct_content_width_centered`, `struct_font_sans`,
`struct_font_mono`, `toolbar_enabled` (the shell's `PRESET_STRUCT_KEYS`).
`apply_theme_preset` writes all of it. The two halves have different owners:
- **Colours** come from upstream. Preset ownership moved into
`@eamonxg/luci-theme-tokens` 2.0.0: the `PRESETS` map lives in that
package's `aurora/presets.js`, its `build.mjs` resolves every preset to hex
and ships `dist/aurora/presets.json`, and `gen-presets.mjs` only formats
whatever `.dev/scripts/aurora-presets.json` says.
- **Structure** is authored here, in the template itself. `sync-tokens.mjs`
overwrites `.dev/scripts/aurora-presets.json` on every sync, so nothing this repo
decides may live there.
To retune colours:
1. `luci-theme-tokens` repo: edit the `PRESETS` map in `aurora/presets.js`
(each preset specifies only the **10 inputs** × light/dark), `npm test`.
2. Tag `vX.Y.Z`, push — CI auto-publishes.
3. `config` (here): bump the pin in `.dev/package.json` → `node
.dev/scripts/sync-tokens.mjs` — refreshes `.dev/scripts/aurora-presets.json` and
reruns `gen-presets.mjs`, which rewrites `root/usr/share/aurora/*.template`
in place, preserving each template's non-color tail (`struct_*`, toolbar,
etc.) → commit.
To iterate on an unreleased preset: `node .dev/scripts/sync-tokens.mjs --local
../luci-theme-tokens`.
`sync-tokens` writes into `.dev/src/resource/`, so finish with `pnpm build`
before committing.
To retune the look, edit the structural block in
`root/usr/share/aurora/<name>.template` directly, then `pnpm gen-presets`
(it re-reads the block into the browser copy). Then `pnpm build`. Two rules the tests enforce:
- Numeric values must sit on the settings page's slider steps (spacing 0.05,
radius 0.125, width 1) and inside the bounds
`validate_and_apply_hub_payload` enforces on hub configurations.
- Font stacks must be **copied verbatim** from field 7 of the matching
`font|<slot>|<id>|…` row in `font-presets.conf`. The device reverse-maps the
stack back to a roster id to know which woff2 files to fetch; a retyped
stack matches nothing and the preset silently renders its fallback family.
A brand-new preset also needs:
- a new `root/usr/share/aurora/<name>.template` file in this repo (copy an
existing one — `gen-presets.mjs` only rewrites templates that already
exist, it never creates one)
- `marketplace.js` → `BUILTIN_PRESETS` (store card) and `BUILTIN_SEED_RE` (so the
hub's own seed of it is not listed twice)
- `luci.aurora` → `resolve_preset_path()` (name → template path)
- `80_aurora` → template fallback chain (optional)
- `.dev/tests/builtin-presets.test.mjs` → `PRESET_IDS`
### B. Add or change a color token
> The token spec's single source of truth is the standalone
> `luci-theme-tokens` repo's `aurora/spec.js`/`aurora/defaults.js`, released
> as `@eamonxg/luci-theme-tokens`; everything here is vendored from that
> package.
1. `luci-theme-tokens` repo: edit `aurora/spec.js`/`aurora/defaults.js`,
`npm test`.
2. Tag `vX.Y.Z`, push — CI auto-publishes.
3. `theme`: bump `.dev/package.json` → `npm install` → `npm run build`.
4. `config` (here): bump the pin in `.dev/package.json` → `pnpm sync-tokens` —
refreshes `tokens.global.js`, `color-tokens.conf`, and
`.dev/scripts/aurora-presets.json` (and, via `gen-presets.mjs`, the preset
templates) in one go (if there's a new token, follow the test prompts to
fill in `studio.js` copy).
To iterate on an unreleased spec: `node .dev/scripts/sync-tokens.mjs --local
../luci-theme-tokens`.
5. For a new token, add its UI copy in `studio.js` →
`COLOR_TOKEN_METADATA` / `DERIVED_COLOR_TOKEN_METADATA` (and a group entry if
needed). The tests and `buildColorTokenTables()` fail loudly until every
registry token has metadata.
6. **Bump `SCHEMA_VERSION` in `80_aurora`** (see §5).
7. `pnpm build` → `pnpm test` → commit. Steps 4 and 5 both wrote into
`.dev/src/resource/`, so the artifacts are stale until this runs; `pnpm test`
refuses to pass otherwise.
### C. Change frontend code
This is the everyday loop. The one thing to remember is that `htdocs/` is
generated, so an edit is not finished until it has been built.
```bash
cd .dev
$EDITOR src/resource/view/aurora/studio.js # edit the SOURCE
pnpm build # regenerate htdocs/
pnpm test # --check runs first
cd .. && git add -A && git commit # source and artifact travel together
```
Forgetting `pnpm build` is caught locally: `pnpm test` runs
`build-js.mjs --check` before the suite and fails naming the stale file. The
same check catches the reverse mistake — someone hand-editing `htdocs/`.
Two things that look like staleness but are not:
- **Adding or changing a comment leaves the artifact identical.** The
compressor strips comments, so `--check` correctly reports "up to date".
- **A terser upgrade moving the output by a few bytes** is expected; commit the
regeneration like any other.
To try it on a real device, copy `htdocs/` and `root/` to the matching paths
and reload the page (`/etc/init.d/rpcd restart` for backend changes).
### D. Verify without a device
```bash
cd .dev
# Everything: artifact freshness, then the full suite
pnpm test
# Just the artifacts vs. the sources
pnpm check
# Generated artifacts in sync with the pinned luci-theme-tokens package?
node scripts/sync-tokens.mjs --check
# Shell syntax (not covered by pnpm test)
sh -n ../root/usr/libexec/rpcd/luci.aurora
sh -n ../root/etc/uci-defaults/80_aurora
```
`pnpm test` asserts against **the shipped bytes**, not only the sources.
`.dev/tests/artifact-safety.test.mjs` checks that every artifact parses, keeps
the `require` directives and top-level `return` LuCI needs, still declares the
`*.global.js` globals, carries the vendored MIT notices, points at no
uninstalled sourcemap, ships no hidden file, and preserves every `_()` msgid
byte-for-byte. §11 explains why each of those can break.
---
## 5. Schema migration & upgrades
When the *meaning* of a color token changes incompatibly, snapshots in an
existing `/etc/config/aurora` go stale — they can inject invisible or wrong
colors, or leave behind obsolete keys. This is handled in
`root/etc/uci-defaults/80_aurora`:
- `SCHEMA_VERSION` at the top is the current version; the header comments record
what each version (v2 / v3 / v4 / …) changed.
- On upgrade, `migrate_color_schema()` compares the stored `config_version`; on a
mismatch it calls `reseed_colors_from_template()`, which:
- re-sets every `light_*` / `dark_*` key from the **current template**, and
- **deletes** any key absent from the template — this is what clears stale
state.
- On a fresh install it simply copies the template to `/etc/config/aurora`.
**Any change that alters the key set or token semantics must bump
`SCHEMA_VERSION` and add a comment** — otherwise existing users never run the
migration on upgrade.
---
## 6. Backend RPC reference (`luci.aurora`)
A shell rpcd script exposing the object `luci.aurora`. The frontend calls it via
`rpc.declare({ object: "luci.aurora", method: ... })`. The full method list is
the `case "$1" in "list")` block at the end of the script. Common methods:
| Method | Purpose |
| --- | --- |
| `get_init_data` | Read first-paint data in one RPC: installed versions, font presets, icons, and the active preset snapshot |
| `get_theme_preset` | Read a preset snapshot for UI placeholders and comparison |
| `apply_theme_preset` | Apply a built-in preset: colours **plus** navigation, spacing, radius, content width, both font stacks and the toolbar switch. Shortcut sections and brand images are left untouched |
| `export_config` / `import_config` | Configuration import/export |
| `list_icons` / `upload_icon` / `remove_icon` | Icon management |
| `prepare_font` / `get_font_presets` / `get_font_status` | Font handling |
| `upload_font` / `remove_font` | Custom (user-uploaded) font management |
| `hub_apply` / `get_hub_status` | Marketplace: kick off an async apply job / poll its progress |
| `hub_restore_backup` | Marketplace: one-tap rollback to the pre-apply snapshot |
| `hub_share` / `hub_update` / `hub_delete` | Marketplace: publish the current config / edit / unpublish a share |
| `hub_me` | Marketplace: this device's creator profile and everything it has published |
| `hub_set_nickname` / `hub_export_key` / `hub_import_key` | Marketplace: name the creator identity / back it up / restore it on another router |
Browsing the store is *not* in this list: the browser fetches the hub's public
list and detail endpoints directly (`utils/hub-api.js`), so no shell forks and
no rpcd round trip stand between the store page and its cards. Only calls that
need the device's write credential or the device's own filesystem go through
rpcd.
ACLs live in `root/usr/share/rpcd/acl.d/luci-app-aurora.json`; the menu entries
(`Theme Studio`, `Configuration Marketplace`) in
`root/usr/share/luci/menu.d/luci-app-aurora.json`. §8 below covers the Theme
Store in detail.
> `load_preset_snapshot()` validates that a template's `option (light|dark)_`
> line count equals `COLOR_TOKEN_KEYS count × 2`. The key list ships as
> `/usr/share/aurora/color-tokens.conf`, generated by `sync-tokens.mjs` from
> the same registry as the templates, so the two cannot drift apart. It then
> calls `load_preset_struct_snapshot()`, which requires **every**
> `PRESET_STRUCT_KEYS` option and checks each against the same bounds
> `validate_and_apply_hub_payload` applies to a hub configuration — a preset
> and a shared config are the same kind of thing, so one must not be able to
> write a value the other rejects. A template missing any of them fails the
> whole apply rather than landing half a look on top of the previous one.
---
## 7. Font system (v2)
Like the color templates, `root/usr/share/aurora/font-presets.conf` is a
**generated file — never hand-edit it**. Regenerate it with:
```bash
pnpm gen-font-presets
```
`.dev/scripts/gen-font-presets.mjs` holds the curated manifest (Fontsource npm
package id, pinned version, and weights per preset). Running it re-downloads
every woff2 from jsDelivr, recomputes its sha256, and rewrites the conf —
commit the result.
### 7.1 `font-presets.conf` format
```
v2|generated-by-gen-font-presets|do-not-edit
font|<slot>|<name>|<label>|<source>|<family>|<stack>
file|<slot>|<name>|<weight>|<sha256>|<url_jsdelivr>|<url_npmmirror>
```
- One `font` line per preset (`slot` is `sans` or `mono`); built-in presets
(`Lato`, `System UI`/`System Mono`) have no matching `file` lines.
- One `file` line per weight for Fontsource-backed presets, carrying the
sha256 and both a jsDelivr and an npmmirror URL for the same file.
- `.dev/tests/font-presets.test.mjs` guards this format (field counts, slot/weight
enums, sha256 shape, URL patterns) and runs as part of `npm test`.
### 7.2 Download pipeline (`luci.aurora`)
`prepare_font` downloads each required file **primary (jsDelivr) → fallback
(npmmirror)**, verifying the sha256 from `font-presets.conf` after each
attempt; a hash mismatch counts as a failure and triggers the fallback too.
The `@font-face` rule is always generated locally on the router — no remote
CSS is ever fetched or served to the browser. A slot's job status starts at
`ready` (queued/in progress) and settles on one of two final values:
`cached` (a verified file is in place — pure-stack presets with nothing to
download report this too) or `fallback` (download/verification failed and
the built-in face is used instead).
`fonts/preload.txt` is a single-line marker file consumed by the theme's
`header.ut` to emit `<link rel="preload">` for the active webfont:
- **Empty** — deliberately no preload (e.g. the built-in/system face is
active, nothing to preload).
- **Absent** — an older layout that predates this marker; the theme falls
back to preloading Lato.
### 7.3 Custom fonts
Users can upload their own woff2 via the `upload_font` RPC method (and remove
it via `remove_font`):
- Upload goes through cgi-io to `/tmp/aurora_font.tmp` (fonts) or
`/tmp/aurora_icon.tmp` (brand assets), then a `receive_upload` gate in
the rpcd script validates before anything touches flash:
- shared size cap: 8MB (`MAX_UPLOAD`);
- fonts: woff2 magic bytes (`wOF2`);
- images: extension allowlist `jpg jpeg png webp svg ico`, path
components rejected. The set is exactly what the themes hub will accept,
so nothing you can put on the router is something you cannot share --
avif and gif are absent because no asset slot can use them;
- the gate deletes the tmp file on every rejection (front-end callers
never clean up).
- Front-end plumbing lives in `utils/asset-upload.js` (dropzone, progress
row, delete confirm, cgi-upload XHR); `view/aurora/studio.js` composes it
for both the Custom Fonts and Brand Asset Library sections.
- Stored under `/www/luci-static/aurora/fonts/custom/<slot>-<slug>.{woff2,meta,face}`
(`.face` is the pre-rendered `@font-face` block, `.meta` carries the
display family + font stack).
- Custom faces are always included in the combined `aurora-font.css`, and
selection flows through the `struct_font_*` UCI stacks exactly like preset
fonts.
---
## 8. Configuration Marketplace
The Marketplace lets users browse configurations shared by other users, apply
one with a single click, and share their own — all through a public hub at
**`https://themes.eamonxg.fun`**. Nothing about it changes how colors are
derived or how UCI is structured; it is a distribution layer on top of the
existing config format.
### 8.1 Pieces
- **`view/aurora/marketplace.js`** — the `admin/system/aurora/marketplace` page: a
card grid (Hot/New sort, swatch preview, downloads count) that opens a
detail modal per config (palette, layout, typography, included assets) with
an Apply button; a "Share My Configuration" modal; and a "My Shares" table
(update-with-current-config / delete) shown only when the device has
published at least one share. All hub-sourced text (name, author, hex
values, toolbar URLs) is rendered via `E()`'s safe children or
`document.createTextNode` — never `innerHTML` — since it is untrusted
server content.
- **`utils/hub-api.js`** — `rpc.declare` wrappers for every `hub_*` method
against the `luci.aurora` object, plus a 5-minute `localStorage` cache
(`aurora.hub.list`) for the "hot" list so the page can paint instantly from
a stale copy while it revalidates.
- **`root/usr/libexec/rpcd/luci.aurora`** — the backend: talks to the hub over
HTTP, validates everything it gets back, and applies it to UCI.
- **`aurora/presets.json`** — the built-in preset cards, so they render with no
round trip and work offline. See below for why it is not the templates.
**Why the built-in presets exist twice.** `root/usr/share/aurora/*.template`
and `aurora/presets.json` hold the same five palettes in two shapes, on
opposite sides of the ubus boundary, and neither can replace the other:
- The **templates** never leave the router. `80_aurora` reads
`default.template` at *install* time, when no browser exists; then
`apply_theme_preset` and `reset_defaults` read them, and
`get_theme_preset` feeds the settings page its placeholder values. Applying a
built-in preset costs one ubus call carrying a name — the router does the
rest. Deleting the templates would mean the browser posting all 62 colours
plus layout and typography and the shell validating the lot, which is more
traffic, more work, and more attack surface than a name string.
- **`presets.json`** is downloaded by every visitor to the store, so it carries
only what the store reads: the four `SWATCH_KEYS` (`bg`, `surface`, `text`,
`brand`) × light/dark, plus layout, typography and the empty toolbar. It used
to carry all 62 colours; 7,968 of its 11,017 bytes were never looked at.
Replacing it with rpcd calls would put five shell forks on the store's first
paint — the thing that page was explicitly optimised away from — and break
offline rendering.
`gen-presets.mjs` writes both from one source, so they cannot drift, and
`builtin-presets.test.mjs` parses `SWATCH_KEYS` out of `marketplace.js` to make
sure the generator's copy of that list stays in step. Add a key there and the
test fails until the generator follows, rather than the card quietly rendering
a blank swatch.
### 8.2 rpcd methods (object `luci.aurora`)
| Method | Purpose |
| --- | --- |
| `get_hub_status(job_id)` | Poll a job's `state`/`step`/`error`; the frontend polls this every 1.5s (`marketplace.js: pollApplyStatus`) |
| `hub_apply(id)` | Forks an async apply job (`hub_apply_worker`) in the background, returns a `job_id` immediately |
| `hub_delete(id)` | Unpublish share `id` |
| `hub_export_key` | Read `device.key` back out so a creator identity can be moved to another router |
| `hub_import_key(key)` | Adopt an exported identity, taking over the shares it owns |
| `hub_local_state` | What the router is actually wearing: `active_source` (which card the look came from), `modified` (whether it still equals that card), and `backup` (the look it had before, projected into the same preview shape a presets.json row carries). Local only — no hub round trip |
| `hub_me` | One request for both halves of the answer: who this router publishes as, and what it has published |
| `hub_restore_backup` | Put back the single most recent pre-apply snapshot. Reached from the "My configuration" card, not from a button of its own |
| `hub_set_nickname(nickname)` | Set the creator name the hub shows on this device's shares |
| `hub_share_begin(name, description, target_id)` | Open a publish draft on the hub and return one upload ticket per asset, plus the local `/luci-static/` URL backing each. `target_id` empty publishes a new entry, non-empty replaces that share |
| `hub_share_commit(draft_id)` | Claim a draft whose assets the browser has finished uploading, turning it into a config (or an update) |
`hub_list`/`hub_get`/`hub_my_shares` are gone. The first two moved into the
browser (see §8.1); `hub_my_shares` walked a local id list and re-validated
each id against the hub on every page load, and `hub_me` replaced it with a
single request whose answer is the hub's own — there is no local record left to
lose or wrongly prune. `tests/rpcd-hub.test.mjs` pins this table to the ACL, so
adding a method without documenting it fails the suite.
The three helpers that talk to the hub — `hub_http_get`, `hub_http_put`,
`hub_http_delete` — return **0** on success, **2** when the hub answers 404,
and **1** when it cannot be reached at all. The distinction is not free:
uclient-fetch exits 8 for every error status alike and `-q` suppresses the one
line naming it, so the helpers keep stderr and read the status off it. Without
that, unpublishing a share that was already gone, or applying a theme the store
had taken down, both told the user their connection was broken.
### 8.3 Device identity
Every hub write (`hub_share`/`hub_update`/`hub_delete`) and the download-count
ping after an apply are authenticated by a per-device identity, created
lazily by `ensure_device_identity()` on first use:
- **`/etc/aurora/device.key`** (mode `0600`) — 32 random bytes, hex-encoded,
as `device_token`. This is a secret: it is the only thing that lets the hub
accept an update/delete against a device's own shares. It is never rendered
in any UI and never leaves the router except as the `device_token` field of
a `hub_share`/`hub_update`/`hub_delete` POST/PUT/DELETE body.
- **`/etc/aurora/device.hash`** (mode `0644`) — a second, independent random
hex value, `device_hash`, sent only with the download-count ping. It
carries no authority (there is nothing it can authenticate) and exists
purely for anonymous download deduplication on the hub side.
- Both files are listed in `/etc/sysupgrade.conf.d/aurora-device`, so a
`sysupgrade` that preserves configuration keeps the same device identity
(and thus the same ownership over previously published shares) across
firmware upgrades.
### 8.4 Apply flow (`hub_apply` → `hub_apply_worker`)
`hub_apply` validates the id, forks `hub_apply_worker` in the background, and
returns a `job_id` right away; the worker writes each step to a status file
under `/tmp/aurora_hub_jobs/<job_id>.status` for `get_hub_status` to read:
1. **backup** — copies the current `/etc/config/aurora` to
`/etc/aurora/pre-hub-backup.conf` (single slot: only the most recent apply
can be rolled back).
2. **fetch** — `GET`s the full config payload from the hub.
3. **validate** — `validate_and_apply_hub_payload()` treats the hub as
untrusted input and re-checks every field with the *same* rules the
frontend/local share path enforces (exact 62 `light_*`/`dark_*` hex keys,
enum-checked `nav_type`/font ids, bounded/regex-checked rem values and
font stacks, per-item toolbar validation, `has_control_char` guards
against embedded-newline UCI injection) using only `case`/`grep -E` — no
`eval`. Nothing is written to UCI until every field passes; a single `uci
batch` (colors deleted then reset, layout/typography set, `toolbar_item`
sections replaced, `hub_applied` + `icon_cache_version` bookkeeping) is
then committed atomically, and reverted if the commit fails.
4. **assets** — any image assets in the payload are fetched from the hub and
sha256-verified (`fetch_verified`) before being written under
`/www/luci-static/aurora/images/`; a login-background asset also updates
`struct_login_bg`. Best-effort: a failed asset fetch doesn't fail the job.
5. **finalize** — resyncs font CSS, bumps the icon cache timestamp, marks the
job `done`.
6. **download count** — fires a fire-and-forget `POST .../download` with
`device_hash` after the job completes, for the hub's download counter.
If validation or fetch fails, the job is marked `error` with a step and error
code; the frontend maps every code to a result-only message (never surfaces
words like `bad_payload` or `job`) and never leaves partial state — either the
whole batch commits or none of it does.
**Rollback**: `hub_restore_backup` copies `pre-hub-backup.conf` back over
`/etc/config/aurora`, resyncs font CSS and the icon cache timestamp, and
returns `no_backup` if nothing has been applied yet in this backup slot. The
gallery page shows a persistent banner ("Applied `<name>`" + "Restore
previous configuration") whenever `aurora.theme.hub_applied` is set, so
rollback is always one tap away, not just immediately after applying.
### 8.5 Share v1 scope
`build_share_payload()` assembles the current `/etc/config/aurora` into the
hub's schema (colors, layout, typography, toolbar, assets) and is shared by
both `hub_share` and `hub_update`. Deliberate v1 limits:
- **Images only, no custom fonts.** Only the six image asset kinds
(`logo_svg`, `favicon_png`, `favicon_ico`, `pwa_icon_192`, `pwa_icon_512`,
`login_bg`) are ever uploaded; `typography.font_sans`/`font_mono` are
shared as **preset ids** only (`resolve_font_preset_id`), never as uploaded
`.woff2` bytes — a custom font a user uploaded locally is never re-uploaded
to the public hub.
- **Skips factory-default images.** An image asset is only included if its
UCI value differs from the package's stock filename (`logo.svg`,
`favicon.ico`, `app-icon-192x192.png`, `app-icon-512x512.png`,
`apple-touch-icon.png`) — an unmodified slot means the user never
customized it, so there's nothing worth uploading and nothing
copyright-risky to check. Each included asset is also read back from disk,
sha256'd, and size-checked before upload, and any filename containing `/`
or `..` is rejected outright (defense against path traversal into
`$ICON_PATH`).
- Toolbar entries are re-validated with the hub's exact per-field limits
(title 1-30 chars, url ≤200 chars + scheme, icon charset+≤64 chars, ≤12
entries) at share time — an out-of-range item is silently dropped from the
share rather than rejecting the whole publish.
---
## 9. Releasing
OpenWrt package metadata lives in the `Makefile`:
```
PKG_VERSION # semantic version; bump at least a patch for breaking schema changes
PKG_RELEASE # YYYYMMDD-style date
```
CI builds `.ipk` / `.apk` artifacts through the `build-luci-package` Action using
the OpenWrt SDK.
**The SDK build needs no Node and runs no build step of ours** — it installs
`htdocs/` and `root/` verbatim. That is the whole reason the minified artifacts
are committed (§1): the feed rebuilds every package from a git ref, so whatever
is in the tree at that tag is what users install. Before tagging, confirm the
tree is self-consistent:
```bash
cd .dev && pnpm test # includes build-js --check
cd .. && git status --porcelain # must be empty
```
A tag pushed with stale artifacts ships the old frontend against the new
backend, and nothing downstream will notice.
---
## 10. Troubleshooting cheat sheet
- **Changed `brand` but hover/status colors don't follow?** Derived tokens
weren't written to UCI — check that `tokens.global.js` loaded and that
`persistDerivedTokens` ran on save.
- **Colors broken or invisible text after an upgrade?** Stale keys weren't
pruned — confirm `SCHEMA_VERSION` was bumped and the migration ran.
- **Applying a preset fails?** The backend `COLOR_TOKEN_KEYS` count no longer
matches the template line count.
- **Injection has no effect (variable name mismatch)?** Config keys (underscores)
become hyphenated CSS variables on injection; they must match the `--xxx`
names in the theme's `_tokens.css` exactly.
- **A page dies with `Invalid regular expression: missing /`, but the source
parses?** The packaged copy was mangled by jsmin — see §11. Confirm by
diffing the device's file size against `htdocs/`'s.
- **The device shows an older UI than the source says it should?** Someone
committed a source change without running `pnpm build`. `cd .dev && pnpm
check` names the stale files.
- **A page is blank with nothing in the console?** Check that
`htdocs/luci-static/resources/utils/color.global.js` still contains
`var Color=`. If it is an empty or near-empty file, `mangle.toplevel` or
`compress.toplevel` got switched on and deleted the global (§11).
- **A newly added string shows in English on a translated UI?** The msgid in
the artifact no longer matches the source `gen-pot` scanned. `pnpm test`
asserts this, so it should not survive a commit — but check there first.
---
## 11. Minification: why we do it ourselves
`luci.mk` minifies every `.js` with Crockford's **jsmin** whenever
`LUCI_MINIFY_JS` is 1 — and `luci.mk` defaults it to 1. jsmin is from the
1990s and predates every ES6 construct this package uses, so it does not
merely fail to shrink things: **it rewrites them into something else.**
This package therefore sets `LUCI_MINIFY_JS:=0` in its `Makefile`, before the
`luci.mk` include (the variable is declared with `?=`, so it must be set
first), and runs terser itself in `.dev/scripts/build-js.mjs` instead. The rest
of this section is why jsmin cannot be trusted, followed by what terser needs
to be told about LuCI.
### What terser needs to know about LuCI
Four settings in `build-js.mjs` are load-bearing. Three are present, one is
present by being absent:
| Setting | Why |
|---|---|
| `parse.bare_returns: true` | A LuCI view's last statement is a top-level `return view.extend(…)`. Without this, terser refuses to parse the file at all. |
| `compress.directives: false` | `"require uci"` and friends are string directives. A directive-dropping compressor deletes them and LuCI then loads the view with none of its dependencies. |
| `format.comments: false` | Maximum compression — but it also strips the three MIT notices inside the vendored colorjs build, which we redistribute. They are re-attached from `utils/color.global.js.license`; any future vendored library needs the same sidecar. |
| `mangle.toplevel` / `compress.toplevel` **left at their defaults (`false`)** | `color.global.js` and `tokens.global.js` exist to publish a top-level `var Color` / `var AuroraTokens`. Turning either on deletes the declaration outright — measured, the output becomes an empty string — and terser still reports success. The symptom is a white page with nothing in the console. **Never enable them.** |
Everything else LuCI-specific needs no special handling: `assetUpload`,
`feedCheck`, `hubApi`, `themePreview` and the injected globals (`L`, `E`, `_`,
`uci`, `rpc`, `ui`, `form`, `view`, `fs`) are all free variables, which terser
treats as globals and never renames.
`.dev/tests/artifact-safety.test.mjs` asserts all of this against the shipped
bytes: they parse, they keep every `require` directive their source declared,
they keep their top-level `return`, the two globals are still declared, the
licence banner is intact, no `sourceMappingURL` points at a map the package
does not install, every `_()` msgid survives byte-for-byte, and no hidden file
(`.DS_Store`) got installed.
### Cache busting: anything we fetch ourselves needs a `?v=`
uhttpd dates this package's installed files `Last-Modified: Thu, 01 Jan 1970`
and sends no `Cache-Control`. Browsers then compute heuristic freshness as
roughly 10% of the file's apparent age — from the epoch, that is years — and
serve their copy **without even sending `If-None-Match`**. (luci-base and the
theme carry real dates; only ours are at the epoch.)
That is not theoretical. The Marketplace spent months drawing its built-in
cards from a `presets.json` whose shape had changed underneath it: against the
old `{light,dark}` shape, `paletteOf` finds no colours and `navOf` no layout,
so every card rendered with the fallback palette and a top nav bar. Both fail
silently, so the page looked deliberate.
Two kinds of URL, and only one is ours:
- **LuCI `require`s it** (`view/aurora/*.js`, everything under `utils/`) —
LuCI appends `?v=${env.resource_version}`, parsed from the `luci.js` script
tag, i.e. **luci-base's** version. Bumping this package's `PKG_VERSION` does
not change it. Nothing here can fix that; a browser picks these up on a hard
reload or when luci-base is upgraded.
- **We build the URL** — then it must carry a version. Three do:
| Resource | Version | Stamped by |
|---|---|---|
| `utils/tokens.global.js` | engine version | `sync-tokens.mjs` → `TOKENS_ENGINE_VERSION` |
| `aurora/presets.json` | content hash | `gen-presets.mjs` → `PRESETS_VERSION` |
| `utils/color.global.js` | content hash | `build-js.mjs` → `__ASSET_HASH(...)__` |
`__ASSET_HASH(<path>)__` in any source is replaced at build time by the first
8 hex of that file's sha256. Use it for anything vendored that carries no
version of its own — the URL then changes if and only if the bytes do, so the
cache stays warm right up until it must not. An unknown path fails the build.
If you add another `fetch()` or script injection for a packaged file, give it a
version the same way. `artifact-safety.test.mjs` checks the three above and
that no sentinel survives into an artifact, but it cannot know about a fourth.
### What it gets wrong
**1. A regex literal after an arrow becomes a division.** jsmin decides a `/`
opens a regex only when the previous non-blank character is one of:
```
( , = : [ ! & | ? { } ; \n
```
An arrow's `>` is not in that set. So in `(url) => /^https?:\/\//.test(url)`
the `/` reads as division, and the two adjacent slashes inside the regex then
look like a line comment — **everything from there to the end of the line is
deleted**. The regex loses its terminator and the browser reports
`Invalid regular expression: missing /`. The whole view fails to load.
This actually shipped: the theme store was dead on-device for exactly this
reason, in `marketplace.js`'s `isExternalShortcut`.
**2. Template literals are not understood at all.** Backticks mean nothing to
jsmin, so:
- the `//` in `` `https://${FEED_HOST}/manifest.json` `` is eaten as a line
comment, same as above;
- whitespace *inside* the literal is stripped as if it were code. Multi-line
inline CSS is the dangerous case: a descendant selector `.a .b` is squeezed
to `.a.b`, which silently means "an element with both classes". **No syntax
error, no console message — the styling just quietly stops matching.**
### Reproducing it
```bash
npm install jsmin --no-save
for f in $(find .dev/src/resource -name '*.js'); do
./node_modules/.bin/jsmin "$f" > /tmp/m.js
node --check /tmp/m.js || echo "MANGLED: $f"
done
```
Note this only catches breakage #1 (a syntax error). Breakage #2 produces
*valid* JS with different behaviour, so it will pass this check — that
asymmetry is the main reason minification is off rather than worked around.
### The rule that used to be enforced, and is not any more
`.dev/tests/jsmin-safety.test.mjs` used to forbid a regex literal directly after an
arrow, because that is the shape jsmin destroys. It was retired when terser
took over: the rule existed only because jsmin is not a parser, and terser is.
`artifact-safety.test.mjs` replaced it with assertions on the artifacts
themselves, which is strictly stronger — it checks what actually shipped rather
than banning a syntax that might have shipped badly.
The style is still worth keeping if you are writing code that might get
vendored into a tree that minifies with jsmin:
```js
const EXTERNAL_URL_RE = /^https?:\/\//;
const isExternalShortcut = (url) => EXTERNAL_URL_RE.test(shortcutText(url));
```
**Sibling packages:** `luci-theme-aurora` and `luci-theme-shadcn` do not
override `LUCI_MINIFY_JS`. Their Vite output survives jsmin's syntax check,
but breakage #2 is invisible to that check — worth a look if styling ever
misbehaves only on-device.