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
+4
View File
@@ -6,3 +6,7 @@
# and systemd rejects CRLF unit files.
*.sh text eol=lf
*.service text eol=lf
# Generated by scripts/build_css.py; collapsed in diffs, not hand-edited.
web_interface/static/v3/tailwind.css linguist-generated=true
web_interface/static/v3/plugin-frame.css linguist-generated=true
+19
View File
@@ -113,6 +113,25 @@ jobs:
REQUIRE_DOM: "1"
run: node test/js/run_all.js
css-build:
name: Tailwind CSS is up to date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
# Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no
# Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the
# templates and JS, and fails if the committed files differ. Fix a
# failure by running `python3 scripts/build_css.py` and committing.
- name: Check the committed CSS matches a fresh build
run: python scripts/build_css.py --check
type-check:
name: Type check (mypy ratchet)
runs-on: ubuntu-latest
+30
View File
@@ -228,6 +228,36 @@ read any of them:
release at or below `src.__version__`, so a release can no longer ship
warning about a removal it has already passed.
### Web UI styling: a real Tailwind build
- The web UI's utility classes now come from a generated
`static/v3/tailwind.css` (Tailwind v3.4.19 standalone CLI, no Node)
instead of ~500 hand-written rules in `app.css`. The CSS is built on a
dev machine with `python3 scripts/build_css.py` and committed; the Pi
never builds anything. CI's new "Tailwind CSS is up to date" job rebuilds
it and fails when the committed file is stale. `app.css` keeps the theme
tokens, components and dark theme, and loads after `tailwind.css`. The
values `app.css` had customised (darker gray text, emerald/amber button
fills, token shadows, font line-heights, keyboard-only focus rings) are
kept in `web_interface/tailwind/tailwind.config.js`.
- Border utilities now draw. `border-b`, `border-t` and `divide-y` set only
a width, and nothing gave them a style, so the tab-row underlines and
section dividers the markup asks for never showed. They do now.
- `2xl:` classes now apply (the hand-written `.2xl\:…` selectors were
invalid CSS): at 1536px and wider the plugin grids show five columns and
the page gutters widen, as the markup intended.
- Classes the hand-written file never defined now work, e.g. the teal
"configure" badge in Operation History, the button of a purple
`web_ui_actions` card (it had white text on no background), the
toggle-switch knob offsets, the slider accent colours and the password
strength colours.
- A scrollable container with its own background (the live preview stage,
command output in Tools) keeps it. The scroll-hint rule's `background`
shorthand wiped it, so the preview stage rendered white instead of dark.
- Plugin `web_ui/` pages no longer load Tailwind from a CDN, which failed
in AP mode with no internet. They get a local `static/v3/plugin-frame.css`
with the v2 palette they were written against.
## 3.7.0
Sports consolidation stage 3 (#672). No behaviour change: nothing in core
+5 -1
View File
@@ -71,7 +71,11 @@ integration tests.
annotation-only where you can -- widen a hint rather than delete a
defensive runtime check mypy calls unreachable. HTML/JS in
`web_interface/` follows the patterns already in `templates/v3/`
and `static/v3/`.
and `static/v3/`. If you change a template or a static JS file,
run `python3 scripts/build_css.py` and commit the regenerated
`static/v3/tailwind.css` with it -- CI fails when the committed CSS
is out of date. It needs no Node; see
[`web_interface/README.md`](web_interface/README.md#styling-tailwind-css).
5. **Update documentation** alongside code changes. If you add a
config key, document it in the relevant `*.md` file (or, for
plugins, in `config_schema.json` so the form is auto-generated).
+3 -1
View File
@@ -482,7 +482,9 @@ The web interface uses modern web technologies:
- **Backend:** Flask with Blueprint-based modular design
- **Frontend:** HTMX for dynamic content, Alpine.js for reactive components
- **Styling:** Tailwind CSS for responsive design
- **Styling:** Tailwind CSS utilities, generated at development time and
committed (the Pi never builds CSS; see
[`web_interface/README.md`](../web_interface/README.md#styling-tailwind-css))
- **Real-Time:** Server-Sent Events (SSE) for live updates
### File Locations
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Build the web UI's Tailwind CSS with the pinned standalone Tailwind CLI.
The generated files are committed, so the Pi never builds anything. Run this
on a dev machine (or let CI run it) after changing a template, a static JS
file, or anything under ``web_interface/tailwind/``:
python3 scripts/build_css.py # rebuild the committed CSS
python3 scripts/build_css.py --check # exit 1 if the committed CSS is stale
No Node or npm: the script downloads Tailwind's standalone CLI (a single
executable) for this OS and CPU from the Tailwind GitHub release, checks it
against the SHA-256 pinned below, and caches it outside the repo
(``$LEDMATRIX_TAILWIND_CACHE``, else the per-user cache directory).
Outputs (see ``BUILDS``):
- ``web_interface/static/v3/tailwind.css``: the utilities the templates and
static JS use. Linked before ``app.css`` in ``base.html``.
- ``web_interface/static/v3/plugin-frame.css``: preflight plus a broad set of
common utilities, for plugin ``web_ui/`` fragments served in an iframe.
Their markup lives in plugin repos, so it can't be scanned; the safelist in
``plugin-frame.config.js`` stands in for it.
To move to a new Tailwind v3 release, change ``TAILWIND_VERSION`` and every
hash in ``TAILWIND_ASSETS`` (the release's ``sha256sums.txt``, or the digests
from ``gh api repos/tailwindlabs/tailwindcss/releases/tags/<tag>``), rebuild,
and review the diff of the generated CSS.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import platform
import shutil
import stat
import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
import sys
import tempfile
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TAILWIND_DIR = PROJECT_ROOT / "web_interface" / "tailwind"
STATIC_V3 = PROJECT_ROOT / "web_interface" / "static" / "v3"
TAILWIND_VERSION = "3.4.19"
# asset name -> SHA-256, from the v3.4.19 release.
TAILWIND_ASSETS = {
"tailwindcss-linux-arm64": "e5b2d27694daa80cc52ec29553ba2c6bd43d86bd51a9d633ed24058b9c05a676",
"tailwindcss-linux-armv7": "e3610b109a64720295e1c00a18dd2d6d79d3cddc618219aa0830de97a55429a4",
"tailwindcss-linux-x64": "4af3198c015616ea7d6617974ec3d70d987ecc00c1ca8463b0a30fd65cc7c06e",
"tailwindcss-macos-arm64": "7fdeb00818b6214a337383063282b2361ecb08bbc08f8c8a7ba97ee1e2eaa4fe",
"tailwindcss-macos-x64": "a597f407e0f1f03535731f5b42f1576a8152cb5fffc2f38e754722bc0c280045",
"tailwindcss-windows-arm64.exe": "f2b6b999747aa0ae31999d59db117b1ba1e4e15e17675d7108e30aac4b680686",
"tailwindcss-windows-x64.exe": "a15158c4c5e0e7a75f7229bfe4986fe7710d2edc468b6f96c8981f78ab211347",
}
DOWNLOAD_URL = (
"https://github.com/tailwindlabs/tailwindcss/releases/download/v{version}/{asset}"
)
# (input CSS, config, output) -- all relative to the project root.
BUILDS = (
(
"web_interface/tailwind/app.input.css",
"web_interface/tailwind/tailwind.config.js",
"web_interface/static/v3/tailwind.css",
),
(
"web_interface/tailwind/plugin-frame.input.css",
"web_interface/tailwind/plugin-frame.config.js",
"web_interface/static/v3/plugin-frame.css",
),
)
def asset_name() -> str:
"""The release asset for this OS and CPU."""
system = platform.system()
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
arch = "x64"
elif machine in ("aarch64", "arm64"):
arch = "arm64"
elif machine.startswith("armv7") or machine == "armv8l":
arch = "armv7"
else:
raise SystemExit(f"No standalone Tailwind CLI for CPU {machine!r}.")
if system == "Linux":
name = f"tailwindcss-linux-{arch}"
elif system == "Darwin":
name = f"tailwindcss-macos-{arch}"
elif system == "Windows":
name = f"tailwindcss-windows-{arch}.exe"
else:
raise SystemExit(f"No standalone Tailwind CLI for {system!r}.")
if name not in TAILWIND_ASSETS:
raise SystemExit(f"No standalone Tailwind CLI for {system} {machine}.")
return name
def cache_dir() -> Path:
override = os.environ.get("LEDMATRIX_TAILWIND_CACHE")
if override:
return Path(override)
if platform.system() == "Windows":
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
elif platform.system() == "Darwin":
base = Path.home() / "Library" / "Caches"
else:
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
return base / "ledmatrix" / "tailwindcss"
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def ensure_cli() -> Path:
"""Path to the verified CLI, downloading it on first use."""
name = asset_name()
expected = TAILWIND_ASSETS[name]
target = cache_dir() / f"v{TAILWIND_VERSION}" / name
if target.is_file():
if sha256_of(target) == expected:
return target
print(f"Cached {target} fails its SHA-256 check; downloading it again.")
target.unlink()
target.parent.mkdir(parents=True, exist_ok=True)
url = DOWNLOAD_URL.format(version=TAILWIND_VERSION, asset=name)
if not url.startswith("https://"):
raise SystemExit(f"Refusing to download the Tailwind CLI over a non-https URL: {url}")
print(f"Downloading Tailwind CLI v{TAILWIND_VERSION} ({name})...")
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=".download-")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "wb") as out, urllib.request.urlopen(url, timeout=120) as resp: # nosec B310 - https only, checked above
shutil.copyfileobj(resp, out)
actual = sha256_of(tmp)
if actual != expected:
raise SystemExit(
f"SHA-256 mismatch for {url}\n expected {expected}\n got {actual}"
)
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
os.replace(tmp, target)
finally:
if tmp.exists():
tmp.unlink()
return target
def run_build(
cli: Path, input_css: str, config: str, output: Path, work_dir: Path
) -> None:
# The minifier's rule merging depends on the input file's line endings,
# so a Windows checkout (core.autocrlf, CRLF) would build different bytes
# than CI's Linux one and --check would fail. Feed the CLI an LF copy.
# (Content files' line endings don't matter; @import isn't used, so the
# copy's location doesn't either.)
lf_input = work_dir / (Path(input_css).name)
lf_input.write_bytes(
(PROJECT_ROOT / input_css).read_bytes().replace(b"\r\n", b"\n")
)
cmd = [
str(cli),
"--input", str(lf_input),
"--config", str(PROJECT_ROOT / config),
"--output", str(output),
"--minify",
]
# NODE_ENV=production and no browserslist lookup keep the output the
# same on every machine.
env = dict(os.environ, NODE_ENV="production", BROWSERSLIST_IGNORE_OLD_DATA="1")
# The CLI path is computed here (cache dir + pinned asset name) and the
# binary was SHA-256-verified by ensure_cli(); env is os.environ plus two
# fixed values.
result = subprocess.run(cmd, cwd=PROJECT_ROOT, env=env, capture_output=True, text=True) # nosec B603 - list-form argv, no shell # nosemgrep
if result.returncode != 0:
sys.stderr.write(result.stdout + result.stderr)
raise SystemExit(f"Tailwind build failed for {input_css}")
# The CLI writes without a trailing newline; add one so the committed
# file is a well-formed text file and editors leave it alone.
text = output.read_text(encoding="utf-8").replace("\r\n", "\n")
if not text.endswith("\n"):
text += "\n"
output.write_bytes(text.encode("utf-8"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--check",
action="store_true",
help="build to a temp dir and fail if the committed CSS differs",
)
args = parser.parse_args(argv)
cli = ensure_cli()
stale = []
with tempfile.TemporaryDirectory(prefix="ledmatrix-css-") as tmp:
for input_css, config, output in BUILDS:
committed = PROJECT_ROOT / output
built = Path(tmp) / Path(output).name if args.check else committed
run_build(cli, input_css, config, built, Path(tmp))
if args.check:
old = (
committed.read_bytes().replace(b"\r\n", b"\n")
if committed.is_file()
else None
)
if old != built.read_bytes():
stale.append(output)
else:
print(f"Wrote {output} ({committed.stat().st_size:,} bytes)")
if stale:
print(
"The committed CSS is out of date: " + ", ".join(stale) + "\n"
"Run `python3 scripts/build_css.py` and commit the result."
)
return 1
if args.check:
print("Committed CSS is up to date.")
return 0
if __name__ == "__main__":
sys.exit(main())
+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"})
+49 -1
View File
@@ -23,13 +23,17 @@ web_interface/
│ │ # plugins, system, backup, fonts, misc,
│ │ # wifi, starlark)
│ └── pages_v3.py # Page routes
├── tailwind/ # Tailwind config + input CSS (build inputs,
│ # not served; see "Styling" below)
├── templates/ # HTML templates
│ └── v3/
│ ├── base.html
│ └── partials/
└── static/ # CSS/JS assets
└── v3/
├── app.css
├── tailwind.css # GENERATED utility classes (committed)
├── plugin-frame.css # GENERATED styles for plugin web_ui/ iframes
├── app.css # hand-written: tokens, components, dark theme
├── app.js
├── manifest.json # PWA manifest
├── plugins_manager.js
@@ -38,6 +42,50 @@ web_interface/
└── vendor/ # codemirror, fontawesome
```
## Styling (Tailwind CSS)
Templates and JS use [Tailwind](https://v3.tailwindcss.com/) utility
classes. The CSS for them is generated on a dev machine or in CI and
**committed**, so the Pi never builds anything and the UI needs no CDN
(it has to work in AP mode, with no internet).
- `static/v3/tailwind.css` holds the utilities. It is generated from the
classes found in `templates/v3/`, `static/v3/**/*.js` and `blueprints/`,
so it only contains what the UI uses.
- `static/v3/app.css` is hand-written: theme tokens, base element styles,
components (`.btn`, `.card`, `.nav-tab`, ...) and the dark theme
(`[data-theme="dark"] ...` overrides). `base.html` loads it after
`tailwind.css`, so its rules win over utilities of equal specificity.
Don't add utility classes to it; use the class and rebuild.
- `static/v3/plugin-frame.css` styles plugin `web_ui/` fragments served by
`/v3/plugin-ui/<plugin>/web-ui/<file>` in an iframe. Their markup lives in
plugin repos, so it can't be scanned; its config safelists the common
utility families instead.
**After changing a template, a static JS file or anything in `tailwind/`,
rebuild and commit the CSS with your change:**
```bash
python3 scripts/build_css.py # rewrites tailwind.css and plugin-frame.css
python3 scripts/build_css.py --check # what CI runs: fails if they are stale
```
No Node or npm is needed. The script downloads Tailwind's standalone CLI
(pinned version, SHA-256 checked) for your OS once and caches it outside
the repo (`LEDMATRIX_TAILWIND_CACHE` overrides where). CI runs `--check`
on every PR.
Where to change what:
- A class built at runtime (`` `bg-${color}-100` ``) is invisible to the
scanner: add it to `safelist` in `tailwind/tailwind.config.js`, or better,
write the full class names in the code.
- Colours, font sizes and shadows that differ from stock Tailwind (darker
gray text, emerald/amber button fills, token-based shadows) are set in the
`theme` of `tailwind/tailwind.config.js`.
- Dark mode is the `data-theme="dark"` attribute on `<html>`; the `dark:`
variant is configured to match it.
## Running the Web Interface
### Standalone (Development)
+6 -4
View File
@@ -301,10 +301,12 @@ def serve_plugin_web_ui(plugin_id, filename):
# but we also Unicode-escape HTML meta-chars as defence in depth.
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
'</script>\n'
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
'<link rel="stylesheet" '
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
'crossorigin="anonymous">\n'
# Served locally (not from a CDN) so fragments are styled in AP
# mode with no internet. Built by scripts/build_css.py; see
# web_interface/tailwind/plugin-frame.config.js.
'<link rel="stylesheet" href="'
+ url_for('static', filename='v3/plugin-frame.css')
+ '">\n'
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
'</head>\n'
'<body>\n'
+19 -626
View File
@@ -111,9 +111,9 @@ body {
background-color: var(--color-background);
}
/* Border utilities (.border, .border-t, ...) set width and style only; this
gives them the theme's border color unless a border-* color class says
otherwise. */
/* Border utilities (.border, .border-t, ...) set only a width (tailwind.css
resets every element to zero width, solid style); this gives them the
theme's border color unless a border-* color class says otherwise. */
*, ::before, ::after {
border-color: var(--color-border);
}
@@ -182,137 +182,11 @@ html[data-theme="dark"] {
top: 0.75rem;
}
/* Utility classes */
.bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; }
.bg-white { background-color: #ffffff; }
.bg-gray-800 { background-color: #1f2937; }
.bg-gray-900 { background-color: #111827; }
.bg-green-500 { background-color: #10b981; }
.bg-red-500 { background-color: #ef4444; }
.bg-blue-500 { background-color: #3b82f6; }
.bg-yellow-500 { background-color: #f59e0b; }
.bg-green-600 { background-color: #059669; }
.bg-red-600 { background-color: #dc2626; }
.bg-blue-600 { background-color: #2563eb; }
.bg-yellow-600 { background-color: #d97706; }
.bg-gray-200 { background-color: #e5e7eb; }
/* Log level row backgrounds (subtle tints on dark log container) */
.log-level-error { background-color: rgb(127 29 29 / 10%); }
.log-level-warning { background-color: rgb(120 53 15 / 10%); }
.log-level-debug { background-color: rgb(31 41 55 / 30%); }
.text-gray-100 { color: #f3f4f6; }
.text-gray-200 { color: #e5e7eb; }
.text-gray-300 { color: #d1d5db; }
.text-gray-900 { color: #111827; }
.text-gray-600 { color: #374151; }
.text-gray-500 { color: #4b5563; }
.text-gray-400 { color: #6b7280; }
.text-red-300 { color: #fca5a5; }
.text-yellow-300 { color: #fcd34d; }
.text-white { color: #ffffff; }
.text-green-600 { color: #059669; }
.text-red-600 { color: #dc2626; }
/* Status badge chips (e.g. tools.html's dirty/clean and power-status badges) */
.bg-red-100 { background-color: #fee2e2; }
.bg-yellow-100 { background-color: #fef9c3; }
.bg-green-100 { background-color: #dcfce7; }
.text-red-800 { color: #991b1b; }
.text-yellow-800 { color: #854d0e; }
.text-green-800 { color: #166534; }
.border-gray-200 { border-color: #e5e7eb; }
.border-gray-300 { border-color: #d1d5db; }
.border-gray-700 { border-color: #374151; }
.border-transparent { border-color: transparent; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-md { border-radius: 0.375rem; }
.rounded-full { border-radius: 9999px; }
.rounded { border-radius: 0.25rem; }
.shadow { box-shadow: var(--shadow); }
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.p-6 { padding: 1.5rem; }
.p-4 { padding: 1rem; }
.p-2 { padding: 0.5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.pb-4 { padding-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-8 { margin-bottom: 2rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-4 { margin-top: 1rem; }
.mr-2 { margin-right: 0.5rem; }
.ml-3 { margin-left: 0.75rem; }
.w-full { width: 100%; }
.w-0 { width: 0; }
.w-2 { width: 0.5rem; }
.w-4 { width: 1rem; }
.h-2 { height: 0.5rem; }
.h-4 { height: 1rem; }
.h-10 { height: 2.5rem; }
.h-16 { height: 4rem; }
.h-24 { height: 6rem; }
.h-32 { height: 8rem; }
.h-96 { height: 24rem; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.flex-wrap { flex-wrap: wrap; }
.flex-shrink-0 { flex-shrink: 0; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.space-x-1 > * + * { margin-left: 0.25rem; }
.space-x-2 > * + * { margin-left: 0.5rem; }
.space-x-4 > * + * { margin-left: 1rem; }
.space-y-1 > * + * { margin-top: 0.25rem; }
.space-y-1\.5 > * + * { margin-top: 0.375rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
.space-y-4 > * + * { margin-top: 1rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.gap-1\.5 { gap: 0.375rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-x-2 { column-gap: 0.5rem; }
.gap-x-3 { column-gap: 0.75rem; }
.gap-x-4 { column-gap: 1rem; }
.gap-y-2 { row-gap: 0.5rem; }
/* Enhanced Typography */
.text-xs { font-size: 0.75rem; line-height: 1.4; }
.text-sm { font-size: 0.875rem; line-height: 1.5; }
.text-base { font-size: 1rem; line-height: 1.5; }
.text-lg { font-size: 1.125rem; line-height: 1.75; }
.text-xl { font-size: 1.25rem; line-height: 1.75; }
.text-2xl { font-size: 1.5rem; line-height: 2; }
.text-4xl { font-size: 2.25rem; line-height: 2.5; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
/* Headings with improved hierarchy */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
@@ -326,53 +200,6 @@ h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; }
.border-b { border-bottom-width: 1px; }
.relative { position: relative; }
.fixed { position: fixed; }
.absolute { position: absolute; }
.z-50 { z-index: 50; }
.top-4 { top: 1rem; }
.right-4 { right: 1rem; }
.max-w-full { max-width: 100%; }
.mx-auto { margin-left: auto; margin-right: auto; }
.overflow-x-auto { overflow-x: auto; }
.overflow-hidden { overflow: hidden; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.transition { transition-property: transform, opacity, color, border-color; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
/* Optimized: Replaced 'all' with specific properties to avoid animating expensive properties */
/* Removed .duration-300 - not used anywhere */
.hover\:bg-green-700:hover { background-color: #047857; }
.hover\:bg-red-700:hover { background-color: #b91c1c; }
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
.hover\:bg-gray-800:hover { background-color: #1f2937; }
.hover\:bg-yellow-700:hover { background-color: #b45309; }
.hover\:text-gray-700:hover { color: #374151; }
.hover\:border-gray-300:hover { border-color: #d1d5db; }
/* Only suppress the outline for pointer focus; keyboard focus keeps it. */
.focus\:outline-none:focus:not(:focus-visible) { outline: 2px solid transparent; outline-offset: 2px; }
.focus\:ring-2:focus {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(2px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000);
}
/* Optimized: Split complex selector onto multiple lines for readability */
.focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
/* Smooth transitions for all interactive elements */
/* Optimized: Only transition properties that don't trigger expensive repaints */
a, button, input, select, textarea {
@@ -479,426 +306,22 @@ a, button, input, select, textarea {
}
}
/* ===== Utility layer (fills the Tailwind-style gaps) =====
Templates and JS are authored in Tailwind class names, but there is no
Tailwind build: this file is the whole stylesheet. Every utility class the
markup uses must be defined here or it silently does nothing
(test/test_web_static_audit.py enforces this). Values follow Tailwind v3.
Keep `.hidden` after the display utilities so JS show/hide wins. */
.-translate-y-1\/2 { --tw-translate-y: -50%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.accent-blue-600 { accent-color: #2563eb; }
.align-bottom { vertical-align: bottom; }
.appearance-none { appearance: none; -webkit-appearance: none; }
.backdrop-blur-sm { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1)); }
.bg-amber-50 { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
.bg-amber-500\/10 { background-color: rgb(245 158 11 / 0.1); }
.bg-amber-600 { --tw-bg-opacity: 1; background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); }
.bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1)); }
.bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.bg-blue-50 { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
.bg-gray-400 { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.bg-gray-500 { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); }
.bg-gray-600 { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1)); }
.bg-green-50 { --tw-bg-opacity: 1; background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); }
.bg-opacity-20 { --tw-bg-opacity: 0.2; }
.bg-opacity-70 { --tw-bg-opacity: 0.7; }
.bg-opacity-75 { --tw-bg-opacity: 0.75; }
.bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1)); }
.bg-purple-50 { --tw-bg-opacity: 1; background-color: rgb(250 245 255 / var(--tw-bg-opacity, 1)); }
.bg-purple-700 { --tw-bg-opacity: 1; background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1)); }
.bg-red-50 { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
.bg-red-800 { --tw-bg-opacity: 1; background-color: rgb(153 27 27 / var(--tw-bg-opacity, 1)); }
.bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1)); }
.block { display: block; }
.border { border-width: 1px; border-style: solid; }
.border-2 { border-width: 2px; border-style: solid; }
.border-amber-200 { border-color: #fde68a; }
.border-amber-300 { border-color: #fcd34d; }
.border-amber-500\/60 { border-color: rgb(245 158 11 / 0.6); }
.border-blue-200 { border-color: #bfdbfe; }
.border-blue-300 { border-color: #93c5fd; }
.border-dashed { border-style: dashed; }
.border-gray-100 { border-color: #f3f4f6; }
.border-gray-400 { border-color: #9ca3af; }
.border-gray-600 { border-color: #4b5563; }
.border-green-200 { border-color: #bbf7d0; }
.border-green-400 { border-color: #4ade80; }
.border-green-500 { border-color: #22c55e; }
.border-l-0 { border-left-width: 0px; border-left-style: solid; }
.border-purple-200 { border-color: #e9d5ff; }
.border-r-0 { border-right-width: 0px; border-right-style: solid; }
.border-red-200 { border-color: #fecaca; }
.border-red-300 { border-color: #fca5a5; }
.border-red-400 { border-color: #f87171; }
.border-red-500 { border-color: #ef4444; }
.border-t { border-top-width: 1px; border-top-style: solid; }
.border-yellow-200 { border-color: #fef08a; }
.border-yellow-300 { border-color: #fde047; }
.break-all { word-break: break-all; }
.break-words { overflow-wrap: break-word; }
.capitalize { text-transform: capitalize; }
.col-span-full { grid-column: 1 / -1; }
.cursor-move { cursor: move; }
.cursor-not-allowed { cursor: not-allowed; }
.cursor-pointer { cursor: pointer; }
.cursor-wait { cursor: wait; }
.divide-gray-100 > :not(:first-child) { border-color: #f3f4f6; }
.divide-gray-200 > :not(:first-child) { border-color: #e5e7eb; }
.divide-y > :not(:first-child) { border-top-width: 1px; border-top-style: solid; }
.duration-150 { transition-duration: 150ms; }
.duration-200 { transition-duration: 200ms; }
.duration-300 { transition-duration: 300ms; }
.ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
.flex-\[3\] { flex: 3 1 0%; }
.flex-col { flex-direction: column; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
.font-normal { font-weight: 400; }
.gap-1 { gap: 0.25rem; }
.h-1 { height: 0.25rem; }
.h-12 { height: 3rem; }
.h-20 { height: 5rem; }
.h-3 { height: 0.75rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-64 { height: 16rem; }
.h-7 { height: 1.75rem; }
.h-8 { height: 2rem; }
.h-9 { height: 2.25rem; }
.h-full { height: 100%; }
.inline { display: inline; }
.inline-block { display: inline-block; }
.inset-0 { inset: 0px; }
.inset-y-0 { top: 0px; bottom: 0px; }
.italic { font-style: italic; }
.items-end { align-items: flex-end; }
.items-start { align-items: flex-start; }
.justify-end { justify-content: flex-end; }
.leading-6 { line-height: 1.5rem; }
.leading-none { line-height: 1; }
.left-0 { left: 0px; }
.left-3 { left: 0.75rem; }
.left-\[3px\] { left: 3px; }
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.line-through { text-decoration-line: line-through; }
.list-disc { list-style-type: disc; }
.list-inside { list-style-position: inside; }
.max-h-32 { max-height: 8rem; }
.max-h-48 { max-height: 12rem; }
.max-h-64 { max-height: 16rem; }
.max-h-96 { max-height: 24rem; }
.max-h-screen { max-height: 100vh; }
.max-w-\[14rem\] { max-width: 14rem; }
.max-w-lg { max-width: 32rem; }
.max-w-md { max-width: 28rem; }
.max-w-xs { max-width: 20rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-5 { margin-bottom: 1.25rem; }
.min-h-\[100px\] { min-height: 100px; }
.min-h-screen { min-height: 100vh; }
.min-w-\[12rem\] { min-width: 12rem; }
.min-w-full { min-width: 100%; }
.ml-1 { margin-left: 0.25rem; }
.ml-1\.5 { margin-left: 0.375rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }
.ml-5 { margin-left: 1.25rem; }
.ml-6 { margin-left: 1.5rem; }
.ml-auto { margin-left: auto; }
.mr-1 { margin-right: 0.25rem; }
.mr-3 { margin-right: 0.75rem; }
.mt-0\.5 { margin-top: 0.125rem; }
.mt-8 { margin-top: 2rem; }
.mt-auto { margin-top: auto; }
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }
.object-cover { object-fit: cover; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-70 { opacity: 0.7; }
.overflow-auto { overflow: auto; }
.overflow-y-auto { overflow-y: auto; }
.p-0 { padding: 0px; }
.p-0\.5 { padding: 0.125rem; }
.p-1 { padding: 0.25rem; }
.pb-20 { padding-bottom: 5rem; }
.pb-3 { padding-bottom: 0.75rem; }
.pl-3 { padding-left: 0.75rem; }
.pl-5 { padding-left: 1.25rem; }
.pl-8 { padding-left: 2rem; }
.pl-9 { padding-left: 2.25rem; }
.pointer-events-auto { pointer-events: auto; }
.pointer-events-none { pointer-events: none; }
.pr-10 { padding-right: 2.5rem; }
.pr-20 { padding-right: 5rem; }
.pr-3 { padding-right: 0.75rem; }
.pr-4 { padding-right: 1rem; }
.pr-8 { padding-right: 2rem; }
.pt-2 { padding-top: 0.5rem; }
.pt-3 { padding-top: 0.75rem; }
.pt-4 { padding-top: 1rem; }
.pt-5 { padding-top: 1.25rem; }
.pt-6 { padding-top: 1.5rem; }
.pt-8 { padding-top: 2rem; }
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.right-0 { right: 0px; }
.right-2 { right: 0.5rem; }
.ring-0 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 var(--tw-ring-offset-width, 0px) var(--tw-ring-color, transparent); }
.rounded-b-lg { border-bottom-left-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
.rounded-l-md { border-top-left-radius: 0.375rem; border-bottom-left-radius: 0.375rem; }
.rounded-r-md { border-top-right-radius: 0.375rem; border-bottom-right-radius: 0.375rem; }
.rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
.self-center { align-self: center; }
.shadow-inner { box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 5%); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0 / 10%), 0 8px 10px -6px rgb(0 0 0 / 10%); }
.shrink-0 { flex-shrink: 0; }
.space-x-3 > * + * { margin-left: 0.75rem; }
.space-y-3 > * + * { margin-top: 0.75rem; }
.space-y-5 > * + * { margin-top: 1.25rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-amber-200\/90 { color: rgb(253 230 138 / 0.9); }
.text-amber-300 { color: #fcd34d; }
.text-amber-500 { color: #f59e0b; }
.text-amber-600 { color: #d97706; }
.text-amber-700 { color: #b45309; }
.text-amber-800 { color: #92400e; }
.text-amber-900 { color: #78350f; }
.text-black { color: #000000; }
.text-blue-400 { color: #60a5fa; }
.text-blue-500 { color: #3b82f6; }
.text-blue-600 { color: #2563eb; }
.text-blue-700 { color: #1d4ed8; }
.text-blue-800 { color: #1e40af; }
.text-blue-900 { color: #1e3a8a; }
.text-center { text-align: center; }
.text-gray-700 { color: #374151; }
.text-gray-800 { color: #1f2937; }
.text-green-500 { color: #22c55e; }
.text-green-700 { color: #15803d; }
.text-green-900 { color: #14532d; }
.text-left { text-align: left; }
.text-md { font-size: 1rem; line-height: 1.5rem; }
.text-purple-600 { color: #9333ea; }
.text-purple-700 { color: #7e22ce; }
.text-purple-800 { color: #6b21a8; }
.text-red-400 { color: #f87171; }
.text-red-500 { color: #ef4444; }
.text-red-700 { color: #b91c1c; }
.text-red-900 { color: #7f1d1d; }
.text-right { text-align: right; }
.text-yellow-400 { color: #facc15; }
.text-yellow-500 { color: #eab308; }
.text-yellow-600 { color: #ca8a04; }
.text-yellow-700 { color: #a16207; }
.text-yellow-900 { color: #713f12; }
.top-1\/2 { top: 50%; }
.top-2 { top: 0.5rem; }
.top-\[3px\] { top: 3px; }
.tracking-wider { letter-spacing: 0.05em; }
.transform { transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-colors { transition-property: color, background-color, border-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-1 { --tw-translate-x: 0.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-5 { --tw-translate-x: 1.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-6 { --tw-translate-x: 1.5rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.translate-x-full { --tw-translate-x: 100%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.underline { text-decoration-line: underline; text-underline-offset: 0.15em; }
.uppercase { text-transform: uppercase; }
.w-1\/2 { width: 50%; }
.w-1\/3 { width: 33.333333%; }
.w-1\/4 { width: 25%; }
.w-10 { width: 2.5rem; }
.w-11 { width: 2.75rem; }
.w-12 { width: 3rem; }
.w-14 { width: 3.5rem; }
.w-16 { width: 4rem; }
.w-2\/3 { width: 66.666667%; }
.w-2\/5 { width: 40%; }
.w-20 { width: 5rem; }
.w-24 { width: 6rem; }
.w-3 { width: 0.75rem; }
.w-3\/4 { width: 75%; }
.w-3\/5 { width: 60%; }
.w-32 { width: 8rem; }
.w-4\/5 { width: 80%; }
.w-4\/6 { width: 66.666667%; }
.w-48 { width: 12rem; }
.w-5 { width: 1.25rem; }
.w-5\/6 { width: 83.333333%; }
.w-6 { width: 1.5rem; }
.w-8 { width: 2rem; }
.w-80 { width: 20rem; }
.w-96 { width: 24rem; }
.w-px { width: 1px; }
.whitespace-nowrap { white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.-m-2 { margin: -0.5rem; }
.list-none { list-style-type: none; }
.pl-0 { padding-left: 0px; }
.w-auto { width: auto; }
.pb-1 { padding-bottom: 0.25rem; }
.hidden { display: none; }
/* ===== Utilities =====
Utility classes (flex, px-4, text-gray-700, md:hidden, ...) come from
tailwind.css, which scripts/build_css.py generates from the templates and
static JS and base.html links before this file. Don't add utilities here:
use the class and rebuild. Only the few this UI wants to behave
differently from stock Tailwind live here; tailwind.config.js blocklists
the focus ones so Tailwind doesn't emit its own. */
/* State and pseudo variants */
.disabled\:bg-gray-400:disabled { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
.disabled\:opacity-50:disabled { opacity: 0.5; }
.disabled\:text-gray-400:disabled { color: #6b7280; }
.file\:bg-blue-50::file-selector-button { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.file\:border-0::file-selector-button { border-width: 0px; }
.file\:font-medium::file-selector-button { font-weight: 500; }
.file\:mr-4::file-selector-button { margin-right: 1rem; }
.file\:px-4::file-selector-button { padding-left: 1rem; padding-right: 1rem; }
.file\:py-2::file-selector-button { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.file\:rounded-md::file-selector-button { border-radius: 0.375rem; }
.file\:text-blue-700::file-selector-button { color: #1d4ed8; }
.file\:text-sm::file-selector-button { font-size: 0.875rem; line-height: 1.25rem; }
.first\:border-t-0:first-child { border-top-width: 0px; border-top-style: solid; }
.focus\:border-blue-500:focus { border-color: #3b82f6; }
.focus\:ring-indigo-500:focus { --tw-ring-color: #6366f1; }
.focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; --tw-ring-offset-shadow: 0 0 0 2px var(--color-surface, #fff); }
.focus\:shadow-md:focus { box-shadow: var(--shadow-md); }
.group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.hover\:bg-amber-50:hover { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
.hover\:bg-amber-700:hover { --tw-bg-opacity: 1; background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-100:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-50:hover { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
.hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-400:hover { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
.hover\:bg-gray-700:hover { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); }
.hover\:bg-opacity-20:hover { --tw-bg-opacity: 0.2; }
.hover\:bg-opacity-30:hover { --tw-bg-opacity: 0.3; }
.hover\:bg-red-50:hover { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
.hover\:bg-red-900:hover { --tw-bg-opacity: 1; background-color: rgb(127 29 29 / var(--tw-bg-opacity, 1)); }
.hover\:bg-white:hover { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); }
.hover\:border-blue-400:hover { border-color: #60a5fa; }
.hover\:file\:bg-blue-100::file-selector-button:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
.hover\:opacity-100:hover { opacity: 1; }
.hover\:scale-110:hover { --tw-scale-x: 1.1; --tw-scale-y: 1.1; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
.hover\:shadow-md:hover { box-shadow: var(--shadow-md); }
.hover\:text-blue-600:hover { color: #2563eb; }
.hover\:text-blue-700:hover { color: #1d4ed8; }
.hover\:text-blue-800:hover { color: #1e40af; }
.hover\:text-gray-600:hover { color: #374151; }
.hover\:text-gray-900:hover { color: #111827; }
.hover\:text-red-300:hover { color: #fca5a5; }
.hover\:text-red-600:hover { color: #dc2626; }
.hover\:text-red-800:hover { color: #991b1b; }
.hover\:text-red-900:hover { color: #7f1d1d; }
.hover\:text-white:hover { color: #ffffff; }
.hover\:text-yellow-700:hover { color: #a16207; }
.hover\:text-yellow-800:hover { color: #854d0e; }
.hover\:text-yellow-900:hover { color: #713f12; }
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
.last\:border-0:last-child { border-width: 0px; }
/* Keyboard focus keeps its outline and ring; a pointer click shows neither. */
.focus\:outline-none:focus:not(:focus-visible) { outline: 2px solid transparent; outline-offset: 2px; }
.peer:focus-visible ~ .peer-focus\:outline-none { outline: 2px solid transparent; outline-offset: 2px; }
.peer:focus-visible ~ .peer-focus\:ring-4 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(4px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000); }
.peer:focus-visible ~ .peer-focus\:ring-blue-300 { --tw-ring-color: #93c5fd; }
.placeholder\:text-gray-400::placeholder { color: #6b7280; }
.placeholder\:text-gray-500::placeholder { color: #4b5563; }
.hover\:bg-gray-200:hover { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); }
.hover\:text-gray-800:hover { color: #1f2937; }
/* Responsive breakpoints */
@media (min-width: 640px) {
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.sm\:block { display: block; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.sm\:align-middle { vertical-align: middle; }
.sm\:flex { display: flex; }
.sm\:flex-row-reverse { flex-direction: row-reverse; }
.sm\:h-10 { height: 2.5rem; }
.sm\:hidden { display: none; }
.sm\:inline { display: inline; }
.sm\:items-start { align-items: flex-start; }
.sm\:max-w-4xl { max-width: 56rem; }
.sm\:max-w-lg { max-width: 32rem; }
.sm\:ml-3 { margin-left: 0.75rem; }
.sm\:ml-4 { margin-left: 1rem; }
.sm\:mt-0 { margin-top: 0px; }
.sm\:mx-0 { margin-left: 0px; margin-right: 0px; }
.sm\:my-8 { margin-top: 2rem; margin-bottom: 2rem; }
.sm\:p-0 { padding: 0px; }
.sm\:p-6 { padding: 1.5rem; }
.sm\:pb-4 { padding-bottom: 1rem; }
.sm\:text-left { text-align: left; }
.sm\:text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.sm\:w-10 { width: 2.5rem; }
.sm\:w-auto { width: auto; }
.sm\:w-full { width: 100%; }
.sm\:flex-nowrap { flex-wrap: nowrap; }
.sm\:gap-4 { gap: 1rem; }
}
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.md\:flex { display: flex; }
.md\:hidden { display: none; }
.md\:block { display: block; }
.md\:w-auto { width: auto; }
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.lg\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
.lg\:gap-x-3 { column-gap: 0.75rem; }
.lg\:gap-x-6 { column-gap: 1.5rem; }
.lg\:block { display: block; }
.lg\:flex { display: flex; }
.lg\:w-64 { width: 16rem; }
}
@media (min-width: 1280px) {
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
.xl\:grid-cols-8 { grid-template-columns: repeat(8, minmax(0, 1fr)); }
.xl\:px-12 { padding-left: 3rem; padding-right: 3rem; }
.xl\:space-x-6 > * + * { margin-left: 1.5rem; }
.xl\:gap-x-4 { column-gap: 1rem; }
.xl\:gap-x-8 { column-gap: 2rem; }
}
@media (min-width: 1536px) {
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
.2xl\:grid-cols-8 { grid-template-columns: repeat(8, minmax(0, 1fr)); }
.2xl\:grid-cols-9 { grid-template-columns: repeat(9, minmax(0, 1fr)); }
.2xl\:grid-cols-10 { grid-template-columns: repeat(10, minmax(0, 1fr)); }
.2xl\:px-16 { padding-left: 4rem; padding-right: 4rem; }
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
}
.underline,
.hover\:underline:hover { text-underline-offset: 0.15em; }
/* ===== Mobile navigation drawer =====
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
@@ -1012,8 +435,11 @@ a, button, input, select, textarea {
}
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
Lea Verou scrolling-shadows technique — backgrounds sit behind content). */
.overflow-x-auto {
Lea Verou scrolling-shadows technique — backgrounds sit behind content).
Not on elements with their own background (the dark command-output
<pre class="bg-gray-900 overflow-x-auto">): the shorthand would wipe it,
and surface-coloured covers don't belong on it anyway. */
.overflow-x-auto:not([class*="bg-"]) {
background:
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
@@ -1459,25 +885,6 @@ button.bg-white {
}
}
/* Removed .divider and .divider-light - not used anywhere */
/* Enhanced Spacing Utilities - Only unique classes not in main utility section */
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-6 { margin-top: 1.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.p-3 { padding: 0.75rem; }
.p-5 { padding: 1.25rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
/* Removed duplicates: mt-4, mb-2, mb-4, mb-6, mb-8, p-4, p-6, px-4, py-2, py-3 (already defined above) */
/* Additional Utility Classes */
.min-w-0 { min-width: 0; }
.leading-relaxed { line-height: 1.625; }
/* Enhanced Navigation Tab Styles */
.nav-tab {
position: relative;
@@ -1734,22 +1141,8 @@ button.bg-white {
[data-theme="dark"] .hover\:text-yellow-700:hover { color: #fef08a; }
[data-theme="dark"] .hover\:text-yellow-800:hover { color: #fef08a; }
[data-theme="dark"] .hover\:text-yellow-900:hover { color: #fef08a; }
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
/* Dark theme for the generated color utilities */
[data-theme="dark"] .hover\:text-gray-800:hover { color: var(--color-text-primary); }
/* Dark theme for the generated color utilities */
/* Theme toggle button */
.theme-toggle-btn {
display: inline-flex;
@@ -354,6 +354,12 @@ widget on one of those is ignored.
selector: allow only `[A-Za-z0-9_-]`. `BaseWidget` has `sanitizeId()`.
- Associate labels with inputs and keep the widget usable from the keyboard.
- Debounce events that fire on every keystroke.
- Style with the Tailwind utility classes the core UI already uses (`px-3`,
`border-gray-300`, `rounded`, `text-sm`, ...). `static/v3/tailwind.css` is
generated from the core templates and JS, so it only contains classes core
uses, plus everything the hand-written stylesheet defined before the build.
A rarer class a plugin widget needs belongs in a `<style>` the widget
injects, since core can't scan plugin repos.
## Troubleshooting
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
/*
* Input for static/v3/tailwind.css. Don't edit the output; edit this file or
* tailwind.config.js and run `python3 scripts/build_css.py`.
*
* Custom CSS belongs in static/v3/app.css, which loads after this file, so
* its rules win over utilities of equal specificity.
*/
@tailwind base;
@layer base {
/* The one piece of Tailwind's preflight the utilities depend on: border
utilities set only a width (`border`, `border-t`, `divide-y`), so every
element starts from zero width and a solid style. app.css sets the
default border colour. */
*,
::before,
::after {
border-width: 0;
border-style: solid;
}
/* The reset above would erase these; keep them visible. */
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
:where(
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], [type="hidden"]),
select,
textarea
) {
border-width: 1px;
}
}
@tailwind components;
@tailwind utilities;
@@ -0,0 +1,100 @@
/*
* Tailwind config for static/v3/plugin-frame.css: the stylesheet
* /v3/plugin-ui/<plugin>/web-ui/<file> links for a plugin's web_ui/ fragment
* (shown in an iframe on the plugin's config tab).
*
* Fragments used to load Tailwind v2.2.19 from a CDN, which fails when the Pi
* is in AP mode with no internet. Their markup lives in plugin repos and
* can't be scanned here, so instead of content globs this file safelists the
* common utility families at the values the v2 CDN had. A fragment that
* needs something rarer should ship it in its own <style>.
*
* Patterns are regex literals, so the colour families (gray red yellow green
* blue indigo purple pink) and the spacing scale are spelled out in each
* pattern that uses them; change every copy together.
*
* Rebuild with `python3 scripts/build_css.py` after changing this file.
*/
const colors = require('tailwindcss/colors');
module.exports = {
content: {
relative: true,
// The route's own wrapper markup; everything else is safelisted.
files: ['../blueprints/pages_v3.py'],
},
safelist: [
// Colour
{
pattern: /^(bg|text|border)-(gray|red|yellow|green|blue|indigo|purple|pink)-(50|100|200|300|400|500|600|700|800|900)$/,
variants: ['hover'],
},
{ pattern: /^(bg|text|border)-(white|black|transparent|current)$/, variants: ['hover'] },
{ pattern: /^(ring|placeholder)-(gray|red|yellow|green|blue|indigo|purple|pink)-(300|400|500|600)$/, variants: ['focus'] },
{ pattern: /^border-(gray|red|yellow|green|blue|indigo|purple|pink)-(300|400|500|600)$/, variants: ['focus'] },
{ pattern: /^bg-opacity-(0|25|50|75|100)$/ },
{ pattern: /^opacity-(0|25|50|75|100)$/, variants: ['hover', 'disabled'] },
// Spacing and sizing
{ pattern: /^(p|px|py|pt|pr|pb|pl|m|mx|my|mt|mr|mb|ml)-(0|0\.5|1|1\.5|2|2\.5|3|4|5|6|8|10|12|16|20|24)$/ },
{ pattern: /^(m|mx|my|mt|mr|mb|ml)-auto$/ },
{ pattern: /^(space-x|space-y|gap|gap-x|gap-y)-(0|0\.5|1|1\.5|2|2\.5|3|4|5|6|8|10|12|16|20|24)$/ },
{ pattern: /^(w|h)-(0|1|2|3|4|5|6|8|10|12|16|20|24|32|40|48|56|64|72|80|96|auto|full|screen|px|1\/2|1\/3|2\/3|1\/4|3\/4)$/ },
{ pattern: /^min-(w|h)-(0|full|screen)$/ },
{ pattern: /^max-w-(xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|full|screen-sm|screen-md|screen-lg|none)$/ },
{ pattern: /^max-h-(32|48|64|96|full|screen)$/ },
// Layout
{ pattern: /^(block|inline-block|inline|flex|inline-flex|grid|inline-grid|table|table-row|table-cell|hidden|contents)$/, variants: ['sm', 'md', 'lg'] },
{ pattern: /^flex-(1|auto|initial|none|row|row-reverse|col|col-reverse|wrap|nowrap|grow|shrink|grow-0|shrink-0)$/, variants: ['sm', 'md', 'lg'] },
{ pattern: /^(grow|shrink|grow-0|shrink-0)$/ },
{ pattern: /^(items|content)-(start|end|center|baseline|stretch|between)$/ },
{ pattern: /^(justify|self)-(start|end|center|between|around|evenly|auto|stretch)$/ },
{ pattern: /^grid-cols-(1|2|3|4|5|6|12)$/, variants: ['sm', 'md', 'lg'] },
{ pattern: /^col-span-(1|2|3|4|5|6|12|full)$/, variants: ['sm', 'md', 'lg'] },
{ pattern: /^(static|relative|absolute|fixed|sticky)$/ },
{ pattern: /^(inset|top|right|bottom|left)-(0|1|2|3|4|auto|full)$/ },
{ pattern: /^inset-(x|y)-0$/ },
{ pattern: /^z-(0|10|20|30|40|50|auto)$/ },
{ pattern: /^(overflow|overflow-x|overflow-y)-(auto|hidden|visible|scroll)$/ },
{ pattern: /^(float-left|float-right|clear-both|mx-auto|container|sr-only|not-sr-only)$/ },
{ pattern: /^object-(contain|cover|center)$/ },
// Typography
{ pattern: /^text-(xs|sm|base|lg|xl|2xl|3xl|4xl)$/, variants: ['sm', 'md'] },
{ pattern: /^text-(left|center|right|justify)$/ },
{ pattern: /^font-(sans|serif|mono|light|normal|medium|semibold|bold|extrabold)$/ },
{ pattern: /^(uppercase|lowercase|capitalize|normal-case|italic|not-italic|underline|line-through|no-underline|truncate|break-words|break-all)$/, variants: ['hover'] },
{ pattern: /^whitespace-(normal|nowrap|pre|pre-line|pre-wrap)$/ },
{ pattern: /^leading-(none|tight|snug|normal|relaxed|loose|4|5|6|7|8)$/ },
{ pattern: /^tracking-(tighter|tight|normal|wide|wider|widest)$/ },
{ pattern: /^(list-none|list-disc|list-decimal|list-inside|list-outside)$/ },
{ pattern: /^align-(top|middle|bottom|baseline)$/ },
// Borders, effects, interaction
{ pattern: /^border(-0|-2|-4|-t|-b|-l|-r|-t-0|-b-0|-t-2|-b-2|-l-4)?$/ },
{ pattern: /^border-(solid|dashed|dotted|none)$/ },
{ pattern: /^divide-(x|y)$/ },
{ pattern: /^divide-(gray|red|yellow|green|blue|indigo|purple|pink)-(100|200|300)$/ },
{ pattern: /^rounded(-none|-sm|-md|-lg|-xl|-2xl|-full)?$/ },
{ pattern: /^rounded-(t|b|l|r)(-md|-lg)?$/ },
{ pattern: /^shadow(-sm|-md|-lg|-xl|-2xl|-inner|-none)?$/, variants: ['hover'] },
{ pattern: /^(ring|ring-0|ring-1|ring-2|ring-4|ring-offset-2|outline-none)$/, variants: ['focus'] },
{ pattern: /^(transition|transition-colors|transition-opacity|transition-all|transform)$/ },
{ pattern: /^(duration|ease)-(150|200|300|in|out|in-out)$/ },
{ pattern: /^(animate-spin|animate-pulse)$/ },
{ pattern: /^cursor-(pointer|default|not-allowed|move|wait)$/ },
{ pattern: /^(select-none|select-all|pointer-events-none|pointer-events-auto|resize|resize-none|resize-y|appearance-none)$/ },
],
theme: {
extend: {
// Tailwind v2's default palette, which fragments were written against:
// its green, yellow and purple were v3's emerald, amber and violet.
colors: {
green: colors.emerald,
yellow: colors.amber,
purple: colors.violet,
},
},
},
};
@@ -0,0 +1,12 @@
/*
* Input for static/v3/plugin-frame.css, the stylesheet for plugin web_ui/
* fragments shown in an iframe. Don't edit the output; edit this file or
* plugin-frame.config.js and run `python3 scripts/build_css.py`.
*
* Includes Tailwind's preflight, as the v2 CDN build the fragments were
* written against did.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
+119
View File
@@ -0,0 +1,119 @@
/*
* Tailwind config for the web UI (static/v3/tailwind.css).
*
* Built by scripts/build_css.py with the pinned standalone CLI; the output is
* committed and CI fails if it is stale. After changing a template, a static
* JS file or this config, run `python3 scripts/build_css.py` and commit
* static/v3/tailwind.css with your change.
*
* tailwind.css holds the utilities; app.css (loaded after it) holds the
* theme tokens, base element styles, components and dark-theme overrides.
* The theme below keeps the values app.css used to hand-write, so moving to
* a real build restyled nothing that already worked.
*/
const colors = require('tailwindcss/colors');
module.exports = {
content: {
relative: true,
files: [
'../templates/v3/**/*.html',
'../static/v3/*.js',
'../static/v3/js/**/*.js',
'!../static/v3/js/*.min.js',
'../blueprints/**/*.py',
],
},
// Class names assembled at runtime, which the scanner can't see.
safelist: [
// tools.html showResult() and the power-status badge build
// `bg-${color}-50`, `text-${color}-800`, ... from green/red/yellow.
{ pattern: /^bg-(green|red|yellow)-(50|100)$/ },
{ pattern: /^border-(green|red|yellow)-200$/ },
{ pattern: /^text-(green|red|yellow)-(600|700|800)$/ },
// Hand-written in app.css before this build and unused by core since.
// Third-party plugin widgets render into the page and may use them, so
// the generated set stays a superset of what used to work.
'grid-cols-3', 'grid-cols-4', 'gap-x-3', 'gap-x-4', 'hover:border-gray-300',
'sm:hidden', 'sm:inline', 'sm:max-w-4xl', 'md:flex',
'lg:grid-cols-5', 'lg:grid-cols-6', 'lg:gap-x-6',
'xl:grid-cols-5', 'xl:grid-cols-6', 'xl:grid-cols-7', 'xl:grid-cols-8', 'xl:gap-x-8',
],
// Rules app.css defines on purpose and Tailwind must not override. Tailwind
// would show these on every focus; app.css shows them for keyboard focus
// only (:focus-visible), so a mouse click doesn't flash a ring.
blocklist: [
'focus:outline-none',
'peer-focus:outline-none',
'peer-focus:ring-4',
'peer-focus:ring-blue-300',
],
// The app switches themes with <html data-theme="dark">, not a class.
darkMode: ['selector', '[data-theme="dark"]'],
// app.css owns the base styles; preflight would reset headings, lists and
// form controls the UI already styles. The border reset utilities rely on
// is in app.input.css.
corePlugins: { preflight: false },
theme: {
extend: {
// Light-mode gray text is one step darker than stock Tailwind for
// contrast (text-gray-400 reads as gray-500, and so on).
textColor: {
gray: {
...colors.gray,
400: colors.gray[500],
500: colors.gray[600],
600: colors.gray[700],
},
green: { ...colors.green, 600: colors.emerald[600] },
yellow: { ...colors.yellow, 300: colors.amber[300] },
},
// Solid green/yellow buttons and dots use the deeper emerald/amber
// shades, which keep white text readable.
backgroundColor: {
green: {
...colors.green,
500: colors.emerald[500],
600: colors.emerald[600],
700: colors.emerald[700],
},
yellow: {
...colors.yellow,
500: colors.amber[500],
600: colors.amber[600],
700: colors.amber[700],
},
},
fontSize: {
xs: ['0.75rem', '1.4'],
sm: ['0.875rem', '1.5'],
base: ['1rem', '1.5'],
md: ['1rem', '1.5rem'],
lg: ['1.125rem', '1.75'],
xl: ['1.25rem', '1.75'],
'2xl': ['1.5rem', '2'],
'4xl': ['2.25rem', '2.5'],
},
// Shadows follow the theme tokens in app.css (:root / [data-theme]).
boxShadow: {
sm: 'var(--shadow-sm)',
DEFAULT: 'var(--shadow)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
},
// `transition` animates only compositor-friendly properties.
transitionProperty: {
DEFAULT: 'transform, opacity, color, border-color',
},
// The gap a focus ring leaves matches the surface, in both themes.
ringOffsetColor: {
DEFAULT: 'var(--color-surface, #fff)',
},
},
},
};
+4
View File
@@ -326,6 +326,10 @@
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
<!-- Utility classes, generated by scripts/build_css.py (Tailwind CLI) and
committed. app.css comes second so its components and theme overrides
win over utilities of equal specificity. -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/tailwind.css') }}">
<!-- Custom v3 styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
+2
View File
@@ -20,6 +20,8 @@
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
<!-- Utilities from scripts/build_css.py; app.css second, as in base.html. -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/tailwind.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
<style>
.login-wrap { max-width: 24rem; margin: 0 auto; padding: 4rem 1rem; }