11 KiB
How LuCI renders a theme
What LuCI expects from a theme on OpenWrt 24.10 and 25.12+, and where footstrap plugs into it. Read this first if you are new to the codebase.
Verified against
openwrt/luciat the commit pinned inluci-theme-footstrap/luci-upstream.pin, against theopenwrt-24.10branch, and on a live OpenWrt 25.12 router.
A theme is a server shell plus client assets
LuCI runs on ucode, not Lua, since 23.05: dispatcher in /usr/share/ucode/luci/dispatcher.uc,
templates as *.ut under /usr/share/ucode/luci/template/.
Page content is rendered on the client. The server sends a shell — header, an empty
<div id="view">, footer — and luci.js instantiates the page's view module from
/www/luci-static/resources/view/**.
So a theme is a server shell (header/footer in ucode) + CSS + client JS for the menu. No Lua, no server-side controllers.
view.ut
├─ include('header') → luci-base header.ut
│ ├─ include(`themes/${theme}/header`) ← the theme's header.ut
│ └─ <script src="…/luci.js"> + L = new LuCI({…env…})
├─ <div id="view"> + ui.instantiateView('<view>')
└─ include('footer') → luci-base footer.ut
└─ include(`themes/${theme}/footer`) ← the theme's footer.ut
The theme is never called directly: luci-base wraps it, and luci.js loads after the theme's
header.
Both release lines, one template API
24.10 and 25.12+ share the template API, checked rather than assumed. 24.10 is already ucode
(modules/luci-base/ucode/ exists there) and carries everything the theme uses: ctx.path,
ctx.request_path, entityencode, striptags, dispatcher.build_url/lookup/lang, ubus.call,
pkgs_update_time. The L.env blob luci-base's own header.ut prints is byte-identical
between branches, so L.env.dispatchpath — which the menu and the SPA router depend on — exists in
both.
The one difference is the package manager: apk on 25.12+, opkg/.ipk on 24.10. CI builds both
formats; install.sh detects which one the router runs.
How LuCI picks a theme
From runtime.uc:
let media = uci.get('luci', 'main', 'mediaurlbase'); // e.g. '/luci-static/footstrap'
let status = self.trycompile(`themes/${basename(media)}/header`);
if (status !== true) {
// fall back to the first theme in luci.themes whose header compiles
}
self.env.media = media; self.env.theme = basename(media);
Three things follow:
- The theme name is
basename(mediaurlbase), and its templates must live in/usr/share/ucode/luci/template/themes/<name>/header.ut. - A header that does not compile does not brick the UI. LuCI falls back to the first working
theme and shows a "Theme fallback" indicator carrying the error. Convenient while developing —
and the reason CI compiles every
.utwithucode -T -c: a stray brace would otherwise ship green and quietly move every user to another theme. resourceis always/luci-static/resources, shared with luci-base. Themes must not touch it.
What the header must do
http.prepare_content('text/html; charset=UTF-8').- Emit
<!DOCTYPE html><html><head>…with its own CSS. - Include
{{ resource }}/cbi.jsand the translations script. - Honour
node.cssandcss— individual pages ask for extra stylesheets. - Open the content container and leave it unclosed; the footer closes it.
- Honour
blank_page— draw no chrome when it is true. - Emit the empty menu containers:
#topmenu,#tabmenu,#modemenu,#indicators.
data-page on <body> matters: luci-base and app CSS target pages through it
([data-page="admin-status-overview"]).
Variables a theme template gets: theme, media, resource, ctx, dispatcher, dispatched,
node, version, config, blank_page, css, lua_active, http, ubus.
Where footstrap differs
- One template directory,
ucode/template/themes/footstrap/.header.utis thin;<head>, brand, notices, logout and search live inpartials/*.ut. Sidebar and top bar are not a second template — they are:root[data-layout]plus CSS. data-pageis built from the dispatch path,join('-', length(ctx.path) ? ctx.path : ctx.request_path)— the reverse of bootstrap's order. On a firstchild route (/admin/statusrenders overview)request_pathis only['admin','status'], sobody[data-page= 'admin-status-overview']rules and the overview include silently did not apply.- The first element in
<body>is a skip link; the content container is<main class="fs-main" id="maincontent" tabindex="-1">; the menu is<nav class="fs-sidebar">. Not<aside>— that carries thecomplementaryrole, which no landmark jump reaches. - The document heading is
<h1 class="fs-title-main">inside.fs-title.fs-sr, clipped, nothidden:hiddenisdisplay:none, which drops the element out of the accessibility tree, so the<h1>effectively did not exist while the SPA router dutifully updated it. partials/head.utsendscascade.css,node.cssandcbi.jswith?v={{ pkgs_update_time }}(uhttpd sends noCache-Control) and passes every interpolation throughentityencode(striptags(...)).
sysauth.ut is mandatory for a theme with its own chrome
luci-base's generic sysauth.ut opens with {% include('header') %} — without blank_page —
while the theme's header hides its whole shell behind {% if (!blank_page) %}. Without a
theme-local sysauth.ut, sidebar, menu and footer draw around the login form and every control in
them is dead: there is no session yet.
The theme's sysauth.ut exists for exactly one line:
{% include('header', { blank_page: true }) %}
The form is rendered by the SERVER. Bootstrap's pattern — a hidden <section> plus a client
view module — was tried here and produced a blank page you could not log in from: the view
bootstraps before a session exists, its RPCs answer "Access denied", the promise rejects and
render() never runs. Server rendering works with JS off, cannot be broken by a rejected promise,
and needs no luci-theme-bootstrap installed. Do not go back.
Login strings deliberately carry no msgctxt, so luci-base translates them in the ~40 languages
this theme has no catalogue for — see conventions.md.
The menu is entirely client-side
The server header emits empty containers by id; client JS fills them. The footer loads the renderer
with L.require('menu-footstrap'), then L.require('fs-select').
Menu JS lives in htdocs/luci-static/resources/ — not in the theme directory — because
L.require() resolves against resourcebase. From there it pulls its dependencies through
'require <module> as <name>' pragmas:
menu-footstrap → ui, fs-fit, fs-prefs, fs-widgets, menu-footstrap-common
menu-footstrap-common → ui, fs-fit, fs-menutree, fs-chrome, fs-router,
fs-prefs, fs-sheets, fs-search
fs-select (no requirer — the footer loads it directly)
fs-overview (PAGE MODULE — required by menu-footstrap-common when
body[data-page] says the page is theirs, see below)
The graph is acyclic and the runtime enforces it: require() throws DependencyError on a cycle.
One module is loaded by PAGE, not by pragma. fs-overview reshapes Status → Overview, watches
body[data-page] and does nothing anywhere else, because that page is luci-mod-status's own — a
theme may register a dispatcher node of its own (fs-appearance does, below), but that adds a
route, it does not hand over one another package already owns. A 'require' pragma, though, is a
hard dependency — luci.js evaluates the module before the requirer's factory runs, on every admin
page — so menu-footstrap-common.js
carries a map from data-page to module name and requires the module when that page appears
(server-stamped or router-stamped, the same attribute either way). The module is unchanged, including
its own observer: wire() re-checks the page synchronously, so one that arrives after the stamp
still starts watching. The map repeats a page name the module also tests, and npm run page-modules
derives both sides and fails if they drift.
fs-appearance (the Appearance controls) used to be the map's other entry, stapled onto System →
System the same way. It now owns a route of its own, /admin/system/footstrap
(root/usr/share/luci/menu.d/luci-theme-footstrap.json, dispatched to
view/footstrap/appearance.js), which requires it by pragma like any ordinary view — an ordinary
dependency, not a page-watching module, because a theme with a route no longer needs the
body[data-page] workaround for the page it owns.
There is no update checker, and nothing that loads one. The installer adds the owfeed-packages
feed, so apk upgrade / opkg upgrade carries the theme forward, and a settings page has no
business reaching GitHub to reimplement that. fs-version.js reports the installed version with no
request at all. Nothing globs for an optional file at render time either: the wallpaper pattern is an
upload, so the server hands the client its cache-bust token like any other saved value.
Renderer and shell in detail: chrome.md.
Registration in UCI
config core 'main'
option mediaurlbase '/luci-static/footstrap' # the active theme
option resourcebase '/luci-static/resources'
config internal 'themes'
option Footstrap '/luci-static/footstrap' # the list the dropdown is built from
Exactly one entry. Bootstrap registers a symlinked theme per mode (BootstrapDark, …);
footstrap does not, because mode, palette and layout are client axes. Details:
package.md.
The runtime layout on the router
luci.mk maps ucode/ → /usr/share/ucode/luci, htdocs/ → /www, root/ → /:
/usr/share/ucode/luci/template/themes/footstrap/
header.ut, footer.ut, sysauth.ut, partials/*.ut
/www/luci-static/footstrap/ cascade.css (generated), fonts/, logo.svg
(+ pattern.svg — a symlink to /etc/footstrap, uploaded)
/www/luci-static/resources/ menu-footstrap.js, menu-footstrap-common.js, fs-*.js
/usr/lib/lua/luci/i18n/footstrap-theme.<lang>.lmo catalogue, bundled INSIDE the theme package
/usr/share/rpcd/acl.d/luci-theme-footstrap.json ACL: uci footstrap + login-bg upload
/etc/uci-defaults/30_luci-theme-footstrap registration
Where the boundary runs
The theme supplies the chrome and the design language. Page content is drawn by the view JS of
luci-mod-*, whose structure the app fixes.
The theme can style every stock widget through cascade.css and its tokens, own the sidebar,
top bar, logo and indicators, and re-order the stock overview sections.
The theme cannot turn the overview into a KPI dashboard with a memory ring and sparklines —
that is a view module in a separate luci-app-*.
The single exception is fs-overview.js: a layout-only, additive include that moves stock overview
sections (System left, Memory/Storage right) and draws no content of its own.