diff --git a/.claude/rules/website.md b/.claude/rules/website.md index c32dc214..284babd9 100644 --- a/.claude/rules/website.md +++ b/.claude/rules/website.md @@ -37,6 +37,9 @@ to the visible answers, and nothing after ``. - English words: the dashboard's (`en.json`: Dashboard, Launcher, Backup); app names and „Felhom" stay; no „please"/„kindly"; prices stay in Ft. The English footer says Felhom serves households in Hungary. - No Accept-Language redirect and no language cookie — a visitor lands on the page they asked for. +- **The switch is a globe** (operator ruling 2026-10-08, like the dashboard's): a `
` in + the nav listing every language in its own name, the current one marked. **A new language = one entry in + `LANGS` in `scripts/site_gates.py` (and the globe's list) + its twin pages** — nothing else in the switch changes. ## The encoding fences diff --git a/STATUS.md b/STATUS.md index aaeb9b29..5b97f478 100644 --- a/STATUS.md +++ b/STATUS.md @@ -30,6 +30,7 @@ - **Every page has an English twin at felhom.eu/en/**, public now (your choice B). A small "English" / "Magyar" link in the menu goes to the same page in the other language. - **A missing-page (404) page exists now.** +- **The language link is now a globe icon**, like the dashboard's: a click shows „Magyar" and „English". **Needs you:** two choices in `REPORT-website-refresh.md`. If nothing: SparkyFitness stays listed; the contact mailer's source stays lost. diff --git a/documentation/architecture/10-localisation.md b/documentation/architecture/10-localisation.md index afd27152..1ede3b73 100644 --- a/documentation/architecture/10-localisation.md +++ b/documentation/architecture/10-localisation.md @@ -829,8 +829,11 @@ that**, and it is the same walk R-516 is waiting for in the other language. `/en/download`. No twin, by name: the hidden prices page and `404.html` (Hungarian first, one English line). Section anchors are English on the English page (`#apps`, not `#alkalmazasok`). -**[DESIGN] The switch is a link, not a redirect.** Each page carries one nav item naming the other language in -that language („English" / „Magyar"), pointing at its own twin, with `lang` and `hreflang`. No Accept-Language +**[DESIGN] The switch is a globe, not a redirect** (ruling 11, 2026-10-08 — it replaced the first day's text link). +Each page's nav carries one `
` globe like the dashboard's (§10.3): a list naming every language in its own +language (Magyar, English — the same order everywhere), the page's own language marked and pointing at the page +itself, every other item at that language's twin, with `lang` and `hreflang`. A third language is one list entry +plus its pages. It works without JavaScript. No Accept-Language redirect, no cookie: a visitor lands on the page they asked for — the same reasoning as §3, where a household's choice lives in its own setting and a stranger's in their own browser. @@ -870,3 +873,5 @@ These are rulings, not proposals. Anything specced against a different assumptio live immediately"): indexed, in `sitemap.xml` with `xhtml:link` alternates, and a language switch in the nav of every page in both languages. No Accept-Language redirect and no cookie: a visitor always lands on the page they asked for. +11. **The website's language switch is a globe with a list of languages, matching the controller; text links are + retired** (operator ruling 2026-10-08, the globe brief). §10.7. diff --git a/documentation/audits/website-globe-2026-10-08/decoys.txt b/documentation/audits/website-globe-2026-10-08/decoys.txt new file mode 100644 index 00000000..1db1bca6 --- /dev/null +++ b/documentation/audits/website-globe-2026-10-08/decoys.txt @@ -0,0 +1,21 @@ +### site/globe-wrong-page rc=1 +mutation (website/en/apps.html): 'Magyar' -> 'Magyar' +FAIL: en/apps.html: the globe's hu item points at '/' — its twin is '/alkalmazasok' + +### site/globe-two-current rc=1 +mutation (website/en/apps.html): 'Magyar' -> 'Magyar '' +FAIL: en/apps.html: the globe lists [('en', 'English')] — every page lists exactly [('hu', 'Magyar'), ('en', 'English')], in that order + +### site/globe-wrong-order rc=1 +mutation (website/en/apps.html): '
  • Magyar '
  • ' -> '
  • Magyar
  • differs from en/index.html (after active-marker normalization) +FAIL: en/apps.html: a leftover text language switch (class="lang-switch") — the globe replaced it + diff --git a/documentation/audits/website-globe-2026-10-08/globe.py b/documentation/audits/website-globe-2026-10-08/globe.py new file mode 100644 index 00000000..fe04b3d3 --- /dev/null +++ b/documentation/audits/website-globe-2026-10-08/globe.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Replace the nav's text language switch with the globe on every page (2026-10-08 ruling: a globe with a list of +languages, matching the controller). One language list; each page's own URL and its twins' URLs.""" +import io, os, re, sys +W = "/mnt/5_hdd/felhom.eu/git/felhom.eu/website" +LANGS = [("hu", "Magyar"), ("en", "English")] # order on every page +LABEL = {"hu": "Nyelv", "en": "Language"} +# page -> {lang: url}; NO_TWIN pages point their foreign item at the other language's home +SETS = [("index.html", "/", "en/index.html", "/en/"), ("alkalmazasok.html", "/alkalmazasok", "en/apps.html", "/en/apps"), + ("technologiak.html", "/technologiak", "en/technology.html", "/en/technology"), + ("biztonsagimentes.html", "/biztonsagimentes", "en/backups.html", "/en/backups"), + ("gyik.html", "/gyik", "en/faq.html", "/en/faq"), ("kapcsolat.html", "/kapcsolat", "en/contact.html", "/en/contact"), + ("letoltes.html", "/letoltes", "en/download.html", "/en/download")] +PAGES = {} +for hu, hu_u, en, en_u in SETS: + PAGES[hu] = ("hu", {"hu": hu_u, "en": en_u}) + PAGES[en] = ("en", {"hu": hu_u, "en": en_u}) +PAGES["404.html"] = ("hu", {"hu": "/", "en": "/en/"}) +PAGES["szolgaltatasok-nonpublic.html"] = ("hu", {"hu": "/szolgaltatasok-nonpublic", "en": "/en/"}) + + +def globe(lang, urls): + items = [] + for code, name in LANGS: + cur = code == lang + items.append('
  • %s
  • ' + % (urls[code], code, code, " current" if cur else "", ' aria-current="true"' if cur else "", name)) + return ('
  • ' + '' + '
      %s
  • ') % (LABEL[lang], LABEL[lang], "".join(items)) + + +SW = re.compile(r'
  • [^<]*
  • ') +for p, (lang, urls) in PAGES.items(): + path = os.path.join(W, p) + s = io.open(path, encoding="utf-8-sig").read() + s, n = SW.subn(lambda m: globe(lang, urls), s) + assert n == 1, (p, n) + s, k = re.subn(r"/assets/site\.css\?v=2\b", "/assets/site.css?v=3", s) + assert k == 1, (p, k) + with io.open(path, "w", encoding="utf-8-sig", newline="\n") as f: + f.write(s) + print(p, "globe + css v3") diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index f0f8cbfd..a8e06c3d 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,13 @@ +## gates — the language globe (site_gates.py gates 3 and 13; 2026-10-08) + +- One language list, `LANGS = [("hu", "Magyar"), ("en", "English")]`, with `GLOBE_LABEL`. Gate 3 normalises the + whole `
    ` (its targets and current mark differ per page). Gate 13 `check_globe`: exactly + one globe; the label in the page's language; one `` per `LANGS` entry in order with `lang`/`hreflang` equal to it; + only the page's own language is `current`/`aria-current`; on twin pages each item points at that language's page + (the page itself or its twin). NO_TWIN pages get the structure checks. Any leftover `class="lang-switch"` fails. +- Decoys (each with its own failure line): `site/globe-wrong-page`, `globe-two-current`, `globe-missing-language`, + `globe-wrong-order`, `leftover-text-switch`; `site/switch-wrong-twin` retired with the switch. + ## gates — the website's two languages (site_gates.py gates 3, 13–18; 2026-10-08) - `site_gates.py`: pages are registered as `TWINS` (Hungarian page → English twin) or by name in `NO_TWIN` diff --git a/scripts/site_gates.py b/scripts/site_gates.py index 7aaf8985..c835d62c 100644 --- a/scripts/site_gates.py +++ b/scripts/site_gates.py @@ -8,7 +8,7 @@ Gates (all must pass; non-zero exit on any failure): Windows grep false-negatives multibyte emoji, proven in D0) 3. nav — the and
    …
    blocks are identical PER LANGUAGE SET (Hungarian pages against index.html, English pages against en/index.html) after - stripping the active-link marker and the language switch's target + stripping the active-link marker and the language globe (gate 13 checks it) 4. analytics — the umami snippet is present on every public page (nonpublic draft exempt) 5. no-CDN — zero fonts.googleapis.com / fonts.gstatic.com references 6. banned — zero legacy hexes / 999px radius / box-shadow in pages + site.css @@ -16,7 +16,9 @@ Gates (all must pass; non-zero exit on any failure): 8. cachebust — every site.css / icons.svg reference carries ?v= 12. download — the two download pages name the same installer file and checksum (R-559) 13. twins — every public Hungarian page has its English twin and back; each pair carries the same - hu/en/x-default alternates; each page's language switch points at its own twin + hu/en/x-default alternates; each page's language GLOBE (one per page, every page) lists LANGS in + order, marks only the page's own language current, and points the other items at the twins; + no leftover text switch 14. lang — on the Hungarian set, lang="en" on the English set 15. same apps — the app logos on /alkalmazasok and /en/apps are the same set 16. no HU — no Hungarian in the English pages: accent scan PLUS an ASCII-folded, word-bounded stem @@ -87,15 +89,21 @@ def is_emoji(ch): ]) -SWITCH_RE = re.compile(r'
    ([^<]*)') +# The language globe (2026-10-08 ruling, 10-localisation.md §10.7): one
    per page, its list built from THIS +# list — a third language is one more entry here plus its pages, and nothing else in the switch. +LANGS = [("hu", "Magyar"), ("en", "English")] +GLOBE_LABEL = {"hu": "Nyelv", "en": "Language"} +GLOBE_RE = re.compile(r'
    .*?
    ', re.S) +GLOBE_ITEM_RE = re.compile(r'([^<]*)') def norm_block(b): b = b.replace(' class="active"', "") b = re.sub(r'\s*aria-current="[^"]*"', "", b) - # the language switch is the one nav item whose target differs per page (it points at the page's own - # twin) — gate 13 checks that target; here only its presence and wording must match - b = SWITCH_RE.sub(lambda m: '%s' % (m.group(2), m.group(4)), b) + # the globe is the one nav item whose content differs per page (its targets, its current mark, its label) — + # gate 13 checks all of that; here only its presence must match + b = GLOBE_RE.sub('
    ', b) return b @@ -225,7 +233,43 @@ for p, s in pages.items(): if not m.group(2).startswith("?v="): fail("%s: %s referenced without ?v= (got %r)" % (p, m.group(1), m.group(0))) -# gate 13: twins — both ways, matching alternates, the switch at the twin +# gate 13: twins — both ways, matching alternates, the globe pointing at the twins + + +def lang_of(p): + return "en" if p in EN_PAGES else "hu" + + +def check_globe(p, lang, urls=None): + """Exactly one globe; its label in the page's language; one item per LANGS entry, in order, lang/hreflang equal to + the entry; the page's own language is the only current/aria-current item; with `urls`, each item's target.""" + s = pages[p] + g = GLOBE_RE.findall(s) + if len(g) != 1: + fail("%s: %d language globes in the page — exactly one, in the nav" % (p, len(g))) + return + lab = re.search(r'', g[0]) + if not lab or lab.group(1) != GLOBE_LABEL[lang] or lab.group(2) != GLOBE_LABEL[lang]: + fail("%s: the globe's label is %r — on a %s page it is %r" % (p, lab and lab.groups(), lang, GLOBE_LABEL[lang])) + items = GLOBE_ITEM_RE.findall(g[0]) + if len(re.findall(r"" % p) + got = [(it[1], it[5]) for it in items] + if got != LANGS: + fail("%s: the globe lists %r — every page lists exactly %r, in that order" % (p, got, LANGS)) + return + for href, code, hreflang, cur, aria, name in items: + if hreflang != code: + fail("%s: the globe's %s item has hreflang=%r" % (p, code, hreflang)) + is_cur = code == lang + if bool(cur) != is_cur or bool(aria) != is_cur: + fail("%s: the globe marks %r as current (class current=%s, aria-current=%s) — only the page's own language %r is" + % (p, code, bool(cur), bool(aria), lang)) + if urls is not None and href != urls[code]: + fail("%s: the globe's %s item points at %r — %s" % (p, code, href, + "the page itself is %r" % urls[code] if is_cur else "its twin is %r" % urls[code])) + + ALT_RE = re.compile(r'') for hu, en in TWINS.items(): if hu not in pages or en not in pages: @@ -236,16 +280,17 @@ for hu, en in TWINS.items(): got = dict(ALT_RE.findall(pages[p])) if got != want: fail("%s: hreflang alternates %s — the pair %s <-> %s needs exactly %s" % (p, got, hu, en, want)) - sw = SWITCH_RE.findall(pages[p]) - if len(sw) != 1: - fail("%s: %d language switch links in the page — exactly one, in the nav" % (p, len(sw))) - elif sw[0][0] != URL[other] or sw[0][1] != lang or sw[0][2] != lang: - fail("%s: the language switch points at %r (lang=%s) — its twin is %r (lang=%s)" - % (p, sw[0][0], sw[0][1], URL[other], lang)) + check_globe(p, lang_of(p), {"hu": URL[hu], "en": URL[en]}) canon = re.search(r'', pages[p]) if not canon or canon.group(1) != SITE + URL[p]: fail("%s: canonical %r — it must point at the page itself (%s)" % (p, canon and canon.group(1), SITE + URL[p])) -print(" twins: %d pairs checked both ways (hreflang, switch, canonical)" % len(TWINS)) +print(" twins: %d pairs checked both ways (hreflang, globe, canonical)" % len(TWINS)) +for p in sorted(NO_TWIN): # no twin: the globe's structure, current mark and label still hold + if p in pages: + check_globe(p, lang_of(p)) +for p, s in pages.items(): + if 'class="lang-switch"' in s: + fail("%s: a leftover text language switch (class=\"lang-switch\") — the globe replaced it" % p) # gate 14: the lang attribute for p, s in pages.items(): @@ -398,4 +443,4 @@ 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