Files
MichaelScottsman e1d7318559 Light layer only while lit: a third less style work per route change
The illumination ::after (04-states.css) painted nothing at rest but was computed for every host (46-93 per route,
~25 custom properties and six gradients each) at every style recalc. The generated host entries (fontkit.py --hooks)
now carry a :where() gate (hover, active, focus, focus-within, pressed, focus-in, .lgs-lit): the layer exists only
while lit. rt/06-states.js holds .lgs-lit for 600 ms after hover, focus or press ends, so the 441 ms fade-out still
plays; a top-level @starting-style (02-motion.nowrap.css) ramps the light in from dark. Verified: ramps unchanged
(press in 0/.66/.98/1, out .86/.45/.03), no layer at rest; route changes ~48 -> ~55 fps.
2026-10-09 01:52:07 -04:00

661 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Build the Phase 2 UI font (Inter 4.1, SIL OFL 1.1) for in-memory injection.
python docs/phase2/fontkit.py subset -> theme/fonts/InterVariable-lgs.woff2, print sizes
python docs/phase2/fontkit.py --css OUT.css also write the @font-face *.nowrap.css (base64 data: URI)
python docs/phase2/fontkit.py --check verify the subset (axes, features, coverage) and exit
Why a data: URI: the theme is injected as one <style> per Steam/SteamVR window and
nothing is ever written into Steam's files, so the font must travel inside the CSS.
Why a subset: every gamepadui window receives the whole bundle, so each KB counts.
Source: theme/fonts/InterVariable.woff2 (Inter 4.1 release, web/InterVariable.woff2,
352,240 bytes, sha256 693b77d4...a8e3) with its licence in theme/fonts/LICENSE.txt.
The OFL allows subsetting and embedding; Inter declares no Reserved Font Name, so the
subset may keep the name "Inter". We still register it under the private family name
"LGS Inter" so it can never collide with an Inter that a page or the system provides.
The design rules for using it are in docs/phase2/DESIGN2.md §5.
"""
import argparse
import base64
import io
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
SRC = os.path.join(ROOT, "theme", "fonts", "InterVariable.woff2")
OUT = os.path.join(ROOT, "theme", "fonts", "InterVariable-lgs.woff2")
# Western and Central European Latin, Greek, Russian/Ukrainian Cyrillic, punctuation,
# euro/rupee/rouble, arrows and a few math signs: 121 KB as WOFF2 with both axes.
# Measured alternatives (2026-10-07): adding Vietnamese, IPA and Latin Extended-B/Additional
# doubles the file (229 KB); pinning opsz saves 42 KB but loses the display cut for titles.
# CJK, Thai, Arabic, Hebrew, Vietnamese etc. are NOT here on purpose: those codepoints fall
# through to Steam's own font stack (Motiva Sans and its per-language fallbacks), and
# DESIGN2.md §5.2 (step 4) keeps Steam's stack for languages that would otherwise mix fonts.
UNICODES = (
"U+0000-00FF,U+0100-017F,U+0192,U+0218-021B,U+02C6-02C7,U+02D8-02DD,U+0300-0308,"
"U+030A-030C,U+0326-0328,U+0370-03FF,U+0400-045F,U+0490-0491,U+2000-206F,U+20AC,"
"U+20B9,U+20BD,U+2122,U+2190-2193,U+2212,U+2215,U+2248,U+2260,U+2264-2265,U+FEFF,U+FFFD"
)
# OpenType features kept. tnum: clocks, battery %, prices, download sizes.
# case: punctuation centred on capitals. calt/kern/ccmp/locl/mark/mkmk: Inter defaults.
FEATURES = "kern,calt,ccmp,locl,mark,mkmk,case,tnum"
FACE = """/* Phase 2 UI font: Inter 4.1 (c) 2016 The Inter Project Authors, SIL Open Font License 1.1
(theme/fonts/LICENSE.txt). Subset {ranges} - generated by docs/phase2/fontkit.py,
do not edit by hand. Private family name so it never collides with a page's own Inter.
unicode-range lets every other script fall through to Steam's own fonts. */
@font-face {{
font-family: "LGS Inter";
src: url(data:font/woff2;base64,{b64}) format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: block;
unicode-range: {ranges};
}}
"""
# The --lgs-font sweep (DESIGN2 §5.2 step 4). Steam sets "Motiva Sans" explicitly on
# many module classes and Arial on form controls (which do not inherit font-family in
# Chromium), so a body rule alone is not enough: every element in body takes the token,
# except monospace runs. Measured 2026-10-07 on main, bar, barpopup, keyboard,
# notifications, tooltip, frame.menu and vr:systemui: the only families in use are
# "Motiva Sans" stacks and Arial (inputs); no icon font is text-rendered.
SWEEP = """
/* --lgs-font sweep: one face everywhere, Steam's stack after it (tokens.md §4) */
html.lgs-on body {
font-family: var(--lgs-font);
font-synthesis: none;
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
}
/* (0,3,2): beats Steam's module rules, the strongest measured being
".BasicUI .A .B" (0,3,0); areas never set another family */
html.lgs-on.lgs-on.lgs-on body :where(:not(code, pre, kbd, samp, tt, code *, pre *, kbd *, samp *)) {
font-family: var(--lgs-font);
}
"""
def subset():
from fontTools import subset as fts
opts = fts.Options()
opts.flavor = "woff2"
opts.layout_features = FEATURES.split(",")
opts.name_IDs = ["*"] # keep copyright/licence name records (OFL requires the notice)
opts.name_legacy = True
opts.name_languages = ["*"]
opts.notdef_outline = True
opts.hinting = False # CEF on the Frame renders unhinted, antialiased
opts.desubroutinize = True
font = fts.load_font(SRC, opts)
sub = fts.Subsetter(opts)
sub.populate(unicodes=fts.parse_unicodes(UNICODES))
sub.subset(font)
buf = io.BytesIO()
fts.save_font(font, buf, opts)
data = buf.getvalue()
with open(OUT, "wb") as f:
f.write(data)
return data
def check(path):
from fontTools.ttLib import TTFont
f = TTFont(path)
axes = {a.axisTag: (a.minValue, a.maxValue) for a in f["fvar"].axes}
feats = sorted({fr.FeatureTag for fr in f["GSUB"].table.FeatureList.FeatureRecord}
| {fr.FeatureTag for fr in f["GPOS"].table.FeatureList.FeatureRecord})
cmap = f.getBestCmap()
probe = "Liquid Glass ÅÉÎõü ĀŁŒŠ Ελληνικά Кириллица Їжак 0123456789 €£¥ → − ≤"
missing = sorted({c for c in probe if c != " " and ord(c) not in cmap})
print(f"{path}: {os.path.getsize(path):,} bytes, {len(f.getGlyphOrder()):,} glyphs, "
f"{len(cmap):,} codepoints")
print(f" axes {axes}")
print(f" features {' '.join(feats)}")
print(f" probe missing: {missing or 'none'}")
copyright_ = f["name"].getDebugName(0)
print(f" name ID 0: {copyright_}")
return not missing and "wght" in axes and "opsz" in axes
# ---------------------------------------------------------------- token parity (FD-3)
# P4 owns the theme tokens and the mockup kit, so the kit's token check lives here too:
# python docs/phase2/fontkit.py --parity [--json]
# Every kit token --lg-X whose theme twin --lgs-X exists (theme/00-tokens.nowrap.css, P5's
# theme/02-motion.nowrap.css) must hold the same value. Values are compared after
# normalising whitespace, number spelling (.10 = 0.1) and var(--lg-…) = var(--lgs-…).
KIT = os.path.join(HERE, "mockups", "kit.css")
THEME_TOKEN_FILES = [os.path.join(ROOT, "theme", "00-tokens.nowrap.css"),
os.path.join(ROOT, "theme", "02-motion.nowrap.css")]
def _strip_comments(text):
import re
return re.sub(r"/\*.*?\*/", "", text, flags=re.S)
def _first_block(text, opener):
"""Declarations of the first top-level block whose selector is `opener`."""
import re
text = _strip_comments(text)
m = re.search(r"(^|\n)\s*" + re.escape(opener) + r"\s*\{", text)
if not m:
return {}
i, depth, start = m.end(), 1, m.end()
while i < len(text) and depth:
depth += {"{": 1, "}": -1}.get(text[i], 0)
i += 1
body = text[start:i - 1]
decls = {}
for part in re.split(r";(?![^(]*\))", body):
if ":" not in part:
continue
k, v = part.split(":", 1)
k = k.strip()
if k.startswith("--"):
decls[k] = v.strip()
return decls
def _norm(v):
import re
v = re.sub(r"\s+", " ", v.strip()).lower()
v = v.replace("var(--lg-", "var(--lgs-")
v = re.sub(r"\(\s+", "(", v)
v = re.sub(r"\s+\)", ")", v)
v = re.sub(r"\s*,\s*", ", ", v)
def num(m):
f = float(m.group(0))
return ("%g" % f)
return re.sub(r"(?<![\w#.-])-?(?:\d+\.?\d*|\.\d+)(?![\d.])", num, v)
def parity(as_json=False):
with open(KIT, encoding="utf-8") as f:
kit = _first_block(f.read(), ":root")
theme = {}
for p in THEME_TOKEN_FILES:
if os.path.exists(p):
with open(p, encoding="utf-8") as f:
theme.update(_first_block(f.read(), "html.lgs-on"))
shared, diff, kit_only = [], [], []
for k, v in sorted(kit.items()):
tk = "--lgs-" + k[len("--lg-"):]
if tk not in theme:
kit_only.append(k)
continue
if _norm(v) == _norm(theme[tk]):
shared.append(k)
else:
diff.append({"kit": k, "kitValue": v, "theme": tk, "themeValue": theme[tk]})
res = {"kit": os.path.relpath(KIT, ROOT), "kitTokens": len(kit), "themeTokens": len(theme),
"shared": len(shared), "equal": len(shared), "different": diff, "kitOnly": kit_only,
"pass": not diff}
if as_json:
print(json.dumps(res, indent=1))
else:
print(f"kit {len(kit)} tokens, theme {len(theme)}; shared {len(shared) + len(diff)}: "
f"{len(shared)} equal, {len(diff)} different")
for d in diff:
print(f" DIFF {d['kit']}: kit {d['kitValue']!r} vs theme {d['themeValue']!r}")
print(f" kit-only (no theme twin): {', '.join(kit_only) or 'none'}")
print("PASS" if not diff else "FAIL")
return 0 if not diff else 1
# ---------------------------------------------------------------- hook hosts (G-PERF, review R1 M1)
# python docs/phase2/fontkit.py --hooks rewrite the generated host lists in 03-material / 04-states
# python docs/phase2/fontkit.py --hooks-check exit 1 when a list is stale (an area added or moved a hook)
# The hooks (--lgs-ill, --lgs-edge, --lgs-scroll-band) are style queries on a pseudo-element. Their subject must
# not be universal: a universal *::after inside @container makes Blink resolve ::before/::after for every element on
# every style recalc, which cost the library grid ~20 % of its scroll frame rate (review R1 M1; P4 microbenchmark:
# a full recalc of the 800-element poster grid 122-158 ms with universal subjects, 73-77 ms without). So each hook
# rule's subject is a generated flat list of the elements the theme opts in, one `:where(host)::pseudo` entry per
# host (bucketed by Blink; a nested `&` or one big :where() list is matched against every element): for each theme
# rule that sets the property to a kind, the rightmost compound of its selector without pseudo-classes (a superset
# of the hosts; the style query still decides), or the whole selector when that compound has no class, id,
# attribute or token, with per-kind lists for the per-kind rules. Scroll A/B on the grid: listed -1.5 %, universal
# -27 % against no hooks. Files are read the way P1's bundler reads them (theme/*.css, "_"/"." names skipped).
HOOK_FAMILIES = (("ill", "--lgs-ill", "04-states.css"), ("edge", "--lgs-edge", "03-material.css"),
("band", "--lgs-scroll-band", "03-material.css"))
HOOK_SKIP_AT = ("@keyframes", "@-webkit-keyframes", "@font-face", "@property", "@counter-style", "@page",
"@font-feature-values", "@import", "@charset", "@namespace")
HOOK_OFF = {"", "none", "initial", "unset", "revert", "revert-layer"}
HOOK_WEAK = {".Panel", ".Focusable"}
HOOK_KINDS = {"ill": ("raised", "recessed", "row", "nav", "white", "card"),
"edge": ("window", "panel", "liquid", "thick", "clear", "control"), "band": ("top", "bottom")}
def _hk_protect(text):
"""Comments out; strings and %{Token}s replaced by n so braces inside them are inert."""
out, keep, i, n = [], [], 0, len(text)
while i < n:
if text.startswith("/*", i):
j = text.find("*/", i + 2)
i = n if j < 0 else j + 2
out.append(" ")
continue
c = text[i]
if c in "\"'":
j = i + 1
while j < n and text[j] != c:
j += 2 if text[j] == "\\" else 1
keep.append(text[i:j + 1])
out.append("%d" % (len(keep) - 1))
i = j + 1
continue
if text.startswith("%{", i):
j = text.find("}", i)
if j > 0:
keep.append(text[i:j + 1])
out.append("%d" % (len(keep) - 1))
i = j + 1
continue
out.append(c)
i += 1
return "".join(out), keep
def _hk_restore(s, keep):
import re
return re.sub("(\\d+)", lambda m: keep[int(m.group(1))], s)
def _hk_tree(text):
"""[(prelude, decls, children)] of a protected stylesheet; at-rule preludes start with '@'."""
root = {"prelude": None, "decls": [], "kids": []}
stack, buf, depth = [root], [], 0
for c in text:
if c in "([":
depth += 1
elif c in ")]":
depth = max(0, depth - 1)
if depth == 0 and c == "{":
node = {"prelude": "".join(buf).strip(), "decls": [], "kids": []}
stack[-1]["kids"].append(node)
stack.append(node)
buf = []
elif depth == 0 and c == ";":
stack[-1]["decls"].append("".join(buf).strip())
buf = []
elif depth == 0 and c == "}":
if "".join(buf).strip():
stack[-1]["decls"].append("".join(buf).strip())
buf = []
if len(stack) > 1:
stack.pop()
else:
buf.append(c)
return root
def _hk_split(s, seps=","):
"""Split at top level (outside (), [])."""
out, cur, depth = [], [], 0
for c in s:
if c in "([":
depth += 1
elif c in ")]":
depth -= 1
if depth == 0 and c in seps:
out.append("".join(cur))
cur = []
else:
cur.append(c)
out.append("".join(cur))
return [x.strip() for x in out if x.strip()]
def _hk_compounds(sel):
"""Compounds of a complex selector (combinators dropped)."""
import re
s = re.sub(r"\s*([>+~])\s*", r" \1 ", sel.strip())
return [c for c in _hk_split(s, " \t\n") if c not in (">", "+", "~")]
def _hk_simple(compound):
"""Simple selectors of a compound: '&', '*', tags, .class, #id, [attr], tokens, :pseudo(...)."""
import re
parts, i, n = [], 0, len(compound)
while i < n:
c = compound[i]
if c == "":
j = compound.index("", i)
parts.append(compound[i:j + 1])
i = j + 1
elif c == "[":
depth, j = 0, i
while j < n:
depth += {"[": 1, "]": -1}.get(compound[j], 0)
j += 1
if depth == 0:
break
parts.append(compound[i:j])
i = j
elif c == ":":
j = i + 2 if compound.startswith("::", i) else i + 1
m = re.match(r"[\w-]*", compound[j:])
j += m.end()
if j < n and compound[j] == "(":
depth = 0
while j < n:
depth += {"(": 1, ")": -1}.get(compound[j], 0)
j += 1
if depth == 0:
break
parts.append(compound[i:j])
i = j
elif c in ".#":
m = re.match(r"[.#](?:\\.|[\w-])+", compound[i:])
parts.append(m.group(0) if m else c)
i += len(m.group(0)) if m else 1
elif c in "&*":
parts.append(c)
i += 1
else:
m = re.match(r"(?:\\.|[\w-])+", compound[i:])
if not m:
i += 1
continue
parts.append(m.group(0))
i += m.end()
return parts
def _hk_resolve(sel, parent):
"""The selector with '&' made explicit against its parent (None: top level of a wrapped file, '&' stays)."""
if parent is None:
return sel
if "&" in sel:
return sel.replace("&", ":is(%s)" % parent)
return ("%s %s" % (":is(%s)" % parent, sel)).strip()
def _hk_anchors(sel, parent_anchors):
"""Anchor compounds (a superset of what `sel` matches), or None when only the whole selector will do."""
comps = _hk_compounds(sel)
if not comps:
return None
parts = _hk_simple(comps[-1])
keep = [p for p in parts if p[0] in ".#[" or (p[0].isalpha() and p not in ("&", "*"))]
own = "".join(keep)
if "&" in parts and parent_anchors:
# "&.x" / "&[a]": the parent's anchors narrowed by this compound's own parts
return [(pa if " " not in pa and ">" not in pa else ":is(%s)" % pa) + own for pa in parent_anchors]
# Steam's literal .Panel / .Focusable sit on most nodes of a page: never an anchor on their own;
# an attribute alone anchors only a one-compound selector (else the whole selector, which is exact)
if any(p[0] in ".#" and p not in HOOK_WEAK for p in keep) or (keep and len(comps) == 1):
return [own]
out = []
for p in parts:
if p.startswith((":is(", ":where(", ":matches(")):
inner = p[p.index("(") + 1:-1]
for arg in _hk_split(inner):
a = _hk_anchors(arg, parent_anchors)
if a is None:
return None
out += a
return out or None
def hook_hosts(root=ROOT):
"""{list name: sorted host selectors}, %{tokens} as written. Names: "ill", "edge", "band" (every host of the
family), "ill:<kind>", "edge:<kind>", "band:<kind>" (the hosts that set that kind; a value that is not a plain
kind, e.g. a var(), counts for every kind), and "ring" (the --lgs-ill hosts' whole selectors, exact, for the
FocusRing plate's "the focused control lights itself" test)."""
import re
theme = os.path.join(root, "theme")
names = sorted(n for n in os.listdir(theme) if n.endswith(".css") and not n.startswith(("_", ".", "#"))
and not n.endswith("~"))
found = {"ring": {}}
for fam, _, _ in HOOK_FAMILIES:
found[fam] = {}
for k in HOOK_KINDS[fam]:
found[fam + ":" + k] = {}
keeps = {}
for name in names:
with open(os.path.join(theme, name), encoding="utf-8") as fh:
text, keep = _hk_protect(fh.read())
wrapped = not name.endswith(".nowrap.css")
def add(key, sels):
for s in sels:
if "::" in s: # a pseudo-element cannot host one
continue
found[key].setdefault(s, set()).add(name)
def walk(node, fulls, rule_anchors, skip):
for d in node["decls"]:
m = re.match(r"(--[\w-]+)\s*:\s*(.*)$", d, re.S)
if not m or skip or fulls is None:
continue
for fam, prop, _ in HOOK_FAMILIES:
v = m.group(2).replace("!important", "").strip().lower()
if m.group(1) != prop or v in HOOK_OFF:
continue
add(fam, rule_anchors)
for k in (HOOK_KINDS[fam] if v not in HOOK_KINDS[fam] else (v,)):
add(fam + ":" + k, rule_anchors)
if fam == "ill":
add("ring", fulls)
for kid in node["kids"]:
pre = kid["prelude"]
if pre.startswith("@"):
walk(kid, fulls, rule_anchors, skip or pre.lower().startswith(HOOK_SKIP_AT))
continue
rule_sel = ", ".join(fulls) if fulls is not None else None
sels, anchors = [], []
for part in _hk_split(pre):
if rule_sel is None and wrapped:
part = re.sub(r"^&\s+(?=[^\s>+~])", "", part) # "& X" at the top = X under html.lgs-on
full = _hk_resolve(part, rule_sel)
if rule_sel is None and not wrapped:
full = ":is(%s)" % full # absolute (nowrap file)
sels.append(full)
a = _hk_anchors(part, rule_anchors)
anchors += [full] if a is None else a # None: only the whole selector will do
walk(kid, sels, anchors, skip)
walk(_hk_tree(text), None, [], False)
keeps[name] = keep
out = {}
for key in found:
# %{tokens} back as written (placeholders are per file)
sels = set()
for s, files in found[key].items():
for fname in files:
sels.add(_hk_restore(s, keeps[fname]))
# drop a compound whose simple selectors include all of a shorter entry's (a superset is already listed)
def skey(s):
t, k = _hk_protect(s)
return frozenset(_hk_restore(p, k) for p in _hk_simple(t)) if len(_hk_compounds(t)) == 1 else None
keyed = {s: skey(s) for s in sels}
final = [s for s in sels if not (keyed[s] and any(o != s and keyed[o] and keyed[o] < keyed[s]
for o in sels))]
out[key] = sorted(final)
return out
HOOK_FILES = ("03-material.css", "04-states.css")
# /* lgs-hosts <list> <::pseudo> begin */ ... /* lgs-hosts <list> <::pseudo> end */ (a flat selector list)
# /* lgs-hosts ring begin */ ... /* lgs-hosts ring end */ (the FocusRing test)
HOOK_MARK = r"(/\* lgs-hosts (%s) begin \*/\n)(.*?)(\n/\* lgs-hosts \2 end \*/)"
def _hook_block(name, hosts):
"""The generated selector text of one marked block. Each host is its own entry, `:where(host)::pseudo`, in a
flat list: Blink files each entry under its rightmost class (a nested `&` or one big :where() list sits in the
universal bucket and was matched against every element: +29 % style recalc on the poster grid against +8 % flat,
R1 microbenchmark), and :where() keeps the specificity the universal subject had (0,1,2), so area rules on a
host's pseudo-element still win. The placeholder class keeps a list non-empty."""
if name == "ring":
return "&.lgs-on:has(.gpfocus:where(\n %s))" % ",\n ".join([".lgs-hosts-ill"] + hosts)
lst, pseudo = name.split(" ")
ph = ".lgs-hosts-" + lst.replace(":", "-")
if pseudo.startswith("where"):
# one :where() list, for a rule that needs a prefix (a few hosts only: it is matched everywhere)
return ":where(%s)%s" % (", ".join([ph] + hosts), pseudo[len("where"):])
# The light layer (ill ::after) paints nothing at rest (every term is a state number), but computing it was a
# third of the style work of a route change (46-93 hosts, ~25 custom properties and six gradients each, at
# every recalc). Its hosts get the pseudo-element only while lit, or fading out (.lgs-lit, rt/06-states.js),
# gated with :where() so the specificity stays (0,1,2).
gate = ":where(%s)" % ILL_LIT if lst.split(":")[0] == "ill" and pseudo == "::after" else ""
return ",\n".join(" :where(%s)%s%s" % (h, gate, pseudo) for h in [ph] + hosts)
# The states in which an ill host's light layer exists (any of its state numbers can be non-zero), plus the
# fade-out hold the states runtime sets after the last of them ends
ILL_LIT = ":hover, :active, .gpfocus, .gpfocuswithin, .lgs-pressed, .lgs-focus-in, .lgs-lit"
def hooks(check_only=False, as_json=False, root=ROOT):
import re
hosts = hook_hosts(root)
stale, written, blocks = [], [], 0
pat = re.compile(HOOK_MARK % r"ring|[\w:-]+ (?:where)?::[\w-]+", re.S)
for fname in HOOK_FILES:
path = os.path.join(root, "theme", fname)
with open(path, encoding="utf-8") as fh:
text = fh.read()
out, pos, changed = [], 0, False
for m in pat.finditer(text):
name = m.group(2)
lst = "ring" if name == "ring" else name.split(" ")[0]
if lst not in hosts:
print(f"hooks: unknown list '{lst}' in theme/{fname}", file=sys.stderr)
return 2
blocks += 1
new = _hook_block(name, hosts[lst])
out.append(text[pos:m.start(3)])
out.append(new)
pos = m.end(3)
if m.group(3) != new:
changed = True
if name not in stale:
stale.append(name)
out.append(text[pos:])
if changed and not check_only:
with open(path, "w", encoding="utf-8", newline="\n") as fh:
fh.write("".join(out))
written.append(fname)
if not blocks:
print("hooks: no 'lgs-hosts' blocks found", file=sys.stderr)
return 2
res = {"blocks": blocks, "hosts": {k: len(v) for k, v in hosts.items()}, "stale": stale,
"written": written, "pass": not (check_only and stale)}
if as_json:
res["list"] = hosts
print(json.dumps(res, indent=1))
else:
fams = ", ".join(f"{k} {len(hosts[k])}" for k in ("ill", "edge", "band", "ring"))
print(f"hook hosts: {fams}; {blocks} blocks"
+ ("; stale: " + ", ".join(stale) if stale else "; up to date")
+ ("; rewrote " + ", ".join(written) if written else ""))
if check_only and stale:
print("FAIL: run `python docs/phase2/fontkit.py --hooks` (any package may; contracts/tokens.md §2)")
return 1 if (check_only and stale) else 0
# The G-PERF gate for the hooks (review R1 M1): a scroll A/B on the library grid with P4's hook rules as shipped
# ("cur"), removed ("none") and with the old universal subject ("univ"), by CSSOM delete/insert (the sheet digest is
# checked equal afterwards). Run (lab lock, ~35 s; keep rounds * variants * ms under the lab's 60 s):
# python glass.py js "(window.__P4AB = {route: '/library/tab/AllGames', mode: 'scroll', ms: 2500, rounds: 2}, $(python docs/phase2/fontkit.py --perf-js))"
# mode 'recalc' times a forced style recalc of the grid's subtree instead. Pass: cur within 5 % of none.
PERF_JS = r'''(async () => {
// P4 G-PERF A/B (R1): P4's hook rules as shipped ("cur"), removed ("none"), or with the old universal
// subject ("univ": every generated host list replaced by *). CSSOM delete/insert; sheet digest checked after.
const L = window.__LGS_LAB;
const CFG = window.__P4AB || {};
const ms = CFG.ms || 3000, rounds = CFG.rounds || 3, variants = CFG.variants || ['cur', 'none', 'univ'], mode = CFG.mode || 'recalc', reps = CFG.reps || 10;
if (CFG.route) { await L.nav(CFG.route); await L.sleep(1500); }
const w = L.surface('main'), doc = w.document;
const sheet = doc.getElementById('lgs-theme').sheet;
const digest = () => { let h = 0; const t = [...sheet.cssRules].map((r) => r.cssText).join('\n'); for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; return h + ':' + t.length; };
const d0 = digest();
// P4's hook rules: container rules (or media rules holding them) whose style rules list lgs-hosts
const hooks = [];
const has = (r) => /lgs-hosts-(ill|edge|band)/.test(r.cssText) && !/:has\(\.gpfocus/.test(r.cssText);
const walk = (list) => { for (const r of list) { if ((r instanceof w.CSSContainerRule || r instanceof w.CSSMediaRule) && has(r)) hooks.push({ parent: r.parentRule || sheet, rule: r, text: r.cssText, cur: r }); else if (r.cssRules && !(r instanceof w.CSSContainerRule)) walk(r.cssRules); } };
walk(sheet.cssRules);
const univText = (t) => t.replace(/((?:^|[{};]\s*))((?:(?:&\s*)?:where\([^{}]*?\)::(?:before|after),?\s*)+)\{/g, (m, a, list) => a + '& *::' + (/::before/.test(list) ? 'before' : 'after') + ' {');
const removeAll = () => { for (const h of hooks.slice().reverse()) { const i = [...h.parent.cssRules].indexOf(h.cur); if (i >= 0) { h.idx = i; h.parent.deleteRule(i); } h.cur = null; } };
const insertAll = (fn) => { for (const h of hooks) { h.parent.insertRule(fn ? fn(h.text) : h.text, h.idx); h.cur = h.parent.cssRules[h.idx]; } };
let best = null, area = 0;
for (const el of doc.querySelectorAll('div')) { if (el.scrollHeight - el.clientHeight < 200) continue; const r = el.getBoundingClientRect(); if (r.width * r.height <= area) continue; const cs = w.getComputedStyle(el); if (!/(auto|scroll)/.test(cs.overflowY)) continue; area = r.width * r.height; best = el; }
const runScroll = () => new Promise((done) => {
const times = []; let dir = 1; const t0 = w.performance.now(); const start = best.scrollTop;
const step = (t) => { times.push(t); const max = best.scrollHeight - best.clientHeight; const cur = best.scrollTop; if (cur >= max - 8) dir = -1; else if (cur <= 8) dir = 1; best.scrollTop = cur + dir * 14; if (t - t0 < ms) w.requestAnimationFrame(step); else { best.scrollTop = start; const d = []; for (let i = 1; i < times.length; i++) d.push(times[i] - times[i - 1]); d.sort((a, b) => a - b); done([Math.round((times.length - 1) / ((times[times.length - 1] - times[0]) / 1000) * 10) / 10, Math.round(d[Math.floor(.95 * d.length)] * 10) / 10, d.filter((x) => x > 34).length]); } };
w.requestAnimationFrame(step);
});
const runRecalc = async () => {
const t = []; const probe = best.lastElementChild || best;
for (let i = 0; i < reps; i++) { best.style.setProperty('--p4-bench', String(i % 2 ? 1 : 2)); const t0 = w.performance.now(); w.getComputedStyle(probe).color; void best.offsetTop; t.push(w.performance.now() - t0); await new Promise((r) => setTimeout(r, 20)); }
best.style.removeProperty('--p4-bench'); t.sort((x, y) => x - y); return [Math.round(t[Math.floor(t.length / 2)] * 100) / 100];
};
const res = {}; const T0 = Date.now();
let univOk = null;
try {
for (let k = 0; k < rounds; k++) {
for (const v of (k % 2 ? variants.slice().reverse() : variants)) {
if (v !== 'cur') { removeAll(); if (v === 'univ') { insertAll(univText); if (univOk == null) univOk = hooks.map((h) => h.cur.cssText).join('').split('& ::').length - 1; } }
await L.sleep(300);
(res[v] = res[v] || []).push(mode === 'scroll' ? await runScroll() : await runRecalc());
if (v !== 'cur') { removeAll(); insertAll(null); }
}
}
} finally {
if (hooks.some((h) => !h.cur)) { removeAll(); insertAll(null); }
res.digest = d0 === digest() ? 'equal' : 'DIFF';
}
for (const v of variants) { const a = res[v]; res[v + '_mean'] = Math.round(a.reduce((s, x) => s + x[0], 0) / a.length * 10) / 10; }
res.ms = Date.now() - T0; res.hookRules = hooks.length; res.univSubjects = univOk; res.bytes = hooks.reduce((s, h) => s + h.text.length, 0);
res.route = L.route();
return JSON.stringify(res);
})()
'''
def main(argv):
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
ap = argparse.ArgumentParser()
ap.add_argument("--css", help="write the @font-face nowrap stylesheet here")
ap.add_argument("--check", action="store_true")
ap.add_argument("--parity", action="store_true", help="kit tokens against theme tokens (FD-3)")
ap.add_argument("--hooks", action="store_true", help="rewrite the generated hook host lists (G-PERF)")
ap.add_argument("--hooks-check", action="store_true", help="exit 1 when a hook host list is stale")
ap.add_argument("--perf-js", action="store_true", help="print the hooks' G-PERF A/B probe for glass.py js")
ap.add_argument("--json", action="store_true")
a = ap.parse_args(argv)
if a.parity:
return parity(a.json)
if a.perf_js:
print(PERF_JS.strip())
return 0
if a.hooks or a.hooks_check:
return hooks(check_only=a.hooks_check, as_json=a.json)
if a.check:
return 0 if check(OUT) else 1
data = subset()
b64 = base64.b64encode(data).decode("ascii")
print(f"source {os.path.getsize(SRC):,} B -> subset {len(data):,} B -> base64 {len(b64):,} B")
ok = check(OUT)
if a.css:
css = FACE.format(b64=b64, ranges=UNICODES.replace(",", ", ")) + SWEEP
with open(a.css, "w", encoding="utf-8", newline="\n") as f:
f.write(css)
print(f"wrote {a.css} ({len(css):,} bytes)")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))