27 KiB
Development Guide
This guide covers the complete development workflow for the Aurora theme, from environment setup to building production packages.
Prerequisites
- Node.js 20.19+ / 22.12+ - JavaScript runtime (mirrors Vite 7's support range — odd releases like 21.x don't qualify; enforced at
pnpm installtime viaengines+engine-strict, so an unsupported Node fails fast with a clear message) - pnpm - Package manager (managed via Corepack)
- Tailwind CSS knowledge - Required for styling. See Tailwind CSS Documentation
- Network access - Development machine must be on the same network as your OpenWrt router
Environment Setup
1. Clone and Install
# Clone the repository
git clone git@github.com:eamonxg/luci-theme-aurora.git
cd luci-theme-aurora/.dev/
# Enable Corepack to manage pnpm version
corepack enable && corepack prepare
# Install dependencies
pnpm install
2. Configure Environment
# One-shot wizard: asks for every .env value (router IP, dev-server host/port,
# current .env entries as defaults), generates/installs an SSH key (one
# router-password prompt), verifies template sync end-to-end, and writes .env
pnpm setup:router
# Non-interactive: pass the IP directly (no prompts; dev-server values keep
# their saved .env entries or defaults)
pnpm setup:router 192.168.2.1
The script is named
setup:router(notsetup) becausepnpm setupresolves to pnpm's own built-in setup command and would never run a package script.
What it does (scripts/setup.js), in order:
- Collects the
.envvalues. With no argument it prompts for each variable in turn, showing the current.enventry as the default (an empty answer keeps it). With an IP argument it takes that asVITE_OPENWRT_HOSTnon-interactively and leaves the dev-server values alone. Nothing is written yet —.envis only updated once every step below has succeeded. - Pre-flights the connection. A raw TCP probe of
<host>:22with a 2s timeout, so an unreachable device or one with SSH disabled fails immediately with a clear message instead of hanging inside ssh. - Finds or generates an SSH key. It looks for
~/.ssh/id_ed25519.pub,id_rsa.pub,id_ecdsa.pubin that order and reuses the first one present — an existing key is never overwritten. If you have no key at all, it generates one (ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519). The empty passphrase is deliberate:.utsync has to run unattended for the wholepnpm devsession. - Installs the public key on the router. It first tests whether passwordless SSH already works (
ssh -o BatchMode=yes … echo ok) and skips the install if so — after a full reflash that wipes the key, the test fails and the install simply runs again. Otherwise it appends the key to/etc/dropbear/authorized_keysover one interactive SSH session; the append is guarded bygrep -qxF, so re-running never duplicates the entry. This is the only step that asks for the router's root password, and it asks once. Afterwards it re-tests passwordless auth and classifies any failure (wrong password / host unreachable / other). On a device running openssh rather than dropbear the key belongs in/root/.ssh/authorized_keysinstead — install it manually there and this step detects it and skips. - Verifies the sync end-to-end. Rather than trusting
echo ok, it pushesucode/template/themes/aurora/through the exacttar -cf - | ssh … tar -xf -pipeline theut-syncplugin uses (see Template (.ut) Live Sync). If this passes,pnpm dev's template sync will too. - Writes
.env. Managed keys are rewritten in place so surrounding comments keep their meaning, missing ones are appended, and any other line passes through untouched.
The password prompt in step 4 comes from the local ssh client, which reads it straight from
/dev/ttyrather than stdin — so it shows up normally even though the script pipes ssh's stderr in order to classify errors. It does mean the step needs a controlling terminal on the development machine (the router side is unaffected): run it from a normal shell, not from CI,nohup, or an editor task pane, where ssh has no way to ask and the run fails at authentication. Check withttyif unsure; in such an environment, install the key by hand instead.
If the router is at the default 192.168.1.1 and passwordless SSH already works, no .env is needed at all — every value below has a working default.
Environment Variables (all optional):
VITE_OPENWRT_HOST- bare router address, e.g.192.168.1.1(default;host:portand full-URL forms also accepted). The web proxy target and the.ut-sync SSH target (root@<hostname>) both derive from it; anything fancier (a dedicated key, a jump host, a non-standard ssh port) belongs in aHostblock in~/.ssh/config, which ssh picks up automatically.VITE_DEV_HOST- Development server host (code default:127.0.0.1,.env.examplesets0.0.0.0for LAN access)VITE_DEV_PORT- Development server port (default:5173)
Development Workflow
Start Development Server
cd luci-theme-aurora/.dev/
pnpm dev
The development server will start at http://127.0.0.1:5173 and proxy requests to your OpenWrt device.
How Vite Proxy Works:
The Vite development server uses middleware to rewrite local requests to serve CSS/JS resources from your development environment instead of the router. This enables live editing without deploying to the router. For detailed implementation, see vite.config.ts.
Key proxy behaviors:
- Proxies
/cgi-binand/luci-staticrequests to OpenWrt device - Uses middleware (
createLocalServePlugin) to rewrite request paths for CSS and JS files - CSS requests to
/luci-static/aurora/main.cssand/luci-static/aurora/login.cssare rewritten to serve from.dev/src/media/main.cssand.dev/src/media/login.cssrespectively - JS file requests are served directly from
.dev/src/resource/with middleware reading and returning file content - Injects Vite HMR client into proxied HTML responses for live reload support
- Redirects
/to/cgi-bin/lucifor proper routing
Code Style and Formatting
This project uses Prettier for code formatting with automatic formatting on save.
Prettier Configuration:
- Located in
.prettierrc - VS Code settings in
.vscode/settings.jsonenable format-on-save for CSS and JS files - Uses
prettier-plugin-tailwindcssto sort Tailwind CSS classes
CSS Nesting Support
Thanks to lightningcss, you can freely use CSS Nesting syntax in your stylesheets. The build process automatically compiles nested CSS into flat, browser-compatible format.
This will be compiled to standard CSS that works in all browsers.
CSS Architecture
The theme has two independent Tailwind CSS v4 entry points, both sourced from .dev/src/media/:
main.css— the LuCI admin UI. It contains no rules of its own; it's a pure import manifest that pulls in (in order)_tokens.css(OKLCH theme tokens, mapped via@theme inline),_base.css,_elements.css,_layout.css, every file incomponents/(one partial per UI component — buttons, cards, modals, tables, etc.), and_utilities.css.login.css— the standalone login page (sysauth.ut). Self-contained: imports Tailwind and_tokens.cssdirectly.
Third-party compatibility patches are not bundled into main.css — they are split into per-page files under media/patches/ and loaded on demand (see On-Demand Third-Party Patches below).
Adding new styles:
- New UI component → create
components/_<name>.cssand add an@importline tomain.css. Each file is its own organizational unit — don't add@layerwrappers: theme partials stay unlayered, so they outrank Tailwind's layered base/utilities regardless of specificity. - Compatibility fix for a third-party LuCI app/page → add a new file under
media/patches/(see below).
All rules use @apply with Tailwind utilities and CSS Nesting — no raw CSS properties.
On-Demand Third-Party Patches
Some third-party LuCI apps ship markup that doesn't adapt to the theme and needs a narrow compatibility override. Instead of bundling every such patch into main.css (which would ship them to every page), each patch is a standalone CSS file loaded only on the page it targets.
How it works:
- One file per page, named by
data-page. Each patch lives atmedia/patches/<page>.css, where<page>is the value of<body data-page="…">for the target page — i.e. the request path segments joined by-(e.g.admin-services-openclash-config).header.utcomputes the same string at render time fromctx.request_path, falling back toctx.pathwhenrequest_pathis empty (join('-', length(ctx.request_path) ? ctx.request_path : ctx.path)) so default landings reached without an explicit path still resolve their patch. - Build splits, not bundles.
vite.config.tsadds everymedia/patches/*.cssas its own Rollup entry, so each compiles tohtdocs/luci-static/aurora/patches/<page>.css. They are no longer part ofmain.css. @reference, not@import. Every patch file starts with@reference "../main.css";. This loads the theme context (tokens, custom utilities likebg-surface, thedark:variant) so@applyresolves — without re-emittingmain.cssinto the patch output. Using@importhere would inline all ofmain.cssinto every patch (hundreds of KB);@referencekeeps each patch to just its own rules.header.utdiscovers patches at render time. On each (non-login) page render,header.utlists/www/luci-static/aurora/patches/with ucode'sfs.lsdir()(a readdir of a dozen entries — microseconds, dwarfed by the template's existingubuscall) and matches the installed*.cssfilenames against the cumulative path-segment prefixes of the request: a patch matches its exact page and any subpage, but only on real segment boundaries —admin-services-wol.csscoversadmin/services/wol/plus, yet never a sibling app whose own segment merely starts the same way (admin/services/wol-plus). Every matching patch is linked right aftermain.css, in lexical order — so a general patch loads before a more specific one and the specific one cascades on top. Pages with no match get nothing — no extra request, no 404. If the directory is missing or unreadable, the list is empty and the page renders unpatched.- The patches directory is a drop-in extension point. Because discovery is at render time, patches don't have to ship with the theme: any package may install a
<page-prefix>.cssinto/www/luci-static/aurora/patches/and the theme will load it on matching pages. Install/uninstall lifecycle is automatic — the file appears and disappears with the package, no registration or allow-list rebuild. (Patches shipped this way are plain CSS served as-is; the theme's own patches additionally go through the Tailwind build below.) - Dynamically generated pages are covered by their fixed prefix. Some apps mint a page per entity — e.g. QModem's SMS conversations render as
admin-modem-qmodem-sms-conversation-<contact>. Name the patch after the fixed prefix (admin-modem-qmodem-sms-conversation.css) and the prefix match loads it for every conversation page, regardless of the contact name. No wildcard syntax is needed (and*in a filename is not supported).
Adding a patch:
- Open the target page in the browser and read
document.body.dataset.page— that exact string is your filename (for a family of dynamic per-entity pages, use their fixed prefix instead — see point 5 above). - Create
media/patches/<that-string>.css:/* PATCH: <page> (luci-app-foo) */ @reference "../main.css"; [data-page="<page>"] { /* narrow, selector-scoped overrides using @apply + CSS Nesting */ } - Run
pnpm build. There is no allow-list to regenerate — the loader discovers whatever.cssfiles are installed underpatches/at render time. - Verify
htdocs/luci-static/aurora/patches/<page>.cssis small (just your rules, not a copy ofmain.css).
Removing a patch is symmetric: delete the file and rebuild — the loader stops linking it because it no longer exists.
Shipping a patch with a third-party app (no theme release needed): build or hand-write a plain CSS file named after your page's data-page prefix and install it from your package's Makefile:
define Package/luci-app-foo/install
...
$(INSTALL_DIR) $(1)/www/luci-static/aurora/patches
$(INSTALL_DATA) ./htdocs/aurora-patch.css \
$(1)/www/luci-static/aurora/patches/admin-services-foo.css
endef
The theme loads it automatically on admin-services-foo and all its subpages whenever both packages are installed. Note app-shipped patches bypass the theme's Tailwind build — write plain CSS (you can still target the theme's CSS custom properties, e.g. var(--surface)), and scope every rule under your own [data-page^="…"] selector.
Naming. The filename is the page's data-page string; matching by prefix means broader targets also just work:
| You want to patch… | File to create | Then it loads on |
|---|---|---|
One specific page, admin/services/foo/general |
admin-services-foo-general.css |
that page (and any subpage under it) |
A whole app, all pages under admin/services/foo/… |
admin-services-foo.css |
foo, foo/general, foo/rules, … |
Dynamic per-entity pages, e.g. QModem SMS …/sms/conversation/<contact> |
admin-modem-qmodem-sms-conversation.css (the fixed prefix — no wildcard needed) |
every conversation page, whatever the contact |
Two rules of thumb that follow from prefix matching:
- A patch applies to its page and all subpages by default.
admin-services-foo.cssloads on every page underadmin/services/foo/…. When you need finer targeting, narrow it in either of two ways: scope individual rules inside the file ([data-page="admin-services-foo-general"] { … }only affects that one page), or ship an additional, longer-named file (admin-services-foo-rules.css) for page-specific rules — on a page matching both, both load, shorter name first, so the more specific file wins the cascade. - Matching respects path-segment boundaries, so a prefix never leaks onto a lookalike sibling:
admin-services-wol.csscoversadmin/services/wol/plusbut not a different app atadmin/services/wol-plus. The one unavoidable collision is two paths joining to the samedata-pagestring (wol/plusvswol-plus) — such a patch loads on both pages. If that matters, key rules to your app's own class names/ids so an accidental load matches nothing.
Unlike the
_-prefixed partials (which are@import-only fragments), patch filenames have no_prefix — each is a real build entry that ships tohtdocs/.
Design Tokens
src/media/_tokens.css is generated — its header says "DO NOT EDIT". The source of truth is the standalone @eamonxg/aurora-tokens npm package, consumed here as a devDependency:
defaults.js— the 10 editable input colors (bg,surface,text,brand,on_brand,link,info,warning,success,danger) for light and dark mode, as OKLCH strings.spec.js—DERIVATIONS(how every other token —text_muted,surface_sunken,hairline,brand_hover,brand_subtle,focus_ring,progress_start/progress_end,*_surface,scrim,mega_menu_bg, …) is computed from the inputs viamix/shade/set/alpha/constoperators, andFIXED(mode-specific literals such as shadows that bypass derivation).engine.js— the OKLCH/OKLAB color math behind those operators, via colorjs.io.resolve.js—resolveMode(mode)walksDERIVATIONSand returns a flat{token: oklchString}map with nocolor-mix()/var()left in it..dev/scripts/gen-tokens.jsimportsresolveMode/FIXEDstraight from the package root.
Changing a color:
- Edit
spec.js/defaults.jsin theaurora-tokensrepo (derivation rules, fixed literals, base input colors), tag a release so CI publishes the package, then bump the@eamonxg/aurora-tokensdevDependency version here and runnpm install. For unreleased iteration against a local checkout, runnpm link ../../aurora-tokensfrom.devinstead of bumping/publishing. - Run
pnpm gen:tokens(also runs automatically as part ofpnpm build) to rewritesrc/media/_tokens.css— it emits:root(light) and[data-darkmode="true"](dark) blocks plus the@theme inlinemapping, in that order. - Run
pnpm testto check the color-math operators and derived-token invariants (tests/engine.test.js,tests/resolve.test.js,tests/surfaces.test.js) — e.g. hue families, lightness ordering betweenbg/surface_sunken/surface, and translucency of menu backgrounds.
Runtime overrides from UCI: header.ut reads uci get_all aurora.theme on each render and re-emits stored tokens as CSS custom-property overrides in an inline <style> after main.css. Keys are namespaced by prefix — light_* and struct_* land in :root, dark_* in [data-darkmode="true"] — with the prefix stripped and _ mapped to - (e.g. light_surface_sunken → --surface-sunken). The template flattens all keys in a single pass into two pre-joined declaration strings (rather than per-key template loops), which halves the iteration work and keeps the emitted <style> compact. This is the hook luci-app-aurora-config writes through.
LuCI JavaScript API
For LuCI-specific JavaScript development, refer to the official API documentation:
Live Reload Behavior
- CSS changes: Trigger full page reload via custom HMR handler
- JS changes: Trigger full page reload via custom HMR handler
- Template changes (
.utfiles): Auto-synced to router over SSH and trigger full page reload (one-timepnpm setup:routerrequired, see below)
Template (.ut) Live Sync
The .ut template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run pnpm setup:router once to configure passwordless SSH; after that it's fully automatic:
- On startup, the whole template directory is pushed (as one tarball over ssh stdin — Dropbear has no SFTP server for scp), so edits made while the dev server was down never leave the router stale.
- On save, changes are debounced and the directory is pushed again, then the browser reloads.
- On page load, requests to
/cgi-binwait for any in-flight push, so a proxied render never uses a stale template.
Troubleshooting — sync errors are printed with the fix:
- Host key mismatch (device was reflashed): Run
ssh-keygen -R <device-ip>, then restart the dev server - Authentication failed (public key not on device, e.g. after a reflash): Run
pnpm setup:router - Connection refused/timed out: Check that the device is online and SSH is enabled
A failed sync is retried on the next .ut change; CSS/JS dev features work normally without SSH.
Building for Production
Build Command
cd luci-theme-aurora/.dev/
pnpm build
This compiles all assets to the production directory htdocs/luci-static/, which is used by LuCI during OpenWrt package compilation.
Build Output:
htdocs/luci-static/
├── aurora/
│ ├── main.css # Minified admin UI CSS (via lightningcss)
│ ├── login.css # Minified login page CSS (via lightningcss)
│ ├── fonts/ # Web fonts (Lato)
│ └── images/ # Logo assets + PWA icons
└── resources/
└── menu-aurora.js # Menu configuration (minified via Terser)
Build Process:
pnpm gen:tokensregeneratessrc/media/_tokens.cssfrom@eamonxg/aurora-tokens(see Design Tokens)- Vite builds the CSS entry points (
src/media/main.cssandsrc/media/login.css), keeping Tailwind's native@layerstructure - Custom Vite plugin (
luci-js-compress) minifies JS files via Terser - Static assets copied from
.dev/public/aurora/
Package Compilation
Via GitHub Actions
Build frontend assets:
- Manually trigger the
frontend-assets-buildworkflow - It runs
pnpm build, then auto-commits the output tohtdocs/if anything changed
Build .ipk/.apk packages:
- Push a version tag (
v*), push tomaster/feat/**with[build]in the commit message, or manually trigger the workflow - The
build-theme-packageworkflow compiles both.ipkand.apkOpenWrt packages
PR review:
Pull requests that touch .dev/, htdocs/, ucode/, or root/ are automatically reviewed by the claude-pr-review workflow — it posts inline comments on the source diff (generated htdocs/ output is excluded) plus a summary comment. Mention @claude in a PR comment to request a follow-up review or ask a question.
Issue triage:
New issues are handled by the claude-issue-bot workflow — it checks for spam/duplicates, applies labels, and posts a deep technical analysis comment. Mention @claude in an issue comment to get a response.
Workflow Files: .github/workflows/
frontend-assets-build.yml— Build assets and auto-commit (manual trigger)build-theme-package.yml— Compile.ipk/.apkpackagesclaude-pr-review.yml— AI code review for PRs (inline + summary comments)claude-issue-bot.yml— AI issue triage and analysis
Directory Structure
luci-theme-aurora/
├── .dev/ # Development environment
│ ├── docs/ # Project documentation
│ │ └── DEVELOPMENT.md # Development guide (this file)
│ ├── public/aurora/ # Public static assets
│ │ ├── fonts/ # Web fonts (Lato)
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts
│ │ ├── clean.js # Build cleanup utility
│ │ ├── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS source (Tailwind CSS v4)
│ │ │ ├── main.css # Admin UI entry point (import manifest)
│ │ │ ├── login.css # Login page entry point
│ │ │ ├── _tokens.css # OKLCH theme tokens -- GENERATED, see @eamonxg/aurora-tokens
│ │ │ ├── _base.css # Document foundation (html/body viewport bg)
│ │ │ ├── _elements.css # Base element styles (headings, links, …)
│ │ │ ├── _layout.css # Page layout/structure
│ │ │ ├── _utilities.css # Custom utility classes
│ │ │ ├── components/ # One partial per UI component
│ │ │ └── patches/ # Per-page third-party patches (on-demand, one file per data-page)
│ │ └── resource/ # JavaScript resources
│ │ └── menu-aurora.js # Menu logic
│ ├── tests/ # All test suites (pnpm test)
│ │ ├── engine.test.js # Color-math operators
│ │ ├── resolve.test.js # Resolved token invariants
│ │ ├── surfaces.test.js # Surface/hue layering invariants
│ │ ├── overlay.test.js # Overlay/layout CSS assertions
│ │ └── navigation-*.test.js # Navigation model/rendering/styles
│ ├── .env.example # Environment variables template
│ ├── .prettierrc # Prettier configuration
│ ├── package.json # Node.js dependencies
│ ├── pnpm-lock.yaml # pnpm lock file
│ └── vite.config.ts # Vite configuration with custom plugins
├── .github/ # GitHub configuration
│ ├── ISSUE_TEMPLATE/ # Issue templates
│ ├── workflows/ # GitHub Actions workflows
│ └── renovate.json # Renovate dependency update config
├── .vscode/ # VS Code workspace settings
│ └── settings.json # Auto-format on save settings
├── htdocs/luci-static/ # Build output (generated by Vite)
│ ├── aurora/ # Theme CSS and assets
│ │ ├── fonts/ # Built font files
│ │ ├── images/ # Built images + PWA icons
│ │ ├── main.css # Compiled admin UI CSS
│ │ ├── login.css # Compiled login page CSS
│ │ └── patches/ # Compiled per-page patches (linked on demand by header.ut)
│ └── resources/ # Built JavaScript modules
│ └── menu-aurora.js # Minified menu logic
├── root/etc/uci-defaults/ # OpenWrt system integration
│ └── 30_luci-theme-aurora # Theme auto-setup script
├── ucode/template/themes/aurora/ # LuCI ucode templates
│ ├── header.ut # Header template
│ ├── footer.ut # Footer template
│ └── sysauth.ut # Login page template
├── LICENSE # Apache License 2.0
├── Makefile # OpenWrt package Makefile
├── README.md # English documentation
└── README_zh.md # Chinese documentation
Tools and Technologies
- Tailwind CSS v4 - Utility-first CSS framework
- Vite - Build tool and development server
- pnpm - Fast, disk space efficient package manager
- lightningcss - CSS minifier
- colorjs.io - OKLCH/OKLAB color math for design token generation (used by
@eamonxg/aurora-tokens) - Terser - JavaScript minifier
- Prettier - Code formatter
- prettier-plugin-tailwindcss - Tailwind class sorting
- tw-animate-css - Animation utilities for Tailwind CSS
- tailwind-scrollbar - Custom scrollbar styling plugin