gates: site gate 22 - the media-aware CSS leak check the phone bug needed
gates / gates (push) Failing after 12m29s

The two defects the operator found on his own phone are fixed and live since
5aeb36b5; this is what pins them so they cannot come back silently.

site_gates.py gate 22 parses site.css and shell.css into
(media context, selector, declarations) and fails on any property site.css
declares on a selector shell.css also styles that nothing overrides - neither
shell.css itself nor a `.shell ...`-scoped reset. site.css loads first, so
"later wins" is per PROPERTY, not per rule. Today: 689 site rules vs 119 shell
rules, 9 shared selectors, 0 leaks. .hamburger-box / .hamburger-line are an
explicit allow-list with the reason beside them.

It is media-aware precisely because the check it replaces was not. The STOP-1
leak diff excluded @media blocks and reported "exactly six" leaking
declarations; the real number is forty, and the worst of them
(@media (max-width: 900px) .nav-links { position: fixed; right: -100% })
parked the drawer's menu off-canvas and shipped.

Two decoys in test_gate_decoys.py, both the exact shape that went live, both
seen to convict:
  site/shell-css-leak-in-media  the body:not(.shell) scope taken back off that
                                one rule - the gate names the media query, the
                                selector and `right: -280px`
  site/footer-outside-main      <main> closed early on gyik.html - gate 3b's line
They plant and restore BYTES, with the anchor translated to the file's own line
ending: these files are CRLF in a Windows worktree and LF in a Linux one, and
replace_in's text-mode round trip would have rewritten a whole file on one side.

Also: CHANGELOG entries for both areas, the M3 report corrected where it quoted
its own wrong "exactly six", and one plain-language STATUS line.
This commit is contained in:
2026-10-11 18:08:13 +02:00
parent 5aeb36b5bd
commit a7480172ab
6 changed files with 239 additions and 6 deletions
+20
View File
@@ -1,3 +1,23 @@
## 2026-10-11 (evening) — site gate 22: no CSS leak from site.css onto a shell page, MEDIA-AWARE
- `site_gates.py` **gate 22** parses both stylesheets into (media context, selector, declarations) and
fails when `site.css` declares a property on a selector `shell.css` also styles that nothing
overrides — neither `shell.css` itself nor a `.shell …`-scoped reset. `site.css` loads first, so
„later wins" is per **property**, not per rule. Today: 689 site rules vs 119 shell rules, 9 shared
selectors, 0 leaking declarations. `.hamburger-box` / `.hamburger-line` are an explicit allow-list
with the reason beside them (the shell's burger is the website's own button markup).
- **Why it exists.** The morning's STOP-1 leak diff excluded `@media` blocks and reported six leaks
where the media-aware number was forty. The worst of them, `@media (max-width: 900px) .nav-links {
position: fixed; right: -100% }`, parked the drawer's menu off-canvas and shipped: the operator
found an empty phone menu on the live site. The gate parses the media context precisely because the
check it replaces did not.
- **Two decoys** in `test_gate_decoys.py`, both the exact shape that went live, both seen to convict:
`site/shell-css-leak-in-media` (the `body:not(.shell)` scope taken off that one rule → the gate
names the media query, the selector and `right: -280px`) and `site/footer-outside-main` (`<main>`
closed early on `gyik.html` → gate 3b's own line). They plant and restore **bytes**, with the anchor
translated to the file's own line ending: these files are CRLF in a Windows worktree and LF in a
Linux one, and `replace_in`'s text-mode round trip would have rewritten a whole file on one of them.
## 2026-10-10 (afternoon) — site gate 21: every catalog app has its logo under its slug (R-912)
- `site_gates.py` gate 21 reads `app-catalog-felhom.eu/templates/*/.felhom.yml` (the `slug:`, else the directory) and
+104 -1
View File
@@ -31,6 +31,12 @@ Gates (all must pass; non-zero exit on any failure):
19. dash-lang — a page shows dashboard pictures only in its own language (`dashboard-*-hu.webp` / `-en.webp`)
20. viewer — data-gallery openers are <a href> to existing assets; gallery.js (with ?v=) exactly where openers are;
no inline viewer copy
22. shell leak — MEDIA-AWARE: no declaration in site.css reaches a shell page on a selector that
shell.css also styles. site.css loads FIRST, so a property shell.css never sets
survives — „later wins” is per PROPERTY, not per rule. Earned live 2026-10-11:
@media (max-width: 900px) .nav-links { position: fixed; right: -100% } parked the
drawer's items off-canvas and the phone menu was empty. The check that missed it
EXCLUDED @media blocks and reported six leaks where the media-aware number was 40
21. app logos — every catalog app (`app-catalog-felhom.eu/templates/*/.felhom.yml`, by its `slug:`) has
website/assets/<slug>-logo.svg or .png — the dashboard asks for exactly that name and shows a grey
placeholder otherwise (R-912: five apps had theirs under other names). The sibling catalog clone
@@ -631,8 +637,105 @@ else:
else:
print(" app logos: %d catalog apps, each with its <slug>-logo file" % len(_slugs))
# gate 22 (2026-10-11): no CSS leak from site.css onto a shell page — MEDIA-AWARE.
#
# site.css loads BEFORE shell.css, so for a selector both files style, shell.css wins only on the
# properties it actually declares; everything else survives from site.css. That is how the phone menu
# shipped empty: site.css's old mobile nav still said, under @media (max-width: 900px),
# `.nav-links { position: fixed; right: -100% }`, which parked the drawer's items off-canvas. It was
# invisible at desktop width AND invisible to the check that was supposed to find it — that diff
# skipped @media blocks and reported six leaking declarations where the media-aware number was forty.
#
# A leak is: site.css declares property P on selector S, shell.css also styles S, and nothing
# overrides P for S — neither shell.css itself nor a `.shell …`-scoped reset in site.css. Scoping a
# rule `body:not(.shell)` (the fix) takes its selector out of the comparison, which is the point.
def css_rules(css):
"""-> [(media_context, selector, [(prop, value), ...])].
@media / @supports / @layer nest and their context is CARRIED, so a declaration that applies only
on a phone is still seen. @keyframes and @font-face hold no page selectors and are skipped.
"""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
out = []
def walk(text, media):
i, n = 0, len(text)
while i < n:
j = text.find("{", i)
if j < 0:
break
prelude = text[i:j].strip()
depth, k = 1, j + 1
while k < n and depth:
if text[k] == "{":
depth += 1
elif text[k] == "}":
depth -= 1
k += 1
body = text[j + 1:k - 1]
if prelude.startswith("@"):
if prelude.split()[0].lower() in ("@media", "@supports", "@layer"):
walk(body, (media + " AND " + prelude) if media else prelude)
else:
decls = []
for d in body.split(";"):
if ":" in d and "{" not in d:
prop, val = d.split(":", 1)
if prop.strip():
decls.append((prop.strip().lower(), val.strip()))
for sel in prelude.split(","):
sel = re.sub(r"\s+", " ", sel).strip()
if sel:
out.append((media, sel, decls))
i = k
walk(css, "")
return out
# Shared ON PURPOSE: the shell's hamburger is the website's own button markup, so site.css styles the
# same two class names the controller does. Another name here needs the same kind of sentence.
_LEAK_ALLOW = {".hamburger-box", ".hamburger-line"}
_site_css_p = os.path.join(W, "assets", "site.css")
_shell_css_p = os.path.join(W, "assets", "shell.css")
if not (os.path.isfile(_site_css_p) and os.path.isfile(_shell_css_p)):
fail("shell leak: site.css or shell.css is missing — the gate checked nothing")
else:
_site_r = css_rules(io.open(_site_css_p, encoding="utf-8-sig").read())
_shell_r = css_rules(io.open(_shell_css_p, encoding="utf-8-sig").read())
if len(_site_r) < 200 or len(_shell_r) < 50:
fail("shell leak: parsed %d site rules and %d shell rules — too few to be the real files, so "
"nothing was compared" % (len(_site_r), len(_shell_r)))
_shell_sel = set(sel for _, sel, _ in _shell_r)
_overridden = set((sel, prop) for _, sel, decls in _shell_r for prop, _ in decls)
for _, _sel, _decls in _site_r: # a `.shell X { … }` reset in site.css overrides X too
if ".shell" in _sel:
for _prop, _ in _decls:
_overridden.add((_sel.split(" ")[-1], _prop))
_leaks = []
for _media, _sel, _decls in _site_r:
if _sel in _LEAK_ALLOW or ".shell" in _sel or "body:not(.shell)" in _sel:
continue
if _sel not in _shell_sel:
continue
for _prop, _val in _decls:
if (_sel, _prop) not in _overridden:
_leaks.append((_media, _sel, _prop, _val))
if _leaks:
for _media, _sel, _prop, _val in _leaks[:12]:
fail("shell leak: site.css %s`%s { %s: %s }` reaches a shell page — shell.css styles %s but "
"never sets %s, so this one survives. Scope the rule body:not(.shell), or reset the "
"property under .shell" % ((_media + " ") if _media else "", _sel, _prop, _val[:40],
_sel, _prop))
if len(_leaks) > 12:
fail("shell leak: and %d more leaking declaration(s), not listed" % (len(_leaks) - 12))
else:
print(" shell leak: %d site rules vs %d shell rules, %d shared selector(s), 0 leaking "
"declarations (media-aware)" % (len(_site_r), len(_shell_r),
len(set(s for _, s, _ in _site_r) & _shell_sel)))
if fails:
print("\nSITE GATES FAILED: %d problem(s)" % len(fails))
sys.exit(1)
print("site gates OK — BOM, emoji=0, nav/footer per language, analytics, no CDN, no legacy tokens, no <style>, "
"cache-busted assets, twins + hreflang + globe, lang, same apps, no Hungarian in English, FAQ JSON-LD, clean tail, dashboard pictures per language, one picture viewer")
"cache-busted assets, twins + hreflang + globe, lang, same apps, no Hungarian in English, FAQ JSON-LD, clean tail, dashboard pictures per language, one picture viewer, no shell CSS leak")
+50 -1
View File
@@ -91,7 +91,7 @@ COVERS = {
"switch at the wrong twin, lang=\"hu\" on an English page, an app missing from one apps page, "
"ASCII-only Hungarian in English text and in a script message, a 'please', an English "
"retrieval promise the Hungarian does not make, a FAQ JSON-LD drifting from the visible "
"answer, text after </html>, a Hungarian dashboard picture on an English page, and four picture-viewer faults (an opener without href, an opener to a missing file, openers with no gallery.js, the inline viewer put back), and (R-912) a catalog app's logo under another name than its slug — each must convict WITH its own failure line"),
"answer, text after </html>, a Hungarian dashboard picture on an English page, and four picture-viewer faults (an opener without href, an opener to a missing file, openers with no gallery.js, the inline viewer put back), and (R-912) a catalog app's logo under another name than its slug — each must convict WITH its own failure line; and (2026-10-11, both of them reported from a real phone AFTER they went live) site.css's old mobile nav reaching the shell's sidebar from inside an @media block — the leak the STOP-1 check missed because it skipped @media entirely — and a <footer> left outside <main class=\"content\">, which on a shell page renders as a second column"),
"script-tests": ("R-885: FIVE cases in scripts/test_script_tests_gate.py, run from here: a suite that PRINTS "
"OK and exits 1 (label without fact), a failing suite three levels deep (scope is a walk), "
"an empty scripts/ tree (checked nothing), the genuine article (must pass), the nested mark"),
@@ -614,6 +614,55 @@ decoy("site/text-after-html", "site_gates.py",
replace_in(_EN("apps.html"), "</body>\n</html>", "</body>\n</html>\n=====\nSummary: 13 files included\n"),
must="en/apps.html: text after </html>")
# -- site: the two defects that reached a real phone (2026-10-11) --------------------------------
#
# Both shipped in the M3 switch and were reported by the operator from his own phone; neither was
# found here. `replace_in` cannot build them: it round-trips through text mode, and these files are
# CRLF in a Windows worktree and LF in a Linux one, so a text-mode restore would rewrite the whole
# file on one of the two. These plant and restore BYTES, with the anchors translated to the file's
# own line ending.
def replace_bytes(path, pairs):
"""Mutate one or more exact spans of a real file IN BYTES; restore it byte-for-byte.
Anchors are written with \\n and translated at plant time. A span that is no longer there makes
the decoy UNBUILDABLE, which is a failure of this suite — never a quietly skipped case."""
def _plant():
backup = io.open(path, "rb").read()
nl = b"\r\n" if b"\r\n" in backup else b"\n"
cur = backup
for old, new in pairs:
o = old.encode("utf-8").replace(b"\n", nl)
n = new.encode("utf-8").replace(b"\n", nl)
assert cur.count(o) == 1, ("%s: the decoy's anchor %r is gone or ambiguous — rebuild the "
"decoy" % (path, old[:60]))
cur = cur.replace(o, n, 1)
io.open(path, "wb").write(cur)
return lambda: io.open(path, "wb").write(backup)
return _plant
# 1. THE PHONE MENU WAS EMPTY. site.css's old mobile nav still reached the shell's sidebar: under
# @media (max-width: 900px) it parked `.nav-links` off-canvas (position: fixed; right: -280px) —
# inside the open drawer. Desktop looked right, so only a phone showed it, and the STOP-1 check
# that should have caught it diffed the two stylesheets with @media blocks EXCLUDED: it reported
# six leaking declarations where the media-aware number was forty. The decoy takes the
# `body:not(.shell)` scope off that one rule, which is exactly the state that went live.
decoy("site/shell-css-leak-in-media", "site_gates.py",
replace_bytes(os.path.join(_WEB, "assets", "site.css"),
[(" body:not(.shell) .nav-links {\n display: flex;\n flex-direction: column;",
" .nav-links {\n display: flex;\n flex-direction: column;")]),
must="shell leak: site.css @media (max-width: 900px) `.nav-links { right: -280px }`")
# 2. THE FOOTER RENDERED AS A SECOND COLUMN at the top right of 16 pages: body is a flex row on a
# shell page, so a <footer> left outside <main class="content"> is a flex SIBLING of the sidebar
# and the content, not the bottom of the page. The decoy closes <main> early and leaves the footer
# outside it — the shape the insert produced on every page that had a <script> after </main>.
decoy("site/footer-outside-main", "site_gates.py",
replace_bytes(os.path.join(_WEB, "gyik.html"),
[(" <footer>\n", " </main>\n <footer>\n"),
("</footer>\n </main>\n", "</footer>\n")]),
must='gyik.html: <footer> is outside <main class="content">')
# ── hostinstall (R-426, 2026-10-06) ──────────────────────────────────────────────────────────────
#
# Each decoy is a one-line edit a real session would make to felhom-host-install.sh, leaving the LABEL