gates: site gate 22 - the media-aware CSS leak check the phone bug needed
gates / gates (push) Failing after 12m29s
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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user