Files
lage-chat-control/src/components/Quiz.astro
T
Aurealibe f4e09ca886
CI / ci (push) Failing after 6m53s
Add educational censorship-resistance quiz as a native Astro page
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>
2026-07-10 13:24:41 +01:00

471 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// 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>