diff --git a/.gitattributes b/.gitattributes index 39867887..9a1e2968 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1f71ea6..4f01615a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 905bcb91..7b8aa961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f3a7eb2..91e6f400 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). diff --git a/docs/WEB_INTERFACE_GUIDE.md b/docs/WEB_INTERFACE_GUIDE.md index 23a249e5..140403fc 100644 --- a/docs/WEB_INTERFACE_GUIDE.md +++ b/docs/WEB_INTERFACE_GUIDE.md @@ -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 diff --git a/scripts/build_css.py b/scripts/build_css.py new file mode 100644 index 00000000..789fb816 --- /dev/null +++ b/scripts/build_css.py @@ -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/``), 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()) diff --git a/test/test_build_css_script.py b/test/test_build_css_script.py new file mode 100644 index 00000000..dbf947e7 --- /dev/null +++ b/test/test_build_css_script.py @@ -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() diff --git a/test/test_pages_v3_partials.py b/test/test_pages_v3_partials.py index 83fcf2ab..3aa54566 100644 --- a/test/test_pages_v3_partials.py +++ b/test/test_pages_v3_partials.py @@ -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("

radar panel

", encoding="utf-8") + + body = client.get("/plugin-ui/radar/web-ui/panel.html").get_data(as_text=True) + + assert ' `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*['\"]([^'\"]+)['\"]" diff --git a/test/web_interface/test_gzip_fallback.py b/test/web_interface/test_gzip_fallback.py index 50f29cbe..0899328c 100644 --- a/test/web_interface/test_gzip_fallback.py +++ b/test/web_interface/test_gzip_fallback.py @@ -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"}) diff --git a/web_interface/README.md b/web_interface/README.md index d826cdbe..74fde887 100644 --- a/web_interface/README.md +++ b/web_interface/README.md @@ -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//web-ui/` 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 ``; the `dark:` + variant is configured to match it. + ## Running the Web Interface ### Standalone (Development) diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index 54013c45..8b01fc43 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -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' '\n' - # Tailwind v2 CDN — same version used by the parent LEDMatrix UI - '\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. + '\n' '\n' '\n' '\n' diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index e24fdd26..641d53f6 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -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 +
): 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;
diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md
index 477bfb68..649b3bca 100644
--- a/web_interface/static/v3/js/widgets/README.md
+++ b/web_interface/static/v3/js/widgets/README.md
@@ -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 `