Background
The dashboard currently loads Tailwind via the Play CDN runtime (https://cdn.tailwindcss.com) — the same runtime regardless of which asset mode is enabled:
| Asset mode |
Tailwind source |
Production (default, USE_LOCAL_ASSETS=false) |
<script src="https://cdn.tailwindcss.com"> |
Local-assets (USE_LOCAL_ASSETS=true) |
<script src="/static/js/vendor/tailwind.js"> — a curl-downloaded copy of the same Play CDN script (see Makefile dev-assets target line 108) |
Both run the full Tailwind engine in the browser and JIT-compile utility classes from a runtime HTML scan. Tailwind itself prints this warning on every page load:
cdn.tailwindcss.com should not be used in production. To use Tailwind CSS in
production, install it as a PostCSS plugin or use the Tailwind CLI:
https://tailwindcss.com/docs/installation
Why this matters
- Performance — ~280 KB of Tailwind engine is downloaded and parsed on every cold visit. Every navigation re-runs the JIT compiler against the HTML.
- Caching — there's no static CSS file to cache. A user who navigates between five gear pages re-runs the JIT compiler five times.
- Production hygiene — the explicit upstream warning is exactly the kind of thing someone deploying the dashboard to a non-homelab environment will notice and flag.
- CSP surface — we currently have
script-src https://cdn.tailwindcss.com in production CSP. Dropping the runtime drops that allowance.
- Class purging — a proper build only emits the utilities actually used. The runtime emits everything that gets JIT-compiled, which is functionally similar but bound to the runtime's correctness.
Proposed approach
Move to the Tailwind CLI (or PostCSS) build, generating a static minified CSS file served from /static/css/.
Concrete steps
-
Add npm tooling under gearbox/ (this is the only Node dependency we'd need — keep its blast radius small):
npm init -y
npm install -D tailwindcss@latest
gearbox/package.json and gearbox/package-lock.json enter the tree. No runtime npm dependencies — Tailwind CLI is dev-only.
-
Create gearbox/tailwind.config.js with content paths pointing at every file that uses Tailwind classes:
module.exports = {
content: [
'./internal/**/*.templ',
'./internal/**/*.go', // catch any class names embedded in Go (rare but possible)
'./static/**/*.{js,html}',
],
darkMode: 'class', // matches the current inline tailwind.config in base.templ
theme: { extend: { /* lift from current inline config */ } },
};
The existing inline tailwind.config = { darkMode: 'class', ... } JS block in base.templ:233-264 ports to this file.
-
Create the source CSS: gearbox/static/css/tailwind.src.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
-
Add a Makefile target:
tailwind-build: ## Compile Tailwind CSS
@npx tailwindcss -i static/css/tailwind.src.css -o static/css/tailwind.css --minify
Chain it into build and dev so a clean make build always produces a fresh CSS bundle.
-
Update every template that loads Tailwind to use the bundled file. Eight files known today:
internal/framework/templates/layouts/base.templ
internal/framework/templates/pages/user_pages.templ (4 occurrences)
internal/framework/templates/pages/welcome.templ
internal/framework/templates/pages/login.templ
internal/framework/templates/pages/haproxy_settings.templ
Replace:
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class', ... }</script>
with:
<link rel="stylesheet" href="/static/css/tailwind.css">
And delete the inline tailwind.config = {...} blocks (config now lives in tailwind.config.js).
-
CSP cleanup — drop https://cdn.tailwindcss.com from script-src in internal/framework/middleware/security_headers.go. Strict-CSP mode (already 'self'-only) becomes the consistent default rather than an opt-in.
-
Delete gearbox/static/js/vendor/tailwind.js (407 KB) and the dev-assets Makefile snippet that downloads it — the local-assets mode now uses the same bundled CSS as production.
-
CI — add make tailwind-build to the GitHub Actions pipeline so tailwind.css is regenerated on every build rather than committed. Or commit the generated file with a CI check that it matches what a fresh build would produce; pick one and document the choice.
Migration concerns
-
Class scanning — Tailwind's JIT runtime detects classes by parsing the live HTML. The CLI scans static files. Any class name dynamically assembled in JS (e.g. 'kpi-card status-' + status) won't be found unless the prefix or the full set is in a safelist: config or appears verbatim somewhere in the scanned content. Existing JS-side class assembly to audit (rough survey):
metrics.templ: 'kpi-card status-' + status — needs safelist: ['status-good', 'status-warn', 'status-bad'] or similar.
- Sidebar / chip / KPI badges with conditional classes — most use static class strings, but a sweep through
static/js/ and the <script> blocks in templ files is needed during the migration.
-
Custom utilities / theme tokens — the inline tailwind.config = {...} in base.templ defines darkMode: 'class' and (per the inline script) probably some extends. All of those need to move to tailwind.config.js. Audit step before the migration starts.
-
Build order — make build currently goes templ generate → go build. Tailwind needs to run between templ generate and go build so it can scan the generated *_templ.go if class names happen to live in Go strings — though scanning *.templ directly is sufficient for the common case.
-
Dev workflow — make dev (hot reload with air) needs to also rebuild Tailwind on *.templ changes. Either air calls make tailwind-build as part of its rebuild command, or run a separate npx tailwindcss --watch alongside.
Out of scope
- Switching to a different CSS framework.
- Inlining critical CSS for first paint optimisation.
- Removing inline
<style> blocks in templ files (they currently use Tailwind syntax and the CSS embedded by templ; some of those would simplify if we ran them through Tailwind's CLI too, but that's a separate clean-up).
Definition of done
npx tailwindcss builds a static CSS file from source.
- Every templ that previously loaded the CDN runtime loads the bundled CSS instead.
- The
cdn.tailwindcss.com should not be used in production warning is gone from the browser console.
script-src https://cdn.tailwindcss.com removed from production CSP.
gearbox/static/js/vendor/tailwind.js (407 KB) deleted along with the dev-assets download step for it.
make build produces a working dashboard with no Tailwind runtime loaded.
- Existing pages render unchanged (visual diff against
main).
References
Background
The dashboard currently loads Tailwind via the Play CDN runtime (
https://cdn.tailwindcss.com) — the same runtime regardless of which asset mode is enabled:USE_LOCAL_ASSETS=false)<script src="https://cdn.tailwindcss.com">USE_LOCAL_ASSETS=true)<script src="/static/js/vendor/tailwind.js">— acurl-downloaded copy of the same Play CDN script (seeMakefiledev-assetstarget line 108)Both run the full Tailwind engine in the browser and JIT-compile utility classes from a runtime HTML scan. Tailwind itself prints this warning on every page load:
Why this matters
script-src https://cdn.tailwindcss.comin production CSP. Dropping the runtime drops that allowance.Proposed approach
Move to the Tailwind CLI (or PostCSS) build, generating a static minified CSS file served from
/static/css/.Concrete steps
Add npm tooling under
gearbox/(this is the only Node dependency we'd need — keep its blast radius small):gearbox/package.jsonandgearbox/package-lock.jsonenter the tree. No runtime npm dependencies — Tailwind CLI is dev-only.Create
gearbox/tailwind.config.jswith content paths pointing at every file that uses Tailwind classes:The existing inline
tailwind.config = { darkMode: 'class', ... }JS block in base.templ:233-264 ports to this file.Create the source CSS:
gearbox/static/css/tailwind.src.css:Add a Makefile target:
Chain it into
buildanddevso a cleanmake buildalways produces a fresh CSS bundle.Update every template that loads Tailwind to use the bundled file. Eight files known today:
internal/framework/templates/layouts/base.templinternal/framework/templates/pages/user_pages.templ(4 occurrences)internal/framework/templates/pages/welcome.templinternal/framework/templates/pages/login.templinternal/framework/templates/pages/haproxy_settings.templReplace:
with:
And delete the inline
tailwind.config = {...}blocks (config now lives intailwind.config.js).CSP cleanup — drop
https://cdn.tailwindcss.comfromscript-srcininternal/framework/middleware/security_headers.go. Strict-CSP mode (already'self'-only) becomes the consistent default rather than an opt-in.Delete
gearbox/static/js/vendor/tailwind.js(407 KB) and thedev-assetsMakefile snippet that downloads it — the local-assets mode now uses the same bundled CSS as production.CI — add
make tailwind-buildto the GitHub Actions pipeline sotailwind.cssis regenerated on every build rather than committed. Or commit the generated file with a CI check that it matches what a fresh build would produce; pick one and document the choice.Migration concerns
Class scanning — Tailwind's JIT runtime detects classes by parsing the live HTML. The CLI scans static files. Any class name dynamically assembled in JS (e.g.
'kpi-card status-' + status) won't be found unless the prefix or the full set is in asafelist:config or appears verbatim somewhere in the scanned content. Existing JS-side class assembly to audit (rough survey):metrics.templ:'kpi-card status-' + status— needssafelist: ['status-good', 'status-warn', 'status-bad']or similar.static/js/and the<script>blocks in templ files is needed during the migration.Custom utilities / theme tokens — the inline
tailwind.config = {...}inbase.templdefinesdarkMode: 'class'and (per the inline script) probably some extends. All of those need to move totailwind.config.js. Audit step before the migration starts.Build order —
make buildcurrently goestempl generate→go build. Tailwind needs to run betweentempl generateandgo buildso it can scan the generated*_templ.goif class names happen to live in Go strings — though scanning*.templdirectly is sufficient for the common case.Dev workflow —
make dev(hot reload withair) needs to also rebuild Tailwind on*.templchanges. Eitheraircallsmake tailwind-buildas part of its rebuild command, or run a separatenpx tailwindcss --watchalongside.Out of scope
<style>blocks in templ files (they currently use Tailwind syntax and the CSS embedded by templ; some of those would simplify if we ran them through Tailwind's CLI too, but that's a separate clean-up).Definition of done
npx tailwindcssbuilds a static CSS file from source.cdn.tailwindcss.com should not be used in productionwarning is gone from the browser console.script-src https://cdn.tailwindcss.comremoved from production CSP.gearbox/static/js/vendor/tailwind.js(407 KB) deleted along with thedev-assetsdownload step for it.make buildproduces a working dashboard with no Tailwind runtime loaded.main).References
cdn.tailwindcss.comon every page load.