Start here
You are writing a luci-app-* package. footstrap
is one of the themes it may run under. You do not install footstrap, import it, or depend
on it — you render stock LuCI widgets and read one colour contract, and the theme does the rest.
This page is everything you need to copy. Click any code to copy it.
luci-base. It works on every theme.
#view you
share with the theme and every other app. Under a modern SPA theme the document is never reloaded,
so anything you leak outside your own subtree — CSS in <head>,
a class you did not prefix, a :root variable — outlives your page and
paints someone else's. Stay inside your subtree and you cannot break anything.
return E([], [
// 1. CSS, if any, goes INSIDE the tree you return — it dies with your view.
E('style', [ '.myapp-card { background: var(--background-color-low, #fff); }' ]),
// 2. Namespace everything. Read colours from the export tier, never a #hex, never --fs-*.
E('div', { 'class': 'myapp-card' }, [
E('h3', {}, _('My app')),
E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save'))
])
]);
Colour tokens — the whole contract
Every LuCI theme exposes the same colour custom-properties. Read them and you follow
light/dark, the palette switch and the tint slider for free — try the controls up top and watch the
swatches move. This is the only footstrap surface you may read. Click a swatch to copy its
var(). Always give a literal fallback: var(--text-color-high, #333).
--fs-*. Open the stylesheet and you will find ~80
--fs-accent, --fs-panel2… with nicer
names. They are private, renamed at will, and no other theme has them. Reading one couples your app
to this build and breaks on the next. The --*-color-* names above are
the contract; nothing else is.
--on-*-color
— never a hardcoded #fff. A white-on-fill badge fell to 1.69:1 on seven
dark palettes before these inks were made per-palette. The family is warn,
not warning; --text-color (no level) does
not exist.
Components — copy the markup
Every widget stock LuCI (and any luci-app) can emit, with the real class names
ui.js/cbi.js produce. Each card has three
tabs: the live Preview, the HTML, and the E() form — the
L.dom factory you actually call in a view. Copy either. Build these from
'require ui' where a real widget class exists (a
ui.Dropdown, ui.FileUpload); the markup
here is what those render to.
Fix my styles
Paste a chunk of your app — CSS, a <style> block,
markup with inline styles, a snippet copied straight out of DevTools — and press Fix. The
mechanical mistakes are rewritten in place; the structural ones (that can't be auto-fixed without
risking your code) are flagged with the manual fix. Every colour keeps your original as a
var(--token, original) fallback, so a rewrite can never change
how your app renders on a theme that lacks the token — worst case, it does exactly what it did before.
- Get the code in. Paste your CSS /
<style>/ markup into the left box — or, if it's injected from JS and you can't find it, use the console grabber below (or a plain Save Page As → HTML only). - Press Fix styles. The right box fills with the rewritten code and the report appears underneath.
- Take the fixed copy, then clear the manual list. Copy the right box back into your app, then work through Fix these by hand — each links to the rule that explains it. You're done when that list is empty.
--warning-*→--warn-* ·
--text-color→--text-color-high · private --fs-*→export token ·
stray !important · runaway z-index · <font color><head> · :root{} / *{} ·
un-prefixed classes · window.onload · prefers-color-scheme ·
hardcoded editor theme · stock selectors without a scopeGrab it straight off a running app →
Can't find the CSS, or it's injected from JS? Open your app's page in the browser,
press F12 → Console, paste this line and hit Enter. It harvests every
injected sheet, every inline style= in #view
and every <font color> to your clipboard — nothing is dropped, so
it can't hide a bug. Paste below, press Fix.
Whose sheet is whose. A live LuCI page also carries CSS that is not yours: the theme's own
stylesheet (skipped — it's a filtered <link>), LuCI base's injected bits
(status/cpu.js, package-manager.js), and every
other app on the page. So each harvested <head> sheet is tagged with
its first selector — if it doesn't start with your class prefix, it isn't yours; delete it before you
Fix. Type your prefix at the prompt (optional) and yours get marked YOURS.
(A Save Page As → HTML only dump works too.)
(function(){var P=(prompt("footstrap devkit — optional: your app's class/id prefix (e.g. podkop) to mark YOUR sheets. Blank is fine — nothing is dropped either way:")||"").trim().toLowerCase();var v=document.querySelector('#view')||document.body,t=(window.L&&L.env&&L.env.media)||'x',o=[];function tag(c){var sel=((c.match(/([^{}]+)\{/)||['',''])[1]||'').trim().slice(0,70);var mine=P&&c.toLowerCase().indexOf(P)>=0;return '/* <head> sheet — first selector: '+sel+(mine?' — YOURS':(P?' — probably NOT yours, delete if so':' — is this yours? delete if not'))+' */';}document.querySelectorAll('head style').forEach(function(n){var c=(n.textContent||'').trim();if(c)o.push(tag(c)+'\n<head><style>\n'+c+'\n</style></head>');});document.querySelectorAll('head link[rel=stylesheet]').forEach(function(n){if(n.href.indexOf(t)<0&&n.href.indexOf('cascade.css')<0)o.push('<head><link rel="stylesheet" href="'+n.href+'"></head>');});var s=new Set();v.querySelectorAll('[style]').forEach(function(e){var x=e.getAttribute('style');if(x&&!s.has(x)){s.add(x);o.push(e.tagName.toLowerCase()+'[inline]{'+x+'}');}});v.querySelectorAll('style').forEach(function(e){var c=(e.textContent||'').trim();if(c)o.push(c);});v.querySelectorAll('font[color]').forEach(function(f){o.push('<font color="'+f.getAttribute('color')+'">x</font>');});var b=o.join('\n\n');try{copy(b)}catch(e){console.log(b)}console.log('%c[footstrap devkit] '+o.length+' snippet(s) copied — paste into Fix my styles','color:#2563eb;font-weight:700');})();
Each problem is pointed at where it is — the line, the exact fragment, and the fix. This is a conservative checker, not a compiler: it is sure about the ones it marks auto-fix and only flags the manual ones — it does not prove your app is correct. Read each against the rules.
The rules — and why each one exists
Every rule below is a bug found in a real, popular app, on a theme its author never tested. Do / Don't pairs; the reason is the point.
1. CSS lifetime — where you put a <style> decides who it hits
Return it inside your tree and it dies with your view. Append it to <head>
and under an SPA theme it lives forever and restyles every later page.
return E([], [
E('style', [ css ]), // scoped to this view by construction
E('div', { 'class': 'myapp-root' }, …)
]);
package-manager.js, nftables.js, aria2/log.js all do this.document.head.appendChild(style);
// .cbi-button-save { display:none !important }
// → every config page in the session is now unsavable
luci-app-filemanager hid the stock Save/Apply/Reset this way. To drop stock buttons, set handleSaveApply = null.2. Namespace everything you declare
You share :root and the class-name space with the theme and every other app.
.myapp-card { … }
#cbi-myapp-section { … }
.myapp-root { --myapp-accent: … } /* on YOUR root */
.hidden, .toast, .label, .skeleton { … }
:root { --accent: … } /* repaints the theme */
* { margin: 0; padding: 0 } /* flattens the chrome */
:root repainted 312 of 336 theme elements before the tokens were split private/export.3. Colour — the tier, never a literal
A hardcoded colour is right on exactly the one configuration you looked at. It is wrong the instant the user picks the high-contrast palette or drags the tint.
color: var(--text-color-high, #333); background-color: var(--background-color-low, #f5f5f5); color: var(--error-color-medium, #f44336); /* text on a fill: */ background: var(--success-color-high, #2e7d32); color: var(--on-success-color, #fff);
background: #f7f7f7; /* black-on-black in dark */ background: white; /* white box on a dark page */ color: var(--fs-accent); /* private tier */ color: var(--warning-color-medium); /* no such name */ <font color="green"> /* vanishes on half the palettes */
The good half: because the theme reads only its own
private tokens, your :root can no longer repaint it — and inside your own
subtree your unlayered CSS already outranks every theme layer. If you have an !important
to beat the theme, delete it; it was never what was winning.
4. Dark mode — prefers-color-scheme is the wrong question
That media query reports the operating system, not the theme. A user who forces the theme dark on a light OS gets your light card on their dark page.
function isDark() {
var r = document.documentElement;
if (r.dataset.darkmode) return r.dataset.darkmode === 'true';
if (r.dataset.theme) return r.dataset.theme === 'dark';
if (r.dataset.bsTheme) return r.dataset.bsTheme === 'dark';
// fallback: body luminance, works on any theme
}
// BEST: read every colour from the tier and never detect at all.
:root before first paint, so any check works.@media (prefers-color-scheme: dark) { … }
editor.setTheme('dracula'); /* black editor on a light page */
isDark(), not hardcoded.5. Layout & SPA
/* key on your container, not the viewport */
@container (max-width: 700px) { … }
// do work in render(), clear your own pollers,
// put nodes in your view tree
@media (max-width: 768px) { … } /* sidebar eats 224px */
window.onload = … /* fires once per document */
document.body.appendChild(toast) /* survives the view swap */
z-index: 2147483647; /* above the theme's overlays */
Ship checklist
Tick these before you tag. Click to mark done (saved locally).
- ✓
<style>returned inside the view tree, not appended to<head> - ✓Every class, id and custom property prefixed with the app name
- ✓No
:root { … }, no* { … }, no imported CSS framework - ✓No stock
.cbi-*/table/preselector styled outside your own subtree - ✓All colours from the
--*-color-*export names with a literal fallback;warn, notwarning - ✓No
--fs-*read anywhere — that is the private tier - ✓Text on a coloured fill takes its ink from
--on-*-color, never#fff - ✓No
!importantto beat the theme — your CSS already outranks every layer - ✓Dark mode from
data-theme/data-bs-theme/ luminance, neverprefers-color-scheme - ✓Editor theme chosen from the page's mode, not hardcoded
- ✓Layout keyed to the container, not the viewport
- ✓
handleSaveApply = nullinstead of hiding stock buttons - ✓No
window.onload, no body-level leftovers, nopopstatehijack, noz-index: 2147483647
Reference apps that get it right:
luci-app-nikki (zero injected CSS, zero literals),
stock luci-app-ttyd (the iframe done right),
luci-app-internet-detector (namespaced :root vars, dark keyed off the theme's attribute).