#!/usr/bin/env python3 """Generate conf/custom.css from the Australis Skyfall source in this directory. Homepage serves exactly two files out of its config dir — custom.css and custom.js — with no static route for anything alongside them. A @font-face pointing at a vendored .woff2 would therefore 404, and an @import of a sibling token file would 404 too. So this build does three things that cannot be done in plain CSS: 1. inlines the font faces as data: URIs; 2. concatenates the vendored token files into one sheet; 3. bridges Skyfall's `[data-theme="light"]` selector to the `light` CLASS Homepage actually sets on . Usage: python3 stacks/homepage/theme/build.py Then: scripts/deploy-stack.sh esh-docker-vm homepage --conf (a settings.yaml change needs a container recreate; a custom.css change needs only a browser reload — custom.css is served per request) """ import base64 import hashlib import pathlib import re import sys HERE = pathlib.Path(__file__).resolve().parent OUT = HERE.parent / "conf" / "custom.css" # --------------------------------------------------------------------------- # VENDORED, VERBATIM, FROM THE SKYFALL BUNDLE. Concatenated in this order: # families before semantics, tokens before bindings. # # The hashes are the anti-fork guard. The theme this one replaces built a # parallel palette "derived from the Australis philosophy" instead of using the # system's own values, and it had to be torn out twice. A vendored file is # either byte-identical to the bundle or it is a fork wearing the bundle's # name; there is no third state, so a mismatch FAILS the build rather than # warning. To legitimately update one, drop in the new file and record its new # hash in the same commit. # --------------------------------------------------------------------------- VENDORED = { "colors.css": "75f78b674dd7fd87ac414cf04165f0e99b016e54d3b546f0e96378c08fdd7b88", "layout.css": "a3826169b7a6b604d9c456c073ba3788297ae88bb703862a95171e3858f94c56", "typography.css": "2ec1a667ced0653292eed0f724b8bc294765d740531ee10e48ebda5f5ca6f2f8", } # Our own bindings — the only file in this directory that is ours to edit. BINDINGS = "skyfall.css.in" # --------------------------------------------------------------------------- # Skyfall keys light mode off `[data-theme="light"]` on . Homepage never # touches data attributes, and — this is the constraint that shapes everything # below — it will not give us its own theme toggle either: rendering that # toggle requires settings.yaml to leave `theme:` unpinned, and an unpinned # `theme:` makes the page's data loader throw (measured 2026-08-24; see # conf/custom.js for the evidence). So `theme: dark` stays pinned, Homepage's # class is always `dark`, and light mode is driven by `data-theme` written by # our own toggle in custom.js, or by the OS preference when nothing is stored. # # Editing the vendored file to add those selectors would fork it; hand-copying # its light block into the bindings would drift the moment the bundle updates. # So re-emit the block here, mechanically, in three forms: # # [data-theme="light"], html.light explicit choice (our toggle; also the # spelling a real Skyfall app would use, # kept so the sheet stays portable) # @media (prefers-color-scheme: light) # html:not([data-theme=...]) the OS preference, and ONLY while no # explicit choice is present — the :not() # pair is what makes a stored "dark" # survive a light-mode OS. # --------------------------------------------------------------------------- LIGHT_BLOCK = re.compile(r'^\[data-theme="light"\]\s*\{\n(.*?)^\}', re.M | re.S) def expand_light(match: "re.Match[str]") -> str: body = match.group(1).rstrip("\n") return ( '[data-theme="light"],\nhtml.light {\n' + body + "\n}\n\n" "@media (prefers-color-scheme: light) {\n" ' html:not([data-theme="dark"]):not([data-theme="light"]) {\n' + body + "\n }\n}" ) # One VARIABLE face per stand-in family — the whole 100-900 axis in a single # file — plus Supreme's three static cuts, which is how Supreme ships. Latin # subset only. See the type note in skyfall.css.in for why two of the three # families are substitutions. FONTS_VARIABLE = [ ("Space Grotesk", "SpaceGrotesk-Variable.woff2"), # stands in for Bespoke Sans ("JetBrains Mono", "JetBrainsMono-Variable.woff2"), # stands in for Victor Mono ] FONTS_STATIC = [ ("Supreme", "Supreme-400.woff2", 400), ("Supreme", "Supreme-500.woff2", 500), # Supreme ships no 600 cut; 600 and 700 both resolve to this file. ("Supreme", "Supreme-700.woff2", "600 700"), ] # Tokens the generated sheet cannot work without. A vendoring mistake that # drops a block produces a stylesheet that parses fine and renders nothing, so # assert on the values rather than trusting the copy. One from each vendored # file, plus proof the light theme survived. REQUIRED = [ "--sea-15", # colors.css, family layer "--surface-card", # colors.css, semantic layer "--shadow-sm", # layout.css "--tracking-caps", # typography.css "html.light", # the explicit-choice selector was emitted "prefers-color-scheme: light", # the OS-preference copy was emitted "#skyfall-theme-toggle", # the toggle has styling, not just behaviour ] def sha256(path: pathlib.Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def font_faces() -> str: out = [] for family, filename in FONTS_VARIABLE: path = HERE / "fonts" / filename if not path.exists(): sys.exit(f"missing font: {path}") b64 = base64.b64encode(path.read_bytes()).decode("ascii") out.append( f"@font-face {{\n" f' font-family: "{family}";\n' f" src: url(data:font/woff2;base64,{b64}) format('woff2');\n" f" font-weight: 100 900;\n" # variable axis f" font-style: normal;\n" f" font-display: swap;\n" f"}}" ) for family, filename, weight in FONTS_STATIC: path = HERE / "fonts" / filename if not path.exists(): sys.exit(f"missing font: {path}") b64 = base64.b64encode(path.read_bytes()).decode("ascii") out.append( f"@font-face {{\n" f' font-family: "{family}";\n' f" src: url(data:font/woff2;base64,{b64}) format('woff2');\n" f" font-weight: {weight};\n" f" font-style: normal;\n" f" font-display: swap;\n" f"}}" ) return "\n".join(out) def vendored_css() -> str: """Concatenate the vendored token files, refusing to build if one moved.""" parts, drift = [], [] for name, expected in VENDORED.items(): path = HERE / name if not path.exists(): sys.exit( f"missing vendored token file: {path}\n" f"Recover it with: git show 45c1995:stacks/homepage/theme/{name}" ) actual = sha256(path) if actual != expected: drift.append((name, actual, expected)) parts.append(f"/* ==== vendored: {name} ({actual[:12]}) ==== */\n{path.read_text()}") if drift: for name, actual, expected in drift: print(f"VENDOR DRIFT: {name}", file=sys.stderr) print(f" on disk : {actual}", file=sys.stderr) print(f" expected: {expected}", file=sys.stderr) sys.exit( "A vendored Skyfall token file has been modified. These are copied " "verbatim from the bundle and are not ours to edit — put the " "override in skyfall.css.in instead. If the bundle itself was " "legitimately updated, record the new hash in VENDORED in the same " "commit as the file." ) print(f"vendored token files verified: {', '.join(VENDORED)}") return "\n\n".join(parts) def main() -> None: src_path = HERE / BINDINGS if not src_path.exists(): sys.exit(f"missing source: {src_path}") src = src_path.read_text() if "/* @@FONTS@@ */" not in src: sys.exit(f"{BINDINGS} lost its @@FONTS@@ placeholder") tokens = vendored_css() bridged, n = LIGHT_BLOCK.subn(expand_light, tokens) if not n: sys.exit( 'no [data-theme="light"] rule found in the vendored tokens — the ' "light theme would silently never apply" ) print(f'expanded {n} [data-theme="light"] block(s): explicit + OS-preference') css = bridged + "\n\n" + src.replace("/* @@FONTS@@ */", font_faces()) for token in REQUIRED: if token not in css: sys.exit(f"generated css has no {token} — a token block did not survive") OUT.write_text(css) print(f"wrote {OUT} ({len(css) / 1024:.0f} KB)") if __name__ == "__main__": main()