build(web): generate the UI's Tailwind CSS with the pinned standalone CLI (#685)

Replaces the hand-written Tailwind subset in app.css with a real, purged
Tailwind build: scripts/build_css.py runs the pinned, SHA-256-checked
standalone Tailwind CLI (no Node), the generated tailwind.css and
plugin-frame.css are committed, and CI fails when they are stale. The Pi
never builds anything. The login page (#683) now links tailwind.css too,
and the load-order test covers every template that links app.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 09:38:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5ea0d511dc
commit ba6eccb489
23 changed files with 899 additions and 665 deletions
+129
View File
@@ -0,0 +1,129 @@
"""scripts/build_css.py: the pinned Tailwind CLI and what it builds.
The build itself needs the CLI download, so CI runs it in its own job
(`build_css.py --check`); these check the parts that must hold without it.
"""
import importlib.util
import re
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
spec = importlib.util.spec_from_file_location(
"build_css", PROJECT_ROOT / "scripts" / "build_css.py"
)
build_css = importlib.util.module_from_spec(spec)
spec.loader.exec_module(build_css)
def test_every_asset_is_pinned_to_a_sha256():
assert re.fullmatch(r"3\.\d+\.\d+", build_css.TAILWIND_VERSION)
assert build_css.TAILWIND_ASSETS
for name, digest in build_css.TAILWIND_ASSETS.items():
assert name.startswith("tailwindcss-"), name
assert re.fullmatch(r"[0-9a-f]{64}", digest), name
@pytest.mark.parametrize("system,machine,expected", [
("Linux", "x86_64", "tailwindcss-linux-x64"),
("Linux", "aarch64", "tailwindcss-linux-arm64"),
("Linux", "armv7l", "tailwindcss-linux-armv7"),
("Darwin", "arm64", "tailwindcss-macos-arm64"),
("Darwin", "x86_64", "tailwindcss-macos-x64"),
("Windows", "AMD64", "tailwindcss-windows-x64.exe"),
("Windows", "ARM64", "tailwindcss-windows-arm64.exe"),
])
def test_asset_name_maps_each_platform(monkeypatch, system, machine, expected):
monkeypatch.setattr(build_css.platform, "system", lambda: system)
monkeypatch.setattr(build_css.platform, "machine", lambda: machine)
assert build_css.asset_name() == expected
assert expected in build_css.TAILWIND_ASSETS
def test_unsupported_cpu_is_a_clear_error(monkeypatch):
monkeypatch.setattr(build_css.platform, "system", lambda: "Linux")
monkeypatch.setattr(build_css.platform, "machine", lambda: "armv6l")
with pytest.raises(SystemExit, match="armv6l"):
build_css.asset_name()
def test_every_build_input_exists_and_its_output_is_committed():
for input_css, config, output in build_css.BUILDS:
assert (PROJECT_ROOT / input_css).is_file(), input_css
assert (PROJECT_ROOT / config).is_file(), config
assert (PROJECT_ROOT / output).is_file(), output
def test_the_cli_is_fed_an_lf_copy_of_a_crlf_input(tmp_path, monkeypatch):
"""Tailwind's minifier merges rules differently when the input CSS has
CRLF line endings, so a Windows checkout built bytes CI's Linux build
didn't, and --check failed. The CLI must always see LF."""
monkeypatch.setattr(build_css, "PROJECT_ROOT", tmp_path)
(tmp_path / "in.css").write_bytes(b"@tailwind base;\r\n@tailwind utilities;\r\n")
seen = {}
def fake_run(cmd, **kwargs):
seen["input"] = Path(cmd[cmd.index("--input") + 1]).read_bytes()
Path(cmd[cmd.index("--output") + 1]).write_text(".a{b:c}", encoding="utf-8")
class Done:
returncode = 0
stdout = stderr = ""
return Done()
monkeypatch.setattr(build_css.subprocess, "run", fake_run)
work = tmp_path / "work"
work.mkdir()
out = tmp_path / "out.css"
build_css.run_build(Path("cli"), "in.css", "cfg.js", out, work)
assert seen["input"] == b"@tailwind base;\n@tailwind utilities;\n"
assert out.read_bytes() == b".a{b:c}\n"
assert (tmp_path / "in.css").read_bytes().count(b"\r\n") == 2 # source untouched
def test_a_corrupt_cached_cli_is_replaced(tmp_path, monkeypatch):
"""A cached binary that fails its hash is deleted and fetched again,
and the fresh download is hash-checked too."""
monkeypatch.setenv("LEDMATRIX_TAILWIND_CACHE", str(tmp_path))
name = build_css.asset_name()
cached = tmp_path / f"v{build_css.TAILWIND_VERSION}" / name
cached.parent.mkdir(parents=True)
cached.write_bytes(b"not the cli")
class FakeResponse:
def __init__(self, data):
self.data = data
def read(self, n=-1):
data, self.data = self.data, b""
return data
def __enter__(self):
return self
def __exit__(self, *exc):
return False
monkeypatch.setattr(build_css.urllib.request, "urlopen",
lambda url, timeout: FakeResponse(b"tampered"))
with pytest.raises(SystemExit, match="SHA-256 mismatch"):
build_css.ensure_cli()
assert not cached.exists()
assert not any(p.name.startswith(".download-") for p in cached.parent.iterdir())
def test_the_cli_is_only_downloaded_over_https(tmp_path, monkeypatch):
"""urlopen would also follow file:// and custom schemes; the download
refuses anything but https before it opens the URL."""
monkeypatch.setenv("LEDMATRIX_TAILWIND_CACHE", str(tmp_path))
monkeypatch.setattr(build_css, "DOWNLOAD_URL", "file:///etc/{version}/{asset}")
def fail(*args, **kwargs):
raise AssertionError("urlopen must not be called for a non-https URL")
monkeypatch.setattr(build_css.urllib.request, "urlopen", fail)
with pytest.raises(SystemExit, match="non-https"):
build_css.ensure_cli()
+14
View File
@@ -63,3 +63,17 @@ def test_web_ui_page_uses_the_ledmatrix_prefix_fallback(client, tmp_path):
assert response.status_code == 200
assert "radar panel" in response.get_data(as_text=True)
def test_web_ui_page_styles_come_from_the_pi_not_a_cdn(client, tmp_path):
"""In AP mode there is no internet; a CDN stylesheet left fragments unstyled."""
web_ui = tmp_path / "radar" / "web_ui"
web_ui.mkdir(parents=True)
(web_ui / "panel.html").write_text("<p>radar panel</p>", encoding="utf-8")
body = client.get("/plugin-ui/radar/web-ui/panel.html").get_data(as_text=True)
assert '<link rel="stylesheet" href="/static/v3/plugin-frame.css' in body
assert "cdnjs" not in body and "https://" not in body
assert (Path(module.__file__).resolve().parents[1]
/ "static" / "v3" / "plugin-frame.css").is_file()
+2
View File
@@ -144,6 +144,8 @@ def test_legacy_v3_alias_serves_the_same_partials(client, path):
STATIC_ASSETS = [
"/static/v3/app.css",
"/static/v3/tailwind.css",
"/static/v3/plugin-frame.css",
"/static/v3/app.js",
"/static/v3/manifest.json",
"/static/v3/icons/icon-192.png",
+92 -32
View File
@@ -1,11 +1,16 @@
"""
Static-analysis audits for the web UI, as tests so CI enforces them.
1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility
subset, so a template can reference a responsive class (e.g. sm:block)
that no CSS rule defines — it silently no-ops. This once left the header
search box and system stats invisible at every screen width. The audit
diffs classes used in templates against classes defined in app.css.
1. Utility class audits: templates and JS are written in Tailwind class names,
and a class no stylesheet defines silently does nothing. `.hidden` was
missing for years, which broke every JS show/hide toggle, and a
hand-written selector starting `.2xl` (an unescaped leading digit) was
invalid CSS, so no 2xl: class ever applied. The utilities are now
generated (scripts/build_css.py writes static/v3/tailwind.css), and these
tests check that the committed output plus app.css cover every class the
markup uses. That catches a class assembled at runtime that needs a
safelist entry in web_interface/tailwind/tailwind.config.js. (A template
edited without rebuilding is caught by `build_css.py --check` in CI.)
2. Asset reference audit: every url_for('static', filename=...) in the
templates must point to a file that exists, so a renamed/moved asset
@@ -24,6 +29,7 @@ WEB = PROJECT_ROOT / "web_interface"
TEMPLATES = WEB / "templates"
STATIC = WEB / "static"
APP_CSS = STATIC / "v3" / "app.css"
TAILWIND_CSS = STATIC / "v3" / "tailwind.css"
BP_PREFIXES = ("sm", "md", "lg", "xl", "2xl")
@@ -32,6 +38,44 @@ def _template_files():
return sorted(TEMPLATES.rglob("*.html"))
def _stylesheets():
"""The main UI's CSS as base.html loads it: generated utilities, then app.css."""
return "\n".join(
p.read_text(encoding="utf-8") for p in (TAILWIND_CSS, APP_CSS)
)
def _css_unescape(name):
r"""`\32xl\:px-16` -> `2xl:px-16` (CSS hex and character escapes)."""
name = re.sub(
r"\\([0-9a-fA-F]{1,6}) ?", lambda m: chr(int(m.group(1), 16)), name
)
return re.sub(r"\\(.)", r"\1", name)
def _css_light_classes(css):
"""Classes that get a rule outside [data-theme="dark"]."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
defined = set()
for block in re.finditer(r"([^{}]+)\{", css):
for sel in block.group(1).split(","):
sel = sel.strip()
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
continue
for m in re.finditer(r"\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[\w-])+)", sel):
defined.add(_css_unescape(m.group(1)))
return defined
def test_css_unescape_reads_tailwind_selectors():
assert _css_unescape(r"\32xl\:px-16") == "2xl:px-16"
assert _css_unescape(r"md\:grid-cols-2") == "md:grid-cols-2"
assert _css_unescape(r"w-1\/2") == "w-1/2"
assert "2xl:grid-cols-5" in _css_light_classes(
r"@media (min-width:1536px){.\32xl\:grid-cols-5{grid-template-columns:1fr}}"
)
def test_every_used_breakpoint_class_is_defined():
used = set()
class_attr = re.compile(r'class="([^"]*)"')
@@ -41,18 +85,16 @@ def test_every_used_breakpoint_class_is_defined():
for m in bp_class.finditer(attr):
used.add(m.group(0))
css = APP_CSS.read_text(encoding="utf-8")
defined = {
m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer(
r"\.(%s)\\:[A-Za-z0-9_-]+" % "|".join(BP_PREFIXES), css
)
cls for cls in _css_light_classes(_stylesheets())
if cls.split(":", 1)[0] in BP_PREFIXES
}
missing = sorted(used - defined)
assert not missing, (
"Responsive utility classes referenced in templates but never defined "
f"in app.css (they silently no-op): {missing}"
"Responsive utility classes referenced in templates but not in "
"tailwind.css or app.css (they silently no-op). Run "
f"`python3 scripts/build_css.py`: {missing}"
)
@@ -82,24 +124,12 @@ _UTILITY = re.compile(
)
def _css_light_classes(css):
"""Classes that get a rule outside [data-theme="dark"]."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
defined = set()
for block in re.finditer(r"([^{}]+)\{", css):
for sel in block.group(1).split(","):
sel = sel.strip()
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
continue
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
defined.add(m.group(1).replace("\\", ""))
return defined
def test_every_used_utility_class_is_defined():
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
it doesn't define silently does nothing. `.hidden` was missing for years,
which broke every JS show/hide toggle. Scans templates and static JS."""
"""A utility class neither tailwind.css nor app.css defines silently does
nothing. Scans templates and static JS the way Tailwind can't: it also
reads classList calls and `${...}`-stripped template strings, so a class
only ever built at runtime shows up here as missing until it's safelisted.
"""
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
@@ -125,14 +155,44 @@ def test_every_used_utility_class_is_defined():
if _UTILITY.match(token):
used.setdefault(token, path.relative_to(PROJECT_ROOT))
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
defined = _css_light_classes(_stylesheets())
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
assert not missing, (
"Utility classes used in templates/JS but not defined in app.css "
f"(they silently no-op): {missing}"
"Utility classes used in templates/JS but not in tailwind.css or "
"app.css (they silently no-op). Run `python3 scripts/build_css.py`; if "
"the class is assembled at runtime, safelist it in "
f"web_interface/tailwind/tailwind.config.js: {missing}"
)
def test_runtime_built_colour_classes_are_generated():
"""tools.html builds `bg-${color}-50` and friends from green/red/yellow;
the scanner can't see those, so they only exist through the safelist."""
defined = _css_light_classes(TAILWIND_CSS.read_text(encoding="utf-8"))
for color in ("green", "red", "yellow"):
for cls in (f"bg-{color}-50", f"bg-{color}-100", f"border-{color}-200",
f"text-{color}-600", f"text-{color}-700", f"text-{color}-800"):
assert cls in defined, cls
def test_tailwind_loads_before_app_css():
"""app.css overrides utilities of equal specificity (components, dark
theme), which only works if it comes second. Every page that links
app.css needs tailwind.css too: app.css no longer holds the utilities, so
a standalone page (login.html) without it renders unstyled."""
pages = []
for path in _template_files():
text = path.read_text(encoding="utf-8")
app = text.find("filename='v3/app.css'")
if app == -1:
continue
pages.append(path.name)
tw = text.find("filename='v3/tailwind.css'")
assert tw != -1, f"{path.relative_to(PROJECT_ROOT)} links app.css but not tailwind.css"
assert tw < app, f"{path.relative_to(PROJECT_ROOT)} links tailwind.css after app.css"
assert "base.html" in pages
def test_every_static_url_for_points_to_a_real_file():
ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
+1
View File
@@ -28,6 +28,7 @@ def client(monkeypatch):
@pytest.mark.parametrize("url,path", [
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
("/static/v3/app.css", "v3/app.css"),
("/static/v3/tailwind.css", "v3/tailwind.css"),
])
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})