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
+32 -3
View File
@@ -75,7 +75,8 @@ deliberately not built** (your brief) — the shell should settle first.
paragraph. Found by a word-level diff.
4. **The sidebar rendered as a horizontal row** across the content column, because „later wins" is
per property and the controller's `.nav-links` never declares `display`. Diffing the two files'
declaration sets found six such leaks; only this one was visible.
declaration sets found six such leaks; only this one was visible. **That diff was wrong, and the
section below says how** — the real number was forty.
5. **The contact pages' header slid under the sidebar** (content column at x=642 instead of 240,
form at two-thirds width) because those pages keep their header outside `<main>`.
@@ -87,6 +88,32 @@ deliberately not built** (your brief) — the shell should settle first.
*controller's* `.btn-primary`, which the shell imports; it measured 4.01:1 again. Your existing
remedy applied. `.nav-group-label` was 3.14:1 and moved to `--text-2`.
## Two defects a real phone found, after the switch
Reported by you, from your own phone, hours after it went live. Both were mine. Fixed, live as
`?v=23`, and read back on all 20 pages at 390 / 860 / 1440 px.
1. **The phone menu was empty.** The drawer slid open correctly and had nothing in it. `site.css`'s
old mobile nav still reached the shell's sidebar: under `@media (max-width: 900px)` it sets
`.nav-links { position: fixed; right: -100% }`, which parked the menu off-canvas *inside* the open
drawer. Every old-chrome selector (29 occurrences) is now scoped `body:not(.shell)` — scoped, not
deleted, so a page without the shell still works.
2. **The footer rendered as a second column** at the top right of 16 pages. `body` is a flex row on a
shell page, and my insert moved the footer inside `<main>` only on pages that *ended* with
`</main>`; a page with a `<script>` after it kept the footer as a direct child of `body`.
**The first one is a failure of my own check, not bad luck.** The leak diff I ran at STOP 1 — the one
whose „exactly six" this report quoted above — **excluded `@media` blocks**. Media-aware, the number
is **forty**, and the worst of them only apply below 900 px, which is exactly why the desktop screens
I took looked right. A check that silently skips part of its subject is not a check: it returned a
confident, wrong number, and I believed it rather than the phone.
**Both are now gated, each red-proofed by putting the live defect back:** gate 3b requires every shell
page's footer to sit inside `<main class="content">`, and the new **gate 22** compares the two
stylesheets **media-aware** and fails on any declaration of `site.css`'s that still reaches a shell
page. Today it reads 689 site rules against 119 shell rules, 9 shared selectors, **0 leaks**. The two
decoys (`site/shell-css-leak-in-media`, `site/footer-outside-main`) are the exact shapes that shipped.
## Still open
- **R-931** — felhom.eu still serves **nothing compressed**. `shell.css` adds 15 KB per page, all of
@@ -98,5 +125,7 @@ deliberately not built** (your brief) — the shell should settle first.
## Gates and register
`site_gates.py` green throughout; full `repo_gates.py --fast` green on DooPlex. Register unchanged at
128 — nothing filed, because everything found was either fixed in the session or already has a row.
`site_gates.py` green throughout, now with gate 22; full `repo_gates.py --fast` green on DooPlex,
decoy suite included. Register unchanged at 128 — nothing filed, because everything found was either
fixed in the session or already has a row, and the two live defects were found, fixed and pinned the
same day.
+6
View File
@@ -23,6 +23,12 @@
contact form sat under the menu.
- **Two phone bugs that were already live are fixed**: the download page and the privacy page used to
scroll sideways on a phone.
- **Two things you found on your own phone after it went live, both mine, both now fixed** (reload
once if the old version is still cached): the slide-out menu opened empty, and on 16 pages the
footer sat as a narrow second column at the top right instead of at the bottom. The menu one is the
worse of the two — the check I ran before switching was not looking at the phone-only part of the
stylesheet, so it told me six things were wrong when forty were, and the six it did find were all
visible on a big screen. Both now have a check that was watched failing before it was trusted.
- **Waiting on you — nothing breaks if you do nothing:** the website still sends **everything
uncompressed** (R-931). That is one setting on the server, it helps every page, and it is now the
cheapest improvement left by a wide margin. Also: Search Console — the addresses did not change, so
+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
+27 -1
View File
@@ -1,4 +1,30 @@
## 2026-10-11 (M3) — the whole site moves into the controller's shell
## 2026-10-11 (evening) — the two M3 defects a real phone found, and the gate that now holds them
Both were mine, both shipped in the morning's switch, and both were reported by the operator from his
own phone rather than caught here. `site.css?v=23` / `shell.css?v=23`, live and read back on all 20
pages at 390 / 860 / 1440 px.
- **The phone menu was empty.** The drawer slid open correctly and had nothing in it: `site.css`'s old
mobile nav still reached the shell's sidebar, because under `@media (max-width: 900px)` it sets
`.nav-links { position: fixed; right: -100% }` — which parked the menu off-canvas *inside* the open
drawer. Fixed at the source: every selector belonging to the website's OLD chrome (`nav`,
`.nav-links*`, `.mobile-overlay`, `.hamburger`, `.logo*`, `.lang-globe-li` — 29 occurrences) is now
scoped `body:not(.shell)`. Scoped, not deleted, so a page without the shell still works.
`.hamburger-box` and `.hamburger-line` stay unscoped on purpose: the shell's burger uses them.
- **Why the morning's check missed it.** The STOP-1 leak diff compared the two stylesheets'
declaration sets **with `@media` blocks excluded**, and reported „exactly six" leaking declarations.
Media-aware, the number is **forty** — and the worst of them only apply below 900 px, which is why
the desktop looked right and a phone did not. A check that silently skips part of its subject is not
a check; this one returned a confident wrong number and was believed.
- **The footer rendered as a second column** at the top right of 16 pages. `body` is a flex row on a
shell page, and the insert moved the footer inside `<main>` only on pages that *ended* with
`</main>`; a page with a `<script>` after it kept the footer as a direct child of `body` — a flex
sibling of the sidebar. Moved inside `<main class="content">` on all 16, each move asserted to have
landed inside.
- **Both are now gated, each red-proofed by putting the live defect back** (`site_gates.py` gate 3b
and the new gate 22; decoys `site/footer-outside-main` and `site/shell-css-leak-in-media`).
## 2026-10-11 (M3) — the whole site moves into the controller's shell
The operator's own direction, after three mockups were rejected: the **controller's frame** (left
sidebar, phone top bar, off-canvas drawer) around **the website's own content**. Switched in one