From 1447122a0c6bdc7e1821fe7bec77dd28c607d678 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 1 Mar 2026 21:39:57 +0000 Subject: [PATCH] Add on-demand CSS: registry, pre-computed component classes, header compression - Parse tw.css into per-class lookup registry at startup - Pre-scan component CSS classes at registration time (avoid per-request regex) - Compress SX-Css header: 8-char hash replaces full class list (LRU cache) - Add ;@css comment annotation for dynamically constructed class names - Safelist bg-sky-{100..400} in Tailwind config for menu-row-sx dynamic shades - Client sends/receives hash, falls back gracefully on cache miss Co-Authored-By: Claude Opus 4.6 --- .../browser/templates/_types/root/_head.html | 9 +- shared/infrastructure/factory.py | 20 +- shared/static/scripts/sx.js | 78 ++++- shared/static/styles/tailwind.config.js | 40 +++ shared/static/styles/tw.css | 3 +- shared/sx/css_registry.py | 327 ++++++++++++++++++ shared/sx/helpers.py | 117 ++++++- shared/sx/html.py | 14 + shared/sx/jinja_bridge.py | 22 ++ shared/sx/templates/layout.sx | 1 + shared/sx/tests/test_components.py | 2 +- shared/sx/types.py | 1 + 12 files changed, 603 insertions(+), 31 deletions(-) create mode 100644 shared/static/styles/tailwind.config.js create mode 100644 shared/sx/css_registry.py diff --git a/shared/browser/templates/_types/root/_head.html b/shared/browser/templates/_types/root/_head.html index d6ad5b9..e4d7c23 100644 --- a/shared/browser/templates/_types/root/_head.html +++ b/shared/browser/templates/_types/root/_head.html @@ -1,15 +1,8 @@ - - - - - - - - + diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 8e33672..895b2c1 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -117,6 +117,24 @@ def create_base_app( load_shared_components() load_relation_registry() + # Load CSS registry (tw.css → class-to-rule lookup for on-demand CSS) + from shared.sx.css_registry import load_css_registry, registry_loaded + _styles = BASE_DIR / "static" / "styles" + _fa_css = BASE_DIR / "static" / "fontawesome" / "css" + if (_styles / "tw.css").exists() and not registry_loaded(): + load_css_registry( + _styles / "tw.css", + extra_css=[ + _styles / "basics.css", + _styles / "cards.css", + _styles / "blog-content.css", + _styles / "prism.css", + _fa_css / "all.min.css", + _fa_css / "v4-shims.min.css", + ], + url_rewrites={"../webfonts/": "/static/fontawesome/webfonts/"}, + ) + # Dev-mode: auto-reload sx templates when files change on disk if os.getenv("RELOAD") == "true": from shared.sx.jinja_bridge import reload_if_changed @@ -298,7 +316,7 @@ def create_base_app( response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Headers"] = ( - "SX-Request, SX-Target, SX-Current-URL, SX-Components, " + "SX-Request, SX-Target, SX-Current-URL, SX-Components, SX-Css, " "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, " "Content-Type, X-CSRFToken" ) diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 8b01f3f..59b56f0 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -1520,6 +1520,10 @@ }); if (loadedNames.length) headers["SX-Components"] = loadedNames.join(","); + // Send known CSS classes so server only sends new rules + var cssHeader = _getSxCssHeader(); + if (cssHeader) headers["SX-Css"] = cssHeader; + // Extra headers from sx-headers var extraH = el.getAttribute("sx-headers"); if (extraH) { @@ -1647,6 +1651,8 @@ // Strip and load any \n{body}') + # On-demand CSS: scan source for classes, send only new rules + from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded, lookup_css_hash, store_css_hash + from .jinja_bridge import _COMPONENT_ENV + from .types import Component as _Component + new_classes: set[str] = set() + cumulative_classes: set[str] = set() + if registry_loaded(): + new_classes = scan_classes_from_sx(source) + # Include pre-computed helper classes (menu bars, admin nav, etc.) + new_classes.update(HELPER_CSS_CLASSES) + if comp_defs: + # Use pre-computed classes for components being sent + for key, val in _COMPONENT_ENV.items(): + if isinstance(val, _Component) and val.css_classes: + new_classes.update(val.css_classes) + + # Resolve known classes from SX-Css header (hash or full list) + known_classes: set[str] = set() + known_raw = request.headers.get("SX-Css", "") + if known_raw: + if len(known_raw) <= 16: + # Treat as hash + looked_up = lookup_css_hash(known_raw) + if looked_up is not None: + known_classes = looked_up + else: + # Cache miss — send all classes (safe fallback) + known_classes = set() + else: + known_classes = set(known_raw.split(",")) + + cumulative_classes = known_classes | new_classes + new_classes -= known_classes + + if new_classes: + new_rules = lookup_rules(new_classes) + if new_rules: + body = f'\n{body}' + resp = Response(body, status=status, content_type="text/sx") - resp.headers["X-SX-Body-Len"] = str(len(body)) - resp.headers["X-SX-Source-Len"] = str(len(source)) - resp.headers["X-SX-Has-Defs"] = "1" if "{title} {meta_html} - - - - - - - + + @@ -418,7 +482,7 @@ details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details. - + """ @@ -429,9 +493,13 @@ def sx_page(ctx: dict, page_sx: str, *, """Return a minimal HTML shell that boots the page from sx source. The browser loads component definitions and page sx, then sx.js - renders everything client-side. + renders everything client-side. CSS rules are scanned from the sx + source and component defs, then injected as a