Add educational censorship-resistance quiz as a native Astro page
CI / ci (push) Failing after 6m53s

Reworks the standalone-HTML quiz from PR #7 into the site's real
architecture so it actually ships and respects every project rule.

- /quiz, /fr/quiz, /nl/quiz — prerendered per-locale routes with correct
  canonical + hreflang (new optional `path` prop on Base.astro).
- Progressive enhancement: the twelve questions and the full scoring key
  (bands + per-area guidance with deep links into the guide) are
  server-rendered and readable with JavaScript disabled; the client script
  only computes the live score and reveals the result panel.
- CSP-clean: bundled same-origin script (no inline handlers), bar widths
  set via a CSS custom property through the CSSOM (no inline styles), no
  third-party request. docker/csp.conf needs no new hash.
- i18n via the existing core+overlay model: src/data/quiz.json (neutral
  scoring) + src/i18n/content/<locale>/quiz.json (prose), loaded by a
  dedicated loadQuiz() that checks question/option/band parity in all three
  locales. Page chrome added to the `quiz` namespace (key parity tested).
- Share is copy-link + native Web Share + X (already allowlisted); the
  third-party mastodonshare.com redirector from PR #7 is dropped.
- TopBar "Contents" link made absolute (/#toc) so it works off the guide,
  plus a new "Quiz" nav link. New tests/e2e/quiz.spec.ts covers no-JS
  readability, scoring, the TOC link and zero third-party requests.

Co-Authored-By: arnaudom <1535627+arnaudom@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aurealibe
2026-07-10 13:06:20 +01:00
parent 4f583d8c88
commit f4e09ca886
16 changed files with 1860 additions and 12 deletions
+470
View File
@@ -0,0 +1,470 @@
---
// Censorship-resistance quiz — a progressive-enhancement scorer. EVERYTHING
// the reader needs is server-rendered: the twelve questions (native radios,
// selectable with JS off) and the full scoring key (what each band means +
// where to improve, with deep links into the guide). The client script only
// computes the live score and reveals the result panel; with JS off the
// static key still teaches the whole thing. No third-party request, no inline
// styles (bar widths come from a CSS custom property set via the CSSOM, which
// style-src 'self' allows), no inline handlers (delegated on document).
import { localePath, useT, type Locale } from '../i18n/config'
import { loadQuiz } from '../lib/content'
import Ext from './Ext.astro'
interface Props {
locale: Locale
}
const { locale } = Astro.props
const t = useT(locale)
const { bands, questions } = loadQuiz(locale)
const localeRoot = `${localePath(locale)}/`.replace('//', '/')
const guideHref = (anchor: string) => `${localeRoot}#${anchor}`
const total = questions.length
const progress0 = t('quiz.progress').replace('{done}', '0').replace('{total}', String(total))
---
<main id="content" class="mx-auto max-w-3xl px-4 py-8">
<header class="my-8">
<p class="eyebrow mt-0">{t('quiz.eyebrow')}</p>
<h1 class="my-2 text-4xl uppercase sm:text-5xl">{t('quiz.title')}</h1>
<p class="text-dim max-w-[60ch]">{t('quiz.lede')}</p>
<p class="text-dim font-mono text-xs">{t('quiz.privacyNote')}</p>
<div class="mt-4 flex flex-wrap gap-2">
<a
href="#quiz-form"
class="rounded-dossier border border-accent bg-accent px-3 py-1.5 font-mono text-xs text-on-accent no-underline"
>
{t('quiz.start')}
</a>
<a
href={localeRoot}
class="rounded-dossier border border-rule bg-surface px-3 py-1.5 font-mono text-xs text-ink no-underline hover:border-accent"
>
{t('quiz.readGuide')}
</a>
</div>
</header>
<div data-quiz-progress class="sticky top-12 z-30 bg-bg/95 py-2 backdrop-blur-sm" hidden>
<p
data-quiz-progress-text
data-tpl={t('quiz.progress')}
class="eyebrow mt-0 mb-1"
aria-live="polite"
>
{progress0}
</p>
<div class="quiz-progress" aria-hidden="true"><i class="quiz-progress__fill"></i></div>
</div>
<form id="quiz-form" data-quiz aria-describedby="quiz-note">
<p id="quiz-note" class="sr-only">{t('quiz.privacyNote')}</p>
<ol class="m-0 list-none p-0">
{
questions.map((question, i) => (
<li class="my-6">
<fieldset
data-quiz-question
data-cat={question.id}
class="rounded-dossier border border-rule bg-surface m-0 p-4"
>
<legend class="px-1 font-mono text-base font-bold">
<span class="text-accent">{String(i + 1).padStart(2, '0')}</span> {question.prompt}
</legend>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
{question.options.map((option) => (
<label class="rounded-dossier border border-rule bg-surface-2 has-[:checked]:border-accent has-[:checked]:bg-surface flex cursor-pointer flex-col gap-1 p-3">
<span class="flex items-start gap-2">
<input
type="radio"
name={question.id}
value={option.points}
class="mt-1 size-4 shrink-0 cursor-pointer accent-accent"
/>
<span class="font-mono text-sm font-bold">{option.label}</span>
</span>
<span class="text-dim ms-6 text-sm">{option.hint}</span>
</label>
))}
</div>
</fieldset>
</li>
))
}
</ol>
</form>
<section
data-quiz-result
data-share-tpl={t('quiz.shareText')}
tabindex="-1"
aria-live="polite"
class="rounded-dossier border border-rule border-l-4 border-l-accent bg-surface my-8 p-5"
hidden
>
<p class="eyebrow mt-0 mb-1">{t('quiz.resultEyebrow')}</p>
<p class="font-mono text-5xl font-bold">
<span data-quiz-score>0</span><span class="text-dim text-2xl"> {t('quiz.outOf')}</span>
</p>
<p data-quiz-band class="text-accent font-mono text-sm font-bold tracking-widest uppercase"></p>
<p data-quiz-summary class="text-dim"></p>
<h3 class="mt-6 mb-2 font-mono text-sm uppercase">{t('quiz.barsHeading')}</h3>
<div data-quiz-bars class="grid gap-2"></div>
<h3 class="mt-6 mb-2 font-mono text-sm uppercase">{t('quiz.recHeading')}</h3>
<div data-quiz-recs class="grid gap-2 sm:grid-cols-3"></div>
<div class="mt-5 flex flex-wrap gap-2 border-t border-rule pt-4">
<button
type="button"
data-quiz-share="copy"
data-copied={t('quiz.copied')}
class="rounded-dossier border border-rule bg-surface-2 text-ink cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
>
{t('quiz.copy')}
</button>
<button
type="button"
data-quiz-share="native"
class="rounded-dossier border border-rule bg-surface-2 text-ink cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
hidden
>
{t('quiz.share')}
</button>
<Ext
href="https://x.com/intent/tweet"
class="quiz-x-share rounded-dossier border border-rule bg-surface-2 text-ink px-3 py-1.5 font-mono text-xs no-underline hover:border-accent"
>
{t('quiz.shareX')}
</Ext>
<button
type="button"
data-quiz-restart
class="rounded-dossier border border-rule bg-surface text-dim ms-auto cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
>
{t('quiz.restart')}
</button>
</div>
</section>
<section id="quiz-key" aria-labelledby="quiz-key-bands" class="my-10">
<h2 id="quiz-key-bands" class="text-2xl uppercase">{t('quiz.bandsHeading')}</h2>
<ul data-quiz-band-key class="m-0 grid list-none gap-2 p-0">
{
bands.map((band) => (
<li
data-band-id={band.id}
data-band-min={band.min}
data-band-max={band.max}
class="rounded-dossier border border-rule bg-surface p-3"
>
<span class="flex items-baseline gap-2">
<strong data-band-title class="font-mono text-sm uppercase">
{band.title}
</strong>
<span class="text-dim font-mono text-xs">
{band.min}{band.max}
</span>
</span>
<p data-band-summary class="text-dim my-1 text-sm">
{band.summary}
</p>
</li>
))
}
</ul>
<h2 class="mt-8 text-2xl uppercase">{t('quiz.categoriesHeading')}</h2>
<ul data-quiz-cat-key class="m-0 grid list-none gap-2 p-0 sm:grid-cols-2">
{
questions.map((question) => (
<li data-cat={question.id} class="rounded-dossier border border-rule bg-surface p-3">
<strong data-cat-label class="font-mono text-sm">
{question.label}
</strong>
<p data-cat-rec class="text-dim my-1 text-sm">
{question.rec}
</p>
<a data-cat-link href={guideHref(question.anchor)} class="font-mono text-xs">
{t('quiz.openGuide')}
</a>
</li>
))
}
</ul>
</section>
</main>
<script>
// ── Quiz enhancement ──────────────────────────────────────────────────
// Delegated on document (survives ClientRouter swaps), re-init on
// astro:page-load. Answers persist in localStorage 'ecc-quiz'.
const KEY = 'ecc-quiz'
type Answers = Record<string, number>
const qa = <T extends Element>(sel: string, root: ParentNode = document): T[] =>
Array.from(root.querySelectorAll<T>(sel))
const one = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
root.querySelector<T>(sel)
const form = () => one<HTMLFormElement>('[data-quiz]')
const readSaved = (): Answers => {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(KEY) ?? '{}')
const out: Answers = {}
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'number') out[k] = v
}
}
return out
} catch {
return {}
}
}
const save = (answers: Answers) => {
try {
localStorage.setItem(KEY, JSON.stringify(answers))
} catch {
/* private mode: not persisted, quiz still works for this visit */
}
}
type Cat = { cat: string; points: number | null; max: number }
const scan = (root: HTMLFormElement): { total: number; answered: number; cats: Cat[] } => {
const cats: Cat[] = []
let answered = 0
for (const fs of qa<HTMLFieldSetElement>('[data-quiz-question]', root)) {
const radios = qa<HTMLInputElement>('input[type="radio"]', fs)
const max = Math.max(0, ...radios.map((r) => Number(r.value)))
const checked = radios.find((r) => r.checked)
if (checked) answered += 1
cats.push({ cat: fs.dataset.cat ?? '', points: checked ? Number(checked.value) : null, max })
}
return { total: cats.length, answered, cats }
}
const bandFor = (score: number): { title: string; summary: string } => {
for (const li of qa<HTMLElement>('[data-quiz-band-key] [data-band-id]')) {
const min = Number(li.dataset.bandMin)
const max = Number(li.dataset.bandMax)
if (score >= min && score <= max) {
return {
title: one<HTMLElement>('[data-band-title]', li)?.textContent ?? '',
summary: one<HTMLElement>('[data-band-summary]', li)?.textContent ?? '',
}
}
}
return { title: '', summary: '' }
}
const catNode = (cat: string) => one<HTMLElement>(`[data-quiz-cat-key] [data-cat="${cat}"]`)
const catLabel = (cat: string) =>
one<HTMLElement>('[data-cat-label]', catNode(cat) ?? undefined)?.textContent ?? cat
const setBar = (fill: HTMLElement, ratio: number) => {
fill.style.setProperty('--quiz-bar', `${Math.round(ratio * 100)}%`)
}
const buildBars = (cats: Cat[]) => {
const host = one<HTMLElement>('[data-quiz-bars]')
if (!host) return
host.textContent = ''
for (const c of cats) {
const ratio = c.max > 0 && c.points != null ? c.points / c.max : 0
const row = document.createElement('div')
row.className = 'quiz-bar-row'
const label = document.createElement('span')
label.className = 'quiz-bar-row__label font-mono text-xs text-dim uppercase'
label.textContent = catLabel(c.cat)
const track = document.createElement('div')
track.className = 'quiz-bar'
const fill = document.createElement('i')
fill.className = 'quiz-bar__fill'
setBar(fill, ratio)
track.appendChild(fill)
const val = document.createElement('strong')
val.className = 'font-mono text-xs'
val.textContent = `${c.points ?? 0}/${c.max}`
row.append(label, track, val)
host.appendChild(row)
}
}
const buildRecs = (cats: Cat[]) => {
const host = one<HTMLElement>('[data-quiz-recs]')
if (!host) return
host.textContent = ''
const weakest = [...cats]
.filter((c) => c.points != null)
.sort((a, b) => a.points! / a.max - b.points! / b.max)
.slice(0, 3)
for (const c of weakest) {
const node = catNode(c.cat)
const card = document.createElement('div')
card.className =
'rounded-dossier border border-rule border-l-2 border-l-accent bg-surface p-3'
const label = document.createElement('strong')
label.className = 'font-mono text-sm'
label.textContent = catLabel(c.cat)
const rec = document.createElement('p')
rec.className = 'text-dim my-1 text-sm'
rec.textContent = one<HTMLElement>('[data-cat-rec]', node ?? undefined)?.textContent ?? ''
const link = document.createElement('a')
link.className = 'font-mono text-xs'
const src = one<HTMLAnchorElement>('[data-cat-link]', node ?? undefined)
link.href = src?.getAttribute('href') ?? '#'
link.textContent = src?.textContent ?? ''
card.append(label, rec, link)
host.appendChild(card)
}
}
const shareUrl = () => location.href.replace(/[?#].*$/, '')
const shareMessage = (score: number, band: string): string => {
const panel = one<HTMLElement>('[data-quiz-result]')
const tpl = panel?.dataset.shareTpl ?? '{score}/100 — {band}'
return tpl.replace('{score}', String(score)).replace('{band}', band)
}
const showResult = (cats: Cat[]) => {
const totalPoints = cats.reduce((s, c) => s + (c.points ?? 0), 0)
const maxPoints = cats.reduce((s, c) => s + c.max, 0)
const score = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0
const band = bandFor(score)
const scoreEl = one<HTMLElement>('[data-quiz-score]')
if (scoreEl) scoreEl.textContent = String(score)
const bandEl = one<HTMLElement>('[data-quiz-band]')
if (bandEl) bandEl.textContent = band.title
const summaryEl = one<HTMLElement>('[data-quiz-summary]')
if (summaryEl) summaryEl.textContent = band.summary
buildBars(cats)
buildRecs(cats)
const msg = shareMessage(score, band.title)
const x = one<HTMLAnchorElement>('.quiz-x-share')
if (x) x.href = `https://x.com/intent/tweet?text=${encodeURIComponent(`${msg} ${shareUrl()}`)}`
const panel = one<HTMLElement>('[data-quiz-result]')
if (panel && panel.hidden) {
panel.hidden = false
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
panel.focus({ preventScroll: true })
}
}
const hideResult = () => {
const panel = one<HTMLElement>('[data-quiz-result]')
if (panel) panel.hidden = true
}
const update = () => {
const root = form()
if (!root) return
const { total, answered, cats } = scan(root)
const block = one<HTMLElement>('[data-quiz-progress]')
if (block) block.hidden = false
const fill = one<HTMLElement>('[data-quiz-progress] .quiz-progress__fill')
if (fill) fill.style.setProperty('--quiz-progress', `${total ? (answered / total) * 100 : 0}%`)
const text = one<HTMLElement>('[data-quiz-progress-text]')
if (text)
text.textContent = (text.dataset.tpl ?? '{done} / {total}')
.replace('{done}', String(answered))
.replace('{total}', String(total))
const answers: Answers = {}
for (const c of cats) if (c.points != null) answers[c.cat] = c.points
save(answers)
if (answered === total && total > 0) showResult(cats)
else hideResult()
}
const restore = () => {
const root = form()
if (!root) return
const native = one<HTMLButtonElement>('[data-quiz-share="native"]')
if (native) native.hidden = typeof navigator.share !== 'function'
const saved = readSaved()
for (const fs of qa<HTMLFieldSetElement>('[data-quiz-question]', root)) {
const cat = fs.dataset.cat ?? ''
if (!(cat in saved)) continue
for (const radio of qa<HTMLInputElement>('input[type="radio"]', fs)) {
if (Number(radio.value) === saved[cat]) radio.checked = true
}
}
update()
}
const restart = () => {
const root = form()
if (!root) return
for (const radio of qa<HTMLInputElement>('input[type="radio"]', root)) radio.checked = false
try {
localStorage.removeItem(KEY)
} catch {
/* nothing to clear */
}
hideResult()
update()
root.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
document.addEventListener('change', (e) => {
const target = e.target as HTMLElement
if (
target instanceof HTMLInputElement &&
target.type === 'radio' &&
target.closest('[data-quiz]')
)
update()
})
document.addEventListener('click', async (e) => {
const target = e.target as HTMLElement
if (target.closest('[data-quiz-restart]')) {
restart()
return
}
const btn = target.closest<HTMLButtonElement>('[data-quiz-share]')
if (!btn) return
const action = btn.dataset.quizShare
const score = one<HTMLElement>('[data-quiz-score]')?.textContent ?? ''
const band = one<HTMLElement>('[data-quiz-band]')?.textContent ?? ''
const message = shareMessage(Number(score), band)
if (action === 'native') {
try {
await navigator.share({ title: document.title, text: message, url: shareUrl() })
} catch {
/* dismissed or unsupported */
}
return
}
if (action === 'copy') {
try {
await navigator.clipboard.writeText(`${message} ${shareUrl()}`)
if (!btn.dataset.label) btn.dataset.label = btn.textContent ?? ''
btn.textContent = btn.dataset.copied ?? btn.dataset.label
window.setTimeout(() => {
btn.textContent = btn.dataset.label ?? ''
}, 2000)
} catch {
/* clipboard unavailable */
}
}
})
restore()
document.addEventListener('astro:page-load', restore)
</script>