Status:🤷 UNKNOWN
Date:📅 unknown
Decision Makers:unknown
ADR-0007: Frontend styling — Tailwind CSS v4 + daisyUI via the standalone CLI
- Status: Accepted (visual choices superseded in part by 📝 ADR-0012 — the stock
dim/winterthemes and the chat-bubble transcript are replaced by the slate theme + dense-log transcript; the framework choice, Tailwind + daisyUI via the standalone CLI, stands) - Date: 2026-06-27
- Builds on: 📝 ADR-0006 (server-rendered UI, no runtime npm, strict CSP)
Context
The HTMX UI (📝 ADR-0006) needs styling: a component
vocabulary (cards, menus, chat bubbles, drawers, stats), a light/dark theme
toggle, and icons — all under a style-src 'self' / script-src 'self' CSP with
no CDN and no Node at runtime. Tailwind + daisyUI is the desired look, but
the canonical way to run them is a PostCSS/npm pipeline, which we explicitly do
not want in the runtime container or the request path.
Decision
Use Tailwind CSS v4 + daisyUI, but build with the Tailwind standalone CLI
(a single binary, no Node), commit the built app.css, and serve it via
go:embed.
- The built stylesheet is committed and embedded.
make csscompilesinternal/web/tailwind/input.csstointernal/web/static/app.css(minified); that output is committed and served from the//go:embed static/*set. The runtime needs no toolchain — only the file. - The toolchain is dev-time and gitignored.
make cssdownloads the Tailwind standalone CLI and the daisyUI npm tarball into.tools/(never committed;make clean-toolsremoves it). Versions are pinned in the Makefile:TAILWIND_VERSION := v4.3.1,DAISYUI_VERSION := 5.6.3. The Makefile mapsuname→ the correct Tailwind release asset (linux/macos × x64/arm64). - daisyUI is loaded as a Tailwind v4 plugin from
.tools/.input.csshas@plugin "../../../.tools/daisyui/package/index.js" { themes: dim --default, winter; }and@source "../templates"for v4 content detection. - Theme toggle is self-hosted JS.
static/theme.jsswitchesdata-themebetweendim(dark, default) andwinter(light) and persists tolocalStorage; it is loaded non-deferred in<head>so the saved theme applies before first paint (no FOUC). It runs underscript-src 'self'. - Icons are inline Hero Icons SVG.
templates/icons.htmlvendors Hero Icons (MIT) as inline{{define "icon-*"}}SVG blocks — no icon font, no JS, no external fetch, so the CSP holds. - Dynamic classes are safelisted. Avatar background classes are chosen in Go
(
avatarColorinrender.go), so Tailwind's content scan never sees them literally.input.cssforce-includes them with@source inline("bg-rose-500 bg-orange-500 … bg-fuchsia-500"), kept in sync with theavatarPaletteslice. - CI guards against stylesheet drift.
.github/workflows/css.ymlrunsmake csswhen templates /tailwind/**/app.css/ the Makefile change and fails if the committedapp.cssdiffers — so an edit to a template that adds a class can't ship without a rebuilt stylesheet. The path filter keeps the ~100 MB Tailwind binary download off unrelated PRs.
Why these choices
- Standalone CLI over PostCSS/npm: it is a single pinned binary that produces
the identical Tailwind v4 output without a
package.json,node_modules, or a Node runtime — exactly the "no Node at runtime" property we want, while still using upstream Tailwind/daisyUI. - Commit the built CSS: the runtime serves one static, embedded file; there is
no build step between a
git cloneand a working UI, and the CSP needs onlystyle-src 'self'. .tools/gitignored, versions in the Makefile: the heavy, platform-specific binaries don't belong in git, but the versions are pinned and visible so the build is reproducible.@source inline(...)over disabling content-purge: keeps the purge on (small CSS) while explicitly safelisting the one set of classes selected at runtime in Go — the comment inrender.goand the directive ininput.cssdocument the coupling.- Inline SVG icons over an icon font/sprite CDN: no extra request, no font
egress, CSP-clean, and each icon scales with
size-[1.15em].
Consequences
Positive
- Runtime is toolchain-free: one committed, embedded, minified
app.css. - Full daisyUI component set and a real light/dark toggle, all same-origin under a strict CSP — no CDN, no external fonts.
- CI catches a stale
app.css, so the committed stylesheet can't silently drift from the templates.
Negative
- A dev-time toolchain exists — editing UI classes requires running
make css(and committing the result) before CI passes. This is the deliberate trade: developer-time complexity in exchange for runtime simplicity and a strict CSP. - The avatar safelist in
input.cssmust be kept in sync withavatarPalette; adding a color in Go without updating the@source inline(...)list would purge it from the build. make cssis platform-specific (downloads the matching Tailwind asset) and needs network access the first time.
Operational
- The css workflow downloads the Tailwind binary, so it is scoped by path filter to UI-touching changes only.
- Bumping
TAILWIND_VERSION/DAISYUI_VERSIONis a Makefile edit followed bymake css+ commit; the CI guard will flag the regeneratedapp.css.
Alternatives considered
- Tailwind via PostCSS/npm in CI. Rejected: pulls a Node toolchain and
node_modulesinto the build for output a single pinned binary produces identically. - A CDN
<link>to Tailwind/daisyUI. Rejected: violatesstyle-src 'self'and the no-off-device-fetch rule, and breaks offline/local-only use. - Hand-written CSS, no framework. Rejected: reimplements daisyUI's components
and theming by hand for no benefit; a small amount of bespoke CSS in
input.csscovers only what daisyUI doesn't (thumbnails,:targetlightbox, flash animation). - An icon font or JS icon library. Rejected: extra asset/egress and CSP friction; inline SVG is zero-dependency.
References
Makefile(csstarget,TAILWIND_VERSION,DAISYUI_VERSION,.tools/)internal/web/tailwind/input.css(@plugin, themes,@source inline)internal/web/static/app.css(committed build),static/theme.jsinternal/web/templates/icons.html(inline Hero Icons),render.go(avatarPalette).github/workflows/css.yml(CSS-drift guard)- 📝 ADR-0006: Web stack — HTMX