Files
small-packages/luci-theme-footstrap/docs/devkit.src.html
T
2026-09-20 01:22:33 +08:00

898 lines
60 KiB
HTML
Raw 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.
<!DOCTYPE html>
<!--
footstrap devkit — SOURCE template. Do not open this file in a browser; it has no stylesheet and
no data. The shipping page is docs/devkit.html, built by tools/devkit-build.mjs (npm run devkit),
which inlines the real cascade.css and injects window.FS_DEVKIT (tokens parsed from
styles/02-tokens.css, components sliced from docs/gallery.html). Edit chrome/prose HERE; edit
widget markup in docs/gallery.html; edit token values in styles/02-tokens.css.
This page's OWN chrome reads --fs-* on purpose — it is part of the footstrap repo, not a guest
luci-app. An actual app must NOT read --fs-* (that is the whole lesson of the Tokens section).
-->
<html lang="en" data-darkmode="false" data-theme="light" data-bs-theme="light" data-palette="footstrap">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>footstrap devkit — style a LuCI app that fits any theme</title>
<style>/*__CASCADE__*/</style>
<style>
/* devkit chrome only — never part of the theme, never something an app should copy */
:root { --dk-gap: 22px; }
* { box-sizing: border-box; }
body { background: var(--fs-bg); color: var(--fs-text); margin: 0;
font-family: Manrope, system-ui, sans-serif; }
a { color: var(--fs-accent); }
.dk-top { position: sticky; top: 0; z-index: 900; display: flex; gap: 14px; align-items: center;
padding: 12px 22px; background: var(--fs-bar-bg); backdrop-filter: blur(var(--fs-blur));
border-bottom: 1px solid var(--fs-border); flex-wrap: wrap; }
.dk-top .dk-brand { font-size: 16px; font-weight: 700; margin: 0; flex: 1 1 auto; letter-spacing: -.01em; }
.dk-top .dk-brand b { color: var(--fs-accent); }
.dk-top .dk-ctl { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.dk-top input[type=range] { width: 92px; vertical-align: middle; }
.dk-shell { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 34px;
max-width: 1200px; margin: 0 auto; padding: 26px 22px 120px; }
.dk-nav { position: sticky; top: 74px; align-self: start; display: flex; flex-direction: column;
gap: 2px; font-size: 13px; }
.dk-nav a { color: var(--fs-dim); text-decoration: none; padding: 6px 10px; border-radius: var(--fs-radius-s);
border-left: 2px solid transparent; }
.dk-nav a:hover { color: var(--fs-text); background: var(--fs-accent-soft); }
.dk-nav a.active { color: var(--fs-accent); border-left-color: var(--fs-accent); font-weight: 700; }
@media (max-width: 780px) { .dk-shell { grid-template-columns: 1fr; } .dk-nav { display: none; } }
.dk-sec { scroll-margin-top: 78px; margin: 0 0 52px; }
.dk-sec > h2 { font-size: 22px; margin: 0 0 4px; letter-spacing: -.02em; }
.dk-sec > .dk-lead { color: var(--fs-dim); font-size: 14px; margin: 0 0 20px; max-width: 62ch; line-height: 1.5; }
.dk-h3 { font-size: 13px; letter-spacing: .07em; text-transform: uppercase; color: var(--fs-dim);
font-weight: 700; margin: 26px 0 12px; }
.dk-card { background: var(--fs-panel); border: 1px solid var(--fs-border); border-radius: var(--fs-radius);
padding: 16px 18px; }
.dk-grid { display: grid; gap: var(--dk-gap); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
/* a copyable code chip / block */
.dk-copy { position: relative; cursor: copy; }
.dk-copy pre, .dk-copy code.blk { margin: 0; overflow-x: auto; }
.dk-copy .dk-cbtn { position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity var(--fs-dur);
font-size: 11px; padding: 3px 8px; border-radius: var(--fs-radius-s);
background: var(--fs-glass); border: 1px solid var(--fs-border); color: var(--fs-dim); }
.dk-copy:hover .dk-cbtn { opacity: 1; }
.dk-copy:hover { outline: 1px solid var(--fs-accent-line); outline-offset: 2px; border-radius: var(--fs-radius-s); }
code.dk-inline { cursor: copy; background: var(--fs-accent-soft); padding: 1px 6px; border-radius: 5px;
font-family: 'JetBrains Mono', monospace; font-size: .88em; }
/* fact tiles (Start here) */
.dk-fact { display: flex; flex-direction: column; gap: 6px; }
.dk-fact .k { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--fs-dim); font-weight: 700; }
.dk-fact .v { font-family: 'JetBrains Mono', monospace; font-size: 14px; word-break: break-all; }
.dk-callout { border-left: 3px solid var(--fs-accent); background: var(--fs-accent-fill);
padding: 12px 16px; border-radius: 0 var(--fs-radius) var(--fs-radius) 0; margin: 18px 0; font-size: 14px; }
/* tokens */
.dk-tok { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; cursor: copy; }
.dk-tok .sw { height: 54px; display: flex; align-items: flex-end; justify-content: flex-end; padding: 6px; }
.dk-tok .ink { font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 4px; }
.dk-tok .lbl { padding: 7px 9px; font-family: 'JetBrains Mono', monospace; font-size: 11.5px;
border-top: 1px solid var(--fs-border); background: var(--fs-panel); word-break: break-all; }
.dk-tokfam { margin-bottom: 20px; }
.dk-tokfam h4 { margin: 0 0 8px; font-size: 13px; }
.dk-tokfam .note { color: var(--fs-dim); font-size: 12px; margin: 0 0 10px; }
.dk-tokrow { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
/* component cards */
.dk-comp { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; margin-bottom: var(--dk-gap); }
.dk-comp > header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-bottom: 1px solid var(--fs-border); background: var(--fs-panel); }
.dk-comp > header h4 { margin: 0; font-size: 14px; flex: 1 1 auto; }
.dk-tabs { display: flex; gap: 4px; }
.dk-tabs button { font-size: 12px; padding: 3px 10px; border-radius: var(--fs-radius-s); border: 1px solid transparent;
background: transparent; color: var(--fs-dim); cursor: pointer; }
.dk-tabs button.on { background: var(--fs-accent-soft); color: var(--fs-accent); border-color: var(--fs-accent-line); }
.dk-panel { padding: 16px; }
.dk-panel.code { padding: 0; }
.dk-panel[hidden] { display: none; }
.dk-preview > * { max-width: 100%; }
/* do / don't */
.dk-dd { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; margin-bottom: 18px; }
@media (max-width: 680px) { .dk-dd { grid-template-columns: 1fr; } }
.dk-dd .box { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); overflow: hidden; }
.dk-dd .box > .hd { padding: 8px 14px; font-weight: 700; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.dk-dd .do > .hd { background: var(--fs-good-fill); color: var(--fs-good); border-bottom: 1px solid var(--fs-good-line); }
.dk-dd .dont > .hd { background: var(--fs-danger-fill); color: var(--fs-danger); border-bottom: 1px solid var(--fs-danger-line); }
.dk-dd .box pre { margin: 0; border: 0; border-radius: 0; }
.dk-dd .box .cap { padding: 10px 14px; font-size: 13px; color: var(--fs-dim); }
.dk-ruletitle { font-size: 16px; margin: 30px 0 4px; }
.dk-ruletitle + p { margin: 0 0 14px; color: var(--fs-dim); font-size: 14px; max-width: 68ch; }
/* checklist */
.dk-check { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.dk-check li { display: flex; gap: 10px; align-items: flex-start; padding: 10px 14px; border: 1px solid var(--fs-border);
border-radius: var(--fs-radius); font-size: 14px; cursor: pointer; }
.dk-check li.done { opacity: .55; text-decoration: line-through; }
.dk-check li .bx { flex: 0 0 auto; width: 18px; height: 18px; border: 2px solid var(--fs-accent); border-radius: 5px;
display: grid; place-items: center; font-size: 12px; color: var(--fs-on-accent); margin-top: 1px; }
.dk-check li.done .bx { background: var(--fs-accent); }
/* fix my styles */
.dk-howto { margin: 0 0 18px; }
.dk-howto ol { margin: 0 0 14px; padding-left: 20px; display: grid; gap: 7px; font-size: 13.5px; line-height: 1.5; }
.dk-howto ol b { color: var(--fs-text); }
.dk-howto-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 680px) { .dk-howto-cols { grid-template-columns: 1fr; } }
.dk-howto-cols > div { border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 10px 14px;
font-size: 12.5px; color: var(--fs-dim); line-height: 1.7; }
.dk-howto-cols .k { display: block; font-size: 11px; letter-spacing: .05em; text-transform: uppercase;
font-weight: 700; margin-bottom: 5px; }
.dk-grab { margin-bottom: 16px; border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 4px 14px; }
.dk-grab summary { cursor: pointer; font-weight: 700; font-size: 13.5px; padding: 8px 0; color: var(--fs-accent); }
.dk-grab p { font-size: 13.5px; color: var(--fs-dim); line-height: 1.55; margin: 4px 0 12px; }
.dk-grab pre { margin: 0; max-height: 120px; overflow: auto; }
.dk-grab code.blk { white-space: pre-wrap; word-break: break-all; font-size: 11px; }
.dk-fixbar { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
.dk-fix-count { font-size: 13px; color: var(--fs-dim); }
.dk-fixgrid { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
@media (max-width: 760px) { .dk-fixgrid { grid-template-columns: 1fr; } }
.dk-fixarea { width: 100%; min-height: 260px; resize: vertical; font-family: 'JetBrains Mono', monospace;
font-size: 12.5px; line-height: 1.55; padding: 12px 14px; border: 1px solid var(--fs-border);
border-radius: var(--fs-radius); background: var(--fs-panel); color: var(--fs-text); white-space: pre; overflow: auto; }
.dk-fixarea:focus { outline: 2px solid var(--fs-accent); outline-offset: 1px; }
.dk-report { margin-top: 20px; display: grid; gap: 8px; }
.dk-finding { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start;
border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 10px 14px; font-size: 13.5px; }
.dk-finding .tag { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
padding: 3px 8px; border-radius: 999px; white-space: nowrap; align-self: center; }
.dk-finding.fix .tag { background: var(--fs-good-fill); color: var(--fs-good); }
.dk-finding.warn .tag { background: var(--fs-warn-fill); color: var(--fs-warn); }
.dk-finding .msg b { color: var(--fs-text); }
.dk-finding .why { color: var(--fs-dim); display: block; margin-top: 3px; }
.dk-finding code { font-family: 'JetBrains Mono', monospace; background: var(--fs-accent-soft);
padding: 1px 5px; border-radius: 4px; font-size: .92em; }
.dk-report-empty { color: var(--fs-good); font-weight: 700; font-size: 14px; padding: 12px 0; }
.dk-report h4 { margin: 18px 0 4px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--fs-dim); }
/* per-occurrence diagnostics — point at each problem where it is */
.dk-report { margin-top: 18px; display: grid; gap: 10px; }
.dk-diag { border: 1px solid var(--fs-border); border-left-width: 3px; border-radius: var(--fs-radius); padding: 10px 14px; }
.dk-diag.fix { border-left-color: var(--fs-good); }
.dk-diag.warn { border-left-color: var(--fs-warn); }
.dk-diag-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.dk-diag-top .ln { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--fs-dim);
background: var(--fs-panel2); padding: 2px 7px; border-radius: 999px; }
.dk-diag-top .tag { font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; }
.dk-diag.fix .tag { background: var(--fs-good-fill); color: var(--fs-good); }
.dk-diag.warn .tag { background: var(--fs-warn-fill); color: var(--fs-warn); }
.dk-diag-top .ti { font-size: 13.5px; font-weight: 700; }
.dk-diag-top .ti code { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: .92em; }
.dk-diag-code { margin: 0 0 8px; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.5;
background: var(--fs-panel2); border-radius: var(--fs-radius-s); padding: 8px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
.dk-diag-code mark { background: var(--fs-warn-fill); color: var(--fs-danger); font-weight: 700; padding: 0 2px; border-radius: 3px; }
.dk-diag-edit { display: flex; align-items: center; gap: 10px; margin-bottom: 7px; flex-wrap: wrap; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.dk-diag-edit .was { color: var(--fs-danger); text-decoration: line-through; opacity: .8; }
.dk-diag-edit .arr { color: var(--fs-dim); }
.dk-diag-edit .now { background: var(--fs-good-fill); color: var(--fs-good); padding: 2px 8px; border-radius: 5px; cursor: copy; }
.dk-diag-edit .now:hover { outline: 1px solid var(--fs-good); }
.dk-diag-why { font-size: 13px; color: var(--fs-dim); line-height: 1.5; }
.dk-diag-why code { font-family: 'JetBrains Mono', monospace; background: var(--fs-accent-soft); padding: 1px 5px; border-radius: 4px; font-size: .9em; }
/* copy toast */
.dk-toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--fs-text); color: var(--fs-bg); padding: 8px 18px; border-radius: 999px; font-size: 13px;
font-weight: 700; opacity: 0; pointer-events: none; transition: opacity var(--fs-dur-fade), transform var(--fs-dur-move); z-index: 999; }
.dk-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body class="lang_en">
<div class="dk-top">
<h1 class="dk-brand"><b>footstrap</b> devkit — style a LuCI app that fits any theme</h1>
<div class="dk-ctl">
<label style="font-size:12px;color:var(--fs-dim)">tint
<input type="range" min="1" max="360" value="255" id="dk-tint"></label>
<button class="cbi-button" id="dk-pal">palette</button>
<button class="cbi-button" id="dk-dark">dark</button>
</div>
</div>
<div class="dk-shell">
<nav class="dk-nav" id="dk-nav">
<a href="#start">Start here</a>
<a href="#tokens">Colour tokens</a>
<a href="#components">Components</a>
<a href="#fix">Fix my styles</a>
<a href="#rules">The rules</a>
<a href="playground/" style="color:var(--fs-accent);font-weight:700">Playground ↗</a>
<a href="#checklist">Ship checklist</a>
</nav>
<main>
<!-- ─────────────────────────── START HERE ─────────────────────────── -->
<section class="dk-sec" id="start">
<h2>Start here</h2>
<p class="dk-lead">You are writing a <code class="dk-inline">luci-app-*</code> package. footstrap
is one of the themes it may run under. You do <b>not</b> 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.</p>
<div class="dk-callout" style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span>🎛️ <b>Just want to feel it?</b> 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.</span>
<a href="playground/" style="margin-left:auto;color:var(--fs-accent);font-weight:700;text-decoration:none;border:1px solid var(--fs-accent-line);padding:6px 14px;border-radius:var(--fs-radius-s);white-space:nowrap">Open the playground ↗</a>
</div>
<div class="dk-grid">
<div class="dk-card dk-fact dk-copy" data-copy="Footstrap">
<span class="k">Theme name (luci.themes)</span><span class="v">Footstrap</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact dk-copy" data-copy="/luci-static/footstrap">
<span class="k">mediaurlbase</span><span class="v">/luci-static/footstrap</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact dk-copy" data-copy="L.env.media">
<span class="k">Detect it is active (JS)</span><span class="v">L.env.media === '/luci-static/footstrap'</span>
<span class="dk-cbtn">copy</span>
</div>
<div class="dk-card dk-fact">
<span class="k">Depends on footstrap?</span><span class="v">no — never</span>
<span style="font-size:12px;color:var(--fs-dim)">Your app depends on <code class="dk-inline">luci-base</code>. It works on every theme.</span>
</div>
</div>
<div class="dk-callout">
<b>The one golden rule.</b> Your view is swapped into a <code class="dk-inline">#view</code> 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 <code class="dk-inline">&lt;head&gt;</code>,
a class you did not prefix, a <code class="dk-inline">:root</code> variable — outlives your page and
paints someone else's. Stay inside your subtree and you cannot break anything.
</div>
<div class="dk-h3">The 30-second version</div>
<div class="dk-copy dk-card" data-copy-from="dk-quickstart">
<span class="dk-cbtn">copy</span>
<pre><code class="blk" id="dk-quickstart">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'))
])
]);</code></pre>
</div>
</section>
<!-- ─────────────────────────── TOKENS ─────────────────────────── -->
<section class="dk-sec" id="tokens">
<h2>Colour tokens — the whole contract</h2>
<p class="dk-lead">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 <b>only</b> footstrap surface you may read. Click a swatch to copy its
<code class="dk-inline">var()</code>. Always give a literal fallback: <code class="dk-inline">var(--text-color-high, #333)</code>.</p>
<div id="dk-tokens"></div>
<div class="dk-callout">
<b>Do not read <code class="dk-inline">--fs-*</code>.</b> Open the stylesheet and you will find ~80
<code class="dk-inline">--fs-accent</code>, <code class="dk-inline">--fs-panel2</code>… 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 <code class="dk-inline">--*-color-*</code> names above are
the contract; nothing else is.
</div>
<div class="dk-callout" style="border-left-color:var(--fs-warn);background:var(--fs-warn-fill)">
Text on a coloured fill takes its ink from the matching <code class="dk-inline">--on-*-color</code>
— never a hardcoded <code class="dk-inline">#fff</code>. A white-on-fill badge fell to 1.69:1 on seven
dark palettes before these inks were made per-palette. The family is <code class="dk-inline">warn</code>,
not <code class="dk-inline">warning</code>; <code class="dk-inline">--text-color</code> (no level) does
not exist.
</div>
</section>
<!-- ─────────────────────────── COMPONENTS ─────────────────────────── -->
<section class="dk-sec" id="components">
<h2>Components — copy the markup</h2>
<p class="dk-lead">Every widget stock LuCI (and any luci-app) can emit, with the real class names
<code class="dk-inline">ui.js</code>/<code class="dk-inline">cbi.js</code> produce. Each card has three
tabs: the live <b>Preview</b>, the <b>HTML</b>, and the <b>E()</b> form — the
<code class="dk-inline">L.dom</code> factory you actually call in a view. Copy either. Build these from
<code class="dk-inline">'require ui'</code> where a real widget class exists (a
<code class="dk-inline">ui.Dropdown</code>, <code class="dk-inline">ui.FileUpload</code>); the markup
here is what those render to.</p>
<div id="dk-components"></div>
</section>
<!-- ─────────────────────────── FIX MY STYLES ─────────────────────────── -->
<section class="dk-sec" id="fix">
<h2>Fix my styles</h2>
<p class="dk-lead">Paste a chunk of your app — CSS, a <code class="dk-inline">&lt;style&gt;</code> block,
markup with inline styles, a snippet copied straight out of DevTools — and press <b>Fix</b>. 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
<code class="dk-inline">var(--token, <i>original</i>)</code> 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.</p>
<div class="dk-howto">
<ol>
<li><b>Get the code in.</b> Paste your CSS / <code class="dk-inline">&lt;style&gt;</code> / 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 <b>Save Page As → HTML only</b>).</li>
<li><b>Press <span style="color:var(--fs-good)">Fix styles</span>.</b> The right box fills with the
rewritten code and the report appears underneath.</li>
<li><b>Take the fixed copy, then clear the manual list.</b> Copy the right box back into your app,
then work through <b>Fix these by hand</b> — each links to <a href="#rules">the rule</a> that
explains it. You're done when that list is empty.</li>
</ol>
<div class="dk-howto-cols">
<div><span class="k" style="color:var(--fs-good)">Rewritten for you</span>
colour literals → tokens (original kept as fallback) · <code class="dk-inline">--warning-*</code>→<code class="dk-inline">--warn-*</code> ·
<code class="dk-inline">--text-color</code>→<code class="dk-inline">--text-color-high</code> · private <code class="dk-inline">--fs-*</code>→export token ·
stray <code class="dk-inline">!important</code> · runaway <code class="dk-inline">z-index</code> · <code class="dk-inline">&lt;font color&gt;</code></div>
<div><span class="k" style="color:var(--fs-warn)">Flagged for you</span>
CSS in <code class="dk-inline">&lt;head&gt;</code> · <code class="dk-inline">:root{}</code> / <code class="dk-inline">*{}</code> ·
un-prefixed classes · <code class="dk-inline">window.onload</code> · <code class="dk-inline">prefers-color-scheme</code> ·
hardcoded editor theme · stock selectors without a scope</div>
</div>
</div>
<details class="dk-grab">
<summary>Grab it straight off a running app →</summary>
<p>Can't find the CSS, or it's injected from JS? Open <b>your app's page</b> in the browser,
press <kbd>F12</kbd> → <b>Console</b>, paste this line and hit <kbd>Enter</kbd>. It harvests <b>every</b>
injected sheet, every inline <code class="dk-inline">style=</code> in <code class="dk-inline">#view</code>
and every <code class="dk-inline">&lt;font color&gt;</code> to your clipboard — <b>nothing is dropped</b>, so
it can't hide a bug. Paste below, press Fix.</p>
<p><b>Whose sheet is whose.</b> A live LuCI page also carries CSS that is <b>not yours</b>: the theme's own
stylesheet (skipped — it's a filtered <code class="dk-inline">&lt;link&gt;</code>), LuCI base's injected bits
(<code class="dk-inline">status/cpu.js</code>, <code class="dk-inline">package-manager.js</code>), and every
<i>other</i> app on the page. So each harvested <code class="dk-inline">&lt;head&gt;</code> sheet is <b>tagged with
its first selector</b> — 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 <code class="dk-inline">YOURS</code>.
(A <b>Save Page As → HTML only</b> dump works too.)</p>
<div class="dk-copy dk-card" data-copy-from="dk-grab-src">
<span class="dk-cbtn">copy</span>
<pre><code class="blk" id="dk-grab-src">(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&amp;&amp;L.env&amp;&amp;L.env.media)||'x',o=[];function tag(c){var sel=((c.match(/([^{}]+)\{/)||['',''])[1]||'').trim().slice(0,70);var mine=P&amp;&amp;c.toLowerCase().indexOf(P)&gt;=0;return '/* &lt;head&gt; 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&lt;head&gt;&lt;style&gt;\n'+c+'\n&lt;/style&gt;&lt;/head&gt;');});document.querySelectorAll('head link[rel=stylesheet]').forEach(function(n){if(n.href.indexOf(t)&lt;0&amp;&amp;n.href.indexOf('cascade.css')&lt;0)o.push('&lt;head&gt;&lt;link rel="stylesheet" href="'+n.href+'"&gt;&lt;/head&gt;');});var s=new Set();v.querySelectorAll('[style]').forEach(function(e){var x=e.getAttribute('style');if(x&amp;&amp;!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('&lt;font color="'+f.getAttribute('color')+'"&gt;x&lt;/font&gt;');});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');})();</code></pre>
</div>
</details>
<div class="dk-fixbar">
<button class="cbi-button cbi-button-positive" id="dk-fix-run">Check ↓</button>
<button class="cbi-button" id="dk-fix-sample">Load a broken example</button>
<button class="cbi-button" id="dk-fix-clear">Clear</button>
<button class="cbi-button" id="dk-fix-applyall" hidden>Apply every fix &amp; copy</button>
<span class="dk-fix-count" id="dk-fix-count"></span>
</div>
<div class="dk-h3" style="margin-top:0">Paste here</div>
<textarea id="dk-fix-in" class="dk-fixarea" spellcheck="false" placeholder="/* paste your CSS / <style> / markup, or grab it above */"></textarea>
<div id="dk-fix-report"></div>
<p style="font-size:13px;color:var(--fs-dim);margin-top:16px">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 <b>auto-fix</b> and only flags the <b>manual</b> ones — it does not prove your app is
correct. Read each against <a href="#rules">the rules</a>.</p>
</section>
<!-- ─────────────────────────── RULES ─────────────────────────── -->
<section class="dk-sec" id="rules">
<h2>The rules — and why each one exists</h2>
<p class="dk-lead">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.</p>
<h3 class="dk-ruletitle">1. CSS lifetime — where you put a <code class="dk-inline">&lt;style&gt;</code> decides who it hits</h3>
<p>Return it inside your tree and it dies with your view. Append it to <code class="dk-inline">&lt;head&gt;</code>
and under an SPA theme it lives forever and restyles every later page.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — in the view tree</div>
<pre>return E([], [
E('style', [ css ]), // scoped to this view by construction
E('div', { 'class': 'myapp-root' }, …)
]);</pre>
<div class="cap">Stock <code class="dk-inline">package-manager.js</code>, <code class="dk-inline">nftables.js</code>, <code class="dk-inline">aria2/log.js</code> all do this.</div>
</div>
<div class="box dont"><div class="hd">✗ Don't — append to head</div>
<pre>document.head.appendChild(style);
// .cbi-button-save { display:none !important }
// → every config page in the session is now unsavable</pre>
<div class="cap">Real: <code class="dk-inline">luci-app-filemanager</code> hid the stock Save/Apply/Reset this way. To drop stock buttons, set <code class="dk-inline">handleSaveApply = null</code>.</div>
</div>
</div>
<h3 class="dk-ruletitle">2. Namespace everything you declare</h3>
<p>You share <code class="dk-inline">:root</code> and the class-name space with the theme and every other app.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — prefix with your package</div>
<pre>.myapp-card { … }
#cbi-myapp-section { … }
.myapp-root { --myapp-accent: … } /* on YOUR root */</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't — claim shared names</div>
<pre>.hidden, .toast, .label, .skeleton { … }
:root { --accent: … } /* repaints the theme */
* { margin: 0; padding: 0 } /* flattens the chrome */</pre>
<div class="cap">Each one is a live collision seen in the top-30 apps. A hostile <code class="dk-inline">:root</code> repainted 312 of 336 theme elements before the tokens were split private/export.</div>
</div>
</div>
<h3 class="dk-ruletitle">3. Colour — the tier, never a literal</h3>
<p>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.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — read the export tier</div>
<pre>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);</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't — hardcode or read privates</div>
<pre>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 */
&lt;font color="green"&gt; /* vanishes on half the palettes */</pre>
</div>
</div>
<p style="font-size:14px;color:var(--fs-dim)"><b>The good half:</b> because the theme reads only its own
private tokens, your <code class="dk-inline">:root</code> can no longer repaint it — and inside your own
subtree your unlayered CSS already outranks every theme layer. If you have an <code class="dk-inline">!important</code>
to beat the theme, delete it; it was never what was winning.</p>
<h3 class="dk-ruletitle">4. Dark mode — <code class="dk-inline">prefers-color-scheme</code> is the wrong question</h3>
<p>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.</p>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do — ask the theme, or don't ask at all</div>
<pre>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.</pre>
<div class="cap">footstrap stamps all three attributes on <code class="dk-inline">:root</code> before first paint, so any check works.</div>
</div>
<div class="box dont"><div class="hd">✗ Don't</div>
<pre>@media (prefers-color-scheme: dark) { … }
editor.setTheme('dracula'); /* black editor on a light page */</pre>
<div class="cap">Pick the editor theme from <code class="dk-inline">isDark()</code>, not hardcoded.</div>
</div>
</div>
<h3 class="dk-ruletitle">5. Layout &amp; SPA</h3>
<div class="dk-dd">
<div class="box do"><div class="hd">✓ Do</div>
<pre>/* 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</pre>
</div>
<div class="box dont"><div class="hd">✗ Don't</div>
<pre>@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 */</pre>
</div>
</div>
</section>
<!-- ─────────────────────────── CHECKLIST ─────────────────────────── -->
<section class="dk-sec" id="checklist">
<h2>Ship checklist</h2>
<p class="dk-lead">Tick these before you tag. Click to mark done (saved locally).</p>
<ul class="dk-check" id="dk-check">
<li data-k="c1"><span class="bx">✓</span><span><code class="dk-inline">&lt;style&gt;</code> returned inside the view tree, not appended to <code class="dk-inline">&lt;head&gt;</code></span></li>
<li data-k="c2"><span class="bx">✓</span><span>Every class, id and custom property prefixed with the app name</span></li>
<li data-k="c3"><span class="bx">✓</span><span>No <code class="dk-inline">:root { … }</code>, no <code class="dk-inline">* { … }</code>, no imported CSS framework</span></li>
<li data-k="c4"><span class="bx">✓</span><span>No stock <code class="dk-inline">.cbi-*</code> / <code class="dk-inline">table</code> / <code class="dk-inline">pre</code> selector styled outside your own subtree</span></li>
<li data-k="c5"><span class="bx">✓</span><span>All colours from the <code class="dk-inline">--*-color-*</code> export names with a literal fallback; <code class="dk-inline">warn</code>, not <code class="dk-inline">warning</code></span></li>
<li data-k="c6"><span class="bx">✓</span><span>No <code class="dk-inline">--fs-*</code> read anywhere — that is the private tier</span></li>
<li data-k="c7"><span class="bx">✓</span><span>Text on a coloured fill takes its ink from <code class="dk-inline">--on-*-color</code>, never <code class="dk-inline">#fff</code></span></li>
<li data-k="c8"><span class="bx">✓</span><span>No <code class="dk-inline">!important</code> to beat the theme — your CSS already outranks every layer</span></li>
<li data-k="c9"><span class="bx">✓</span><span>Dark mode from <code class="dk-inline">data-theme</code> / <code class="dk-inline">data-bs-theme</code> / luminance, never <code class="dk-inline">prefers-color-scheme</code></span></li>
<li data-k="c10"><span class="bx">✓</span><span>Editor theme chosen from the page's mode, not hardcoded</span></li>
<li data-k="c11"><span class="bx">✓</span><span>Layout keyed to the container, not the viewport</span></li>
<li data-k="c12"><span class="bx">✓</span><span><code class="dk-inline">handleSaveApply = null</code> instead of hiding stock buttons</span></li>
<li data-k="c13"><span class="bx">✓</span><span>No <code class="dk-inline">window.onload</code>, no body-level leftovers, no <code class="dk-inline">popstate</code> hijack, no <code class="dk-inline">z-index: 2147483647</code></span></li>
</ul>
<p style="margin-top:22px;font-size:14px;color:var(--fs-dim)">Reference apps that get it right:
<code class="dk-inline">luci-app-nikki</code> (zero injected CSS, zero literals),
stock <code class="dk-inline">luci-app-ttyd</code> (the iframe done right),
<code class="dk-inline">luci-app-internet-detector</code> (namespaced <code class="dk-inline">:root</code> vars, dark keyed off the theme's attribute).</p>
</section>
</main>
</div>
<div class="dk-toast" id="dk-toast">copied</div>
<script>/*__DATA__*/</script>
<script>
(function () {
'use strict';
var D = window.FS_DEVKIT || { tiers: [], components: [] };
var $ = function (s, r) { return (r || document).querySelector(s); };
/* ---- clipboard + toast ---- */
var toast = $('#dk-toast'), tt;
function flash(msg) { toast.textContent = msg || 'copied'; toast.classList.add('show');
clearTimeout(tt); tt = setTimeout(function () { toast.classList.remove('show'); }, 1100); }
function copy(text, label) {
navigator.clipboard.writeText(text).then(function () { flash(label || 'copied'); },
function () { flash('copy failed'); });
}
document.addEventListener('click', function (e) {
var el = e.target.closest('[data-copy]');
if (el) { copy(el.getAttribute('data-copy'), 'copied ' + el.getAttribute('data-copy')); return; }
var from = e.target.closest('[data-copy-from]');
if (from) { copy($('#' + from.getAttribute('data-copy-from')).textContent); return; }
var inl = e.target.closest('code.dk-inline');
if (inl && !e.target.closest('a')) { copy(inl.textContent); }
});
/* ---- tokens ---- */
var NOTES = {
background: 'Elevation, not emphasis: high = raised (a card), low = recessed (the page).',
border: 'high = the visible rule, low = the hairline that fades into the surface.',
text: 'Four levels. -low equals -medium on purpose — a fainter grey would fail AA.',
primary: 'The accent ramp. Chroma at constant lightness — every level is legible as text.',
success: 'high / medium / low are three real colours, not aliases.',
warn: 'The family is warn, NOT warning. --warning-color-* does not exist.',
error: ''
};
function readableInk(fam) { return fam ? 'var(--on-' + fam + '-color)' : '#fff'; }
(function renderTokens() {
var mount = $('#dk-tokens'); if (!mount) return;
D.tiers.forEach(function (t) {
var fam = document.createElement('div'); fam.className = 'dk-tokfam';
var h = document.createElement('h4'); h.textContent = t.family; fam.appendChild(h);
if (NOTES[t.family]) { var n = document.createElement('p'); n.className = 'note'; n.textContent = NOTES[t.family]; fam.appendChild(n); }
var row = document.createElement('div'); row.className = 'dk-tokrow';
t.levels.forEach(function (name) {
var v = 'var(--' + name + ')';
var card = document.createElement('div'); card.className = 'dk-tok'; card.setAttribute('data-copy', v);
var sw = document.createElement('div'); sw.className = 'sw'; sw.style.background = v;
if (t.ink) { var ink = document.createElement('span'); ink.className = 'ink';
ink.style.background = 'var(--' + t.ink + ')'; ink.style.color = v; ink.textContent = 'ink'; sw.appendChild(ink); }
var lbl = document.createElement('div'); lbl.className = 'lbl'; lbl.textContent = '--' + name;
card.appendChild(sw); card.appendChild(lbl); row.appendChild(card);
});
if (t.ink) {
var v2 = 'var(--' + t.ink + ')';
var card2 = document.createElement('div'); card2.className = 'dk-tok'; card2.setAttribute('data-copy', v2);
var sw2 = document.createElement('div'); sw2.className = 'sw'; sw2.style.background = 'var(--' + t.family + '-color-high)';
var chip = document.createElement('span'); chip.className = 'ink'; chip.style.color = v2; chip.textContent = 'Aa'; chip.style.fontSize = '15px';
sw2.appendChild(chip);
var lbl2 = document.createElement('div'); lbl2.className = 'lbl'; lbl2.textContent = '--' + t.ink;
card2.appendChild(sw2); card2.appendChild(lbl2); row.appendChild(card2);
}
fam.appendChild(row); mount.appendChild(fam);
});
})();
/* ---- DOM → E(): the L.dom factory an app actually calls. Prints indented, string child when
an element holds only text, attrs object omitted when empty. Matches the live preview node. ---- */
function domToE(node, indent) {
var pad = new Array(indent + 1).join(' ');
var tag = node.tagName.toLowerCase();
var attrs = {};
for (var i = 0; i < node.attributes.length; i++) attrs[node.attributes[i].name] = node.attributes[i].value;
var attrStr = Object.keys(attrs).length
? '{ ' + Object.keys(attrs).map(function (k) { return "'" + k + "': '" + attrs[k].replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; }).join(', ') + ' }'
: '{}';
var kids = Array.prototype.filter.call(node.childNodes, function (c) {
return c.nodeType === 1 || (c.nodeType === 3 && c.textContent.trim());
});
if (!kids.length) return pad + "E('" + tag + "', " + attrStr + ')';
if (kids.length === 1 && kids[0].nodeType === 3)
return pad + "E('" + tag + "', " + attrStr + ", '" + kids[0].textContent.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "')";
var childCode = kids.map(function (c) {
if (c.nodeType === 3) return pad + " '" + c.textContent.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
return domToE(c, indent + 1);
}).join(',\n');
return pad + "E('" + tag + "', " + attrStr + ', [\n' + childCode + '\n' + pad + '])';
}
function fragToE(container) {
var els = Array.prototype.filter.call(container.children, function (c) { return c.nodeType === 1; });
if (els.length === 1) return domToE(els[0], 0) + ';';
return 'E([], [\n' + els.map(function (el) { return domToE(el, 1); }).join(',\n') + '\n]);';
}
/* ---- components: three tabs off the one rendered preview ---- */
(function renderComponents() {
var mount = $('#dk-components'); if (!mount) return;
D.components.forEach(function (c, idx) {
var comp = document.createElement('div'); comp.className = 'dk-comp';
var head = document.createElement('header');
var h = document.createElement('h4'); h.textContent = c.title;
var tabs = document.createElement('div'); tabs.className = 'dk-tabs';
head.appendChild(h); head.appendChild(tabs); comp.appendChild(head);
var preview = document.createElement('div'); preview.className = 'dk-panel dk-preview';
preview.innerHTML = c.markup;
var htmlWrap = document.createElement('div'); htmlWrap.className = 'dk-panel code dk-copy'; htmlWrap.hidden = true;
var htmlPre = document.createElement('pre'); var htmlCode = document.createElement('code'); htmlCode.className = 'blk';
htmlCode.textContent = c.markup.replace(/\t/g, ' ').trim();
htmlPre.appendChild(htmlCode); htmlWrap.appendChild(htmlPre);
var hb = document.createElement('span'); hb.className = 'dk-cbtn'; hb.textContent = 'copy'; htmlWrap.appendChild(hb);
htmlWrap.addEventListener('click', function () { copy(htmlCode.textContent); });
var eWrap = document.createElement('div'); eWrap.className = 'dk-panel code dk-copy'; eWrap.hidden = true;
var ePre = document.createElement('pre'); var eCode = document.createElement('code'); eCode.className = 'blk';
eCode.textContent = fragToE(preview);
ePre.appendChild(eCode); eWrap.appendChild(ePre);
var eb = document.createElement('span'); eb.className = 'dk-cbtn'; eb.textContent = 'copy'; eWrap.appendChild(eb);
eWrap.addEventListener('click', function () { copy(eCode.textContent); });
comp.appendChild(preview); comp.appendChild(htmlWrap); comp.appendChild(eWrap);
[['Preview', preview], ['HTML', htmlWrap], ['E()', eWrap]].forEach(function (pair, i) {
var b = document.createElement('button'); b.textContent = pair[0]; if (i === 0) b.className = 'on';
b.addEventListener('click', function () {
tabs.querySelectorAll('button').forEach(function (x) { x.classList.remove('on'); });
b.classList.add('on');
[preview, htmlWrap, eWrap].forEach(function (p) { p.hidden = true; });
pair[1].hidden = false;
});
tabs.appendChild(b);
});
mount.appendChild(comp);
});
})();
/* ---- controls: dark / palette / tint ---- */
var root = document.documentElement;
function setDark(on) {
root.setAttribute('data-darkmode', on ? 'true' : 'false');
root.setAttribute('data-theme', on ? 'dark' : 'light');
root.setAttribute('data-bs-theme', on ? 'dark' : 'light');
}
$('#dk-dark').addEventListener('click', function () { setDark(root.getAttribute('data-darkmode') !== 'true'); });
$('#dk-pal').addEventListener('click', function () {
root.setAttribute('data-palette', root.getAttribute('data-palette') === 'hicontrast' ? 'footstrap' : 'hicontrast');
});
$('#dk-tint').addEventListener('input', function () {
root.style.setProperty('--fs-tint-h', this.value); // property BEFORE attribute (the head.ut ordering rule)
root.setAttribute('data-tint', 'on');
});
/* ---- checklist (localStorage) ---- */
var CK = 'dk-check';
var done = {};
try { done = JSON.parse(localStorage.getItem(CK) || '{}'); } catch (e) { done = {}; }
$('#dk-check').querySelectorAll('li').forEach(function (li) {
var k = li.getAttribute('data-k');
if (done[k]) li.classList.add('done');
li.addEventListener('click', function () {
li.classList.toggle('done'); done[k] = li.classList.contains('done');
try { localStorage.setItem(CK, JSON.stringify(done)); } catch (e) {}
});
});
/* ---- fix my styles: a conservative in-browser codemod ----
Rewrites only what is provably safe (every colour keeps the original as a var() fallback, so a
rewrite can never change rendering on a theme without the token) and FLAGS the structural
mistakes it must not touch. Text-based on purpose: the input is a paste — CSS, a <style>, markup
with inline styles, a DevTools copy — not a parseable document. ---- */
(function fixTool() {
var inEl = $('#dk-fix-in'), rep = $('#dk-fix-report'), cnt = $('#dk-fix-count');
if (!inEl) return;
function parseHex(h) {
h = h.replace('#', '');
if (h.length === 3) h = h.split('').map(function (c) { return c + c; }).join('');
if (h.length < 6 || /[^0-9a-f]/i.test(h.slice(0, 6))) return null;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
var NAMED = { white: [255, 255, 255], black: [0, 0, 0], red: [255, 0, 0], green: [0, 128, 0],
blue: [0, 0, 255], orange: [255, 165, 0], gray: [128, 128, 128], grey: [128, 128, 128],
whitesmoke: [245, 245, 245], silver: [192, 192, 192], lime: [0, 255, 0], navy: [0, 0, 128],
teal: [0, 128, 128], maroon: [128, 0, 0], yellow: [255, 255, 0] };
function rgbOf(c) {
c = c.trim().toLowerCase();
if (c[0] === '#') return parseHex(c);
if (NAMED[c]) return NAMED[c].slice();
var m = c.match(/^rgba?\(([^)]+)\)/);
if (m) { var p = m[1].split(',').map(parseFloat); return [p[0], p[1], p[2]]; }
return null;
}
/* (property-kind, colour) → the export token that role should read. Chromatic colours map to a
status family by dominant hue; greys map by luminance on an elevation/text ladder. */
function classify(kind, color) {
var rgb = rgbOf(color); if (!rgb) return null;
var r = rgb[0], g = rgb[1], b = rgb[2],
lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255, sat = Math.max(r, g, b) - Math.min(r, g, b);
if (sat > 50) {
var fam;
if (g >= r && g >= b) fam = 'success';
else if (b >= r && b >= g) fam = 'primary';
else fam = (g > 110 ? 'warn' : 'error'); // red dominant; strong green too → orange/amber
return kind === 'bg' ? fam + '-color-high' : fam + '-color-medium';
}
if (kind === 'bg') return lum > 0.92 ? 'background-color-high' : 'background-color-low';
if (kind === 'border') return 'border-color-medium';
return lum < 0.5 ? 'text-color-high' : 'text-color-medium';
}
var COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|\b(?:white|black|red|green|blue|orange|gray|grey|whitesmoke|silver|lime|navy|teal|maroon|yellow)\b/;
function kindOf(prop) {
prop = prop.toLowerCase();
if (/^background(-color)?$/.test(prop)) return 'bg';
if (/^border(-(top|right|bottom|left))?(-color)?$/.test(prop)) return 'border';
if (prop === 'color' || prop === 'fill' || prop === 'stroke') return 'text';
return null;
}
var FS_MAP = { 'fs-accent': 'primary-color-high', 'fs-danger': 'error-color-high',
'fs-good': 'success-color-high', 'fs-warn': 'warn-color-high', 'fs-panel': 'background-color-high',
'fs-panel2': 'background-color-low', 'fs-bg': 'background-color-low', 'fs-text': 'text-color-high',
'fs-dim': 'text-color-medium', 'fs-border': 'border-color-medium' };
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
/* Point at each problem where it is: return per-occurrence findings with a line number, the source
line, the exact offending fragment (to highlight) and — for the mechanical ones — the precise
before→after edit. run() also computes the whole-input rewrite for the "apply everything" path. */
function run(src) {
var F = [];
function locate(idx) {
var pre = src.slice(0, idx), s = pre.lastIndexOf('\n') + 1, e = src.indexOf('\n', idx);
if (e < 0) e = src.length;
return { line: pre.split('\n').length, text: src.slice(s, e) };
}
function fix(idx, mark, title, why, before, after) {
var l = locate(idx); F.push({ level: 'fix', line: l.line, text: l.text, mark: mark, title: title, why: why, before: before, after: after });
}
function warn(idx, mark, title, why) {
var l = locate(idx); F.push({ level: 'warn', line: l.line, text: l.text, mark: mark, title: title, why: why });
}
var m, re;
// 1. colour literals → tokens (original kept as fallback)
re = /([-\w]+)\s*:\s*([^;{}]+)/g;
while ((m = re.exec(src))) {
var kind = kindOf(m[1]); if (!kind || /var\(/.test(m[2])) continue;
var cm = m[2].match(COLOR_RE); if (!cm) continue;
var tok = classify(kind, cm[0]); if (!tok) continue;
var before = cm[0].trim(), after = 'var(--' + tok + ', ' + before + ')';
fix(m.index + m[0].indexOf(cm[0]), before, 'Hard-coded colour',
'A literal is right on the one theme/mode you looked at. Read the export token so it follows light/dark, the palette and the tint; your value stays as the fallback.', before, after);
}
// 2. wrong token names
re = /--warning-color-(high|medium|low|highest)\b/g;
while ((m = re.exec(src))) fix(m.index, m[0], 'Wrong token name', 'The family is <code>warn</code>, not <code>warning</code>. <code>--warning-color-*</code> is defined by nothing and silently falls through to your fallback.', m[0], '--warn-color-' + m[1]);
re = /--text-color(?![-\w])/g;
while ((m = re.exec(src))) fix(m.index, m[0], 'Wrong token name', '<code>--text-color</code> (no level) does not exist on the tier — only <code>-highest/-high/-medium/-low</code>.', '--text-color', '--text-color-high');
// 3. private tier reads
re = /--fs-([\w-]+)/g;
while ((m = re.exec(src))) {
if (FS_MAP['fs-' + m[1]]) fix(m.index, m[0], 'Reads the private tier', 'The <code>--fs-*</code> tier is internal, renamed at will, absent on every other theme. Only <code>--*-color-*</code> is a contract.', m[0], '--' + FS_MAP['fs-' + m[1]]);
else warn(m.index, m[0], 'Reads a private <code>--fs-*</code> with no colour equivalent', 'This is a private token (radius, z-index, spacing) with no public contract. Hardcode a value of your own, or drop the dependency on the theme.');
}
// 4. !important
re = /!important/g;
while ((m = re.exec(src))) fix(m.index, '!important', 'Needless <code>!important</code>', 'Your app CSS is unlayered and already beats every cascade layer the theme has — the flag was never what was winning. Remove it.', '!important', '');
// 5. absurd z-index
re = /z-index\s*:\s*(\d{5,})/gi;
while ((m = re.exec(src))) { if (+m[1] > 9999) fix(m.index, m[0], 'Runaway <code>z-index</code>', "A theme's own overlays can't coexist with <code>z-index: 2147483647</code>. Nothing you render needs to sit above everything ever.", m[0], 'z-index: 1000'); }
// 6. <font color>
re = /<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/gi;
while ((m = re.exec(src))) { var ft = classify('text', m[1]) || 'text-color-high'; fix(m.index, m[0], '<code>&lt;font color&gt;</code>', 'A hardcoded font colour vanishes on half the palettes. Use a tokenised element.', m[0], '<span style="color:var(--' + ft + ', ' + m[1].trim() + ')">'); }
// ---- manual: structural, per occurrence ----
re = /document\.head\.(?:appendChild|append|insertBefore)/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'CSS injected into <code>&lt;head&gt;</code> from JS', 'Under an SPA theme it outlives your view and restyles every later page. Return the <code>&lt;style&gt;</code> inside the tree your <code>render()</code> returns, or set <code>handleSaveApply=null</code> to drop stock buttons.');
re = /<head[\s>][\s\S]*?<(?:style|link\b[^>]*stylesheet)/gi;
while ((m = re.exec(src))) { warn(m.index, '<head>', 'A <code>&lt;style&gt;</code> / <code>&lt;link&gt;</code> in <code>&lt;head&gt;</code>', 'On a full reload it dies with the document; under an SPA theme it never does — it paints every later page. Return the CSS inside your view tree; never <code>&lt;link&gt;</code> an app stylesheet from <code>&lt;head&gt;</code>. (If this sheet is the theme’s or another app’s, it isn’t yours to fix.)'); if (re.lastIndex === m.index) re.lastIndex++; }
re = /:root\s*\{/g;
while ((m = re.exec(src))) warn(m.index, ':root', 'A <code>:root { … }</code> block', 'A shared global scope every app writes into. Declare your custom properties on <b>your</b> root element (<code>.myapp-root { --myapp-… }</code>), never <code>:root</code>.');
re = /(?:^|[^.\w#-])(\*\s*\{)/gm;
while ((m = re.exec(src))) warn(m.index + m[0].indexOf('*'), '*', 'A universal <code>*</code> reset', 'It flattens the theme’s own chrome. Never ship a document-wide reset.');
re = /\.(hidden|toast|skeleton|centered|dialog-title|flex-row|log-line|description)\b\s*\{/g;
while ((m = re.exec(src))) warn(m.index, '.' + m[1], 'Un-prefixed dictionary class', 'A live collision with the theme and every other app. Prefix it with your package name (<code>.myapp-' + m[1] + '</code>).');
re = /\b(?:window\.onload|DOMContentLoaded)\b/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'Runs once per document', 'Fires once and never again after the first SPA navigation. Do your work in <code>render()</code> instead.');
re = /document\.body\.(?:appendChild|append)\b/g;
while ((m = re.exec(src))) warn(m.index, m[0], 'Node appended to <code>document.body</code>', 'It survives the view swap and leaks onto the next page. Put it in your view tree.');
re = /@media[^{]*prefers-color-scheme/gi;
while ((m = re.exec(src))) warn(m.index, 'prefers-color-scheme', 'Wrong dark-mode signal', 'Reports the OS, not the theme. Read the colour tokens (they already follow the theme) or sniff <code>data-theme</code> / <code>data-bs-theme</code>.');
re = /\.setTheme\s*\(\s*["'](?:dracula|monokai|tomorrow[-_]?night\w*|twilight|cobalt|material)/gi;
while ((m = re.exec(src))) warn(m.index, m[0], 'Hardcoded editor theme', 'A permanently dark editor on a light page. Pick the editor theme from <code>isDark()</code>.');
re = /(?:^|[{}])\s*(\.cbi-[\w-]+|table\s+th|pre)\s*\{/gm;
while ((m = re.exec(src))) warn(m.index + m[0].indexOf(m[1]), m[1], 'Stock LuCI selector without a scope', 'An unlayered rule on <code>' + esc(m[1]) + '</code> beats every theme layer, on every page. Scope it under your own root: <code>.myapp-root ' + esc(m[1]) + ' { … }</code>.');
F.sort(function (a, b) { return a.line - b.line || (a.level === b.level ? 0 : a.level === 'fix' ? -1 : 1); });
return { findings: F, fixed: computeFixed(src) };
}
/* the whole-input rewrite, for "apply every fix & copy" — the same edits, applied at once */
function computeFixed(src) {
var out = src.replace(/([-\w]+)\s*:\s*([^;{}]+)/g, function (mm, prop, val) {
var kind = kindOf(prop); if (!kind || /var\(/.test(val)) return mm;
var cm = val.match(COLOR_RE); if (!cm) return mm;
var tok = classify(kind, cm[0]); if (!tok) return mm;
return prop + ': ' + val.replace(COLOR_RE, 'var(--' + tok + ', ' + cm[0].trim() + ')');
});
out = out.replace(/--warning-color-(high|medium|low|highest)\b/g, '--warn-color-$1')
.replace(/--text-color(?![-\w])/g, '--text-color-high')
.replace(/--fs-([\w-]+)/g, function (mm, name) { return FS_MAP['fs-' + name] ? '--' + FS_MAP['fs-' + name] : mm; })
.replace(/\s*!important/g, '')
.replace(/(z-index\s*:\s*)(\d{5,})/gi, function (mm, p, v) { return +v > 9999 ? p + '1000' : mm; })
.replace(/<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/gi, function (mm, col) { return '<span style="color:var(--' + (classify('text', col) || 'text-color-high') + ', ' + col.trim() + ')">'; })
.replace(/<\/font>/gi, '</span>');
return out;
}
var lastFixed = '';
function render(res) {
lastFixed = res.fixed;
var fixes = res.findings.filter(function (x) { return x.level === 'fix'; }).length,
warns = res.findings.length - fixes;
cnt.textContent = res.findings.length ? (res.findings.length + ' problem' + (res.findings.length > 1 ? 's' : '') + ' — ' + fixes + ' auto-fixable, ' + warns + ' manual') : '';
$('#dk-fix-applyall').hidden = !fixes;
if (!res.findings.length) { rep.innerHTML = '<div class="dk-report-empty">Nothing flagged — this already follows the rules. ✓</div>'; return; }
var CAP = 200, shown = res.findings.slice(0, CAP);
rep.innerHTML = '<div class="dk-report">' + shown.map(card).join('') +
(res.findings.length > CAP ? '<p style="color:var(--fs-dim);font-size:13px">…and ' + (res.findings.length - CAP) + ' more — fix these and re-check.</p>' : '') + '</div>';
}
function hl(text, mark) {
var L = esc(text.trim()), M = esc(String(mark).trim());
var i = M ? L.indexOf(M) : -1;
return i < 0 ? L : L.slice(0, i) + '<mark>' + M + '</mark>' + L.slice(i + M.length);
}
function card(x) {
var isFix = x.level === 'fix';
var h = '<div class="dk-diag ' + x.level + '">' +
'<div class="dk-diag-top"><span class="ln">line ' + x.line + '</span>' +
'<span class="tag">' + (isFix ? 'auto-fix' : 'manual') + '</span>' +
'<span class="ti">' + x.title + '</span></div>' +
'<pre class="dk-diag-code">' + hl(x.text, x.mark) + '</pre>';
if (isFix && x.after != null) {
h += '<div class="dk-diag-edit"><span class="was">' + (esc(x.before) || '∅') + '</span>' +
'<span class="arr">→</span>' +
'<code class="now" data-copy="' + escAttr(x.after) + '" title="click to copy the fix">' + (esc(x.after) || '(remove)') + '</code></div>';
}
return h + '<div class="dk-diag-why">' + x.why + '</div></div>';
}
var SAMPLE = [
'/* paste from DevTools — this example trips most rules */',
'.myapp-card {',
' background: #f7f7f7;',
' color: #333;',
' border: 1px solid #ddd !important;',
'}',
'.hidden { display: none }',
':root { --accent: #0d6efd; }',
'.log { background: #1e1e1e; color: #22c55e; }',
'.status { color: var(--warning-color-medium, orange); }',
'.brand { color: var(--fs-accent); border-radius: var(--fs-radius); }',
'.cbi-value { padding: 20px }',
'@media (prefers-color-scheme: dark) { .myapp-card { background: #222 } }',
'',
'// in the view module:',
'document.head.appendChild(style);',
'window.onload = init;',
'editor.setTheme("dracula");'
].join('\n');
$('#dk-fix-run').addEventListener('click', function () { render(run(inEl.value)); });
$('#dk-fix-sample').addEventListener('click', function () { inEl.value = SAMPLE; render(run(SAMPLE)); });
$('#dk-fix-clear').addEventListener('click', function () { inEl.value = ''; rep.innerHTML = ''; cnt.textContent = ''; $('#dk-fix-applyall').hidden = true; });
$('#dk-fix-applyall').addEventListener('click', function () { if (lastFixed) copy(lastFixed, 'copied the whole thing, fixed'); });
})();
/* ---- nav scroll-spy ---- */
var links = Array.prototype.slice.call(document.querySelectorAll('.dk-nav a'));
var secs = links.map(function (a) { return $(a.getAttribute('href')); });
var spy = new IntersectionObserver(function (ents) {
ents.forEach(function (en) {
if (!en.isIntersecting) return;
links.forEach(function (a) { a.classList.toggle('active', a.getAttribute('href') === '#' + en.target.id); });
});
}, { rootMargin: '-40% 0px -55% 0px' });
secs.forEach(function (s) { if (s) spy.observe(s); });
})();
</script>
</body>
</html>