footstrap devkit — style a LuCI app that fits any theme

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.

🎛️ Just want to feel it? The playground is footstrap's real chrome with every Appearance control live — drag the tint, accent and rounding, flip the palette and layout, watch it repaint. Open the playground ↗
Theme name (luci.themes)Footstrap copy
mediaurlbase/luci-static/footstrap copy
Detect it is active (JS)L.env.media === '/luci-static/footstrap' copy
Depends on footstrap?no — never Your app depends on luci-base. It works on every theme.
The one golden rule. Your view is swapped into a #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.
The 30-second version
copy
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).

Do not read --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.
Text on a coloured fill takes its ink from the matching --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.

  1. 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).
  2. Press Fix styles. The right box fills with the rewritten code and the report appears underneath.
  3. 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.
Rewritten for you colour literals → tokens (original kept as fallback) · --warning-*--warn-* · --text-color--text-color-high · private --fs-*→export token · stray !important · runaway z-index · <font color>
Flagged for you CSS in <head> · :root{} / *{} · un-prefixed classes · window.onload · prefers-color-scheme · hardcoded editor theme · stock selectors without a scope
Grab 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 F12Console, 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.)

copy
(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');})();
Paste here

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.

✓ Do — in the view tree
return E([], [
    E('style', [ css ]),   // scoped to this view by construction
    E('div', { 'class': 'myapp-root' }, …)
]);
Stock package-manager.js, nftables.js, aria2/log.js all do this.
✗ Don't — append to head
document.head.appendChild(style);
// .cbi-button-save { display:none !important }
// → every config page in the session is now unsavable
Real: 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.

✓ Do — prefix with your package
.myapp-card { … }
#cbi-myapp-section { … }
.myapp-root { --myapp-accent: … }   /* on YOUR root */
✗ Don't — claim shared names
.hidden, .toast, .label, .skeleton { … }
:root { --accent: … }        /* repaints the theme */
* { margin: 0; padding: 0 }  /* flattens the chrome */
Each one is a live collision seen in the top-30 apps. A hostile :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.

✓ Do — read the export tier
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);
✗ Don't — hardcode or read privates
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.

✓ Do — ask the theme, or don't ask at all
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.
footstrap stamps all three attributes on :root before first paint, so any check works.
✗ Don't
@media (prefers-color-scheme: dark) { … }
editor.setTheme('dracula');   /* black editor on a light page */
Pick the editor theme from isDark(), not hardcoded.

5. Layout & SPA

✓ Do
/* 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
✗ Don't
@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 / pre selector styled outside your own subtree
  • All colours from the --*-color-* export names with a literal fallback; warn, not warning
  • No --fs-* read anywhere — that is the private tier
  • Text on a coloured fill takes its ink from --on-*-color, never #fff
  • No !important to beat the theme — your CSS already outranks every layer
  • Dark mode from data-theme / data-bs-theme / luminance, never prefers-color-scheme
  • Editor theme chosen from the page's mode, not hardcoded
  • Layout keyed to the container, not the viewport
  • handleSaveApply = null instead of hiding stock buttons
  • No window.onload, no body-level leftovers, no popstate hijack, no z-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).

copied