Add educational censorship-resistance quiz as a native Astro page
CI / ci (push) Failing after 6m53s
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:
@@ -11,8 +11,9 @@ In July 2026, the European Parliament let the "Chat Control" machinery advance
|
||||
Its promises, all enforced by tests:
|
||||
|
||||
- **Static and self-contained** — no tracker, no cookie, not a single request to a third-party domain.
|
||||
- **Readable with JavaScript disabled** (Tor Browser "safest" mode included) — scripts only power the theme, filters and checklist.
|
||||
- **Readable with JavaScript disabled** (Tor Browser "safest" mode included) — scripts only power the theme, filters, checklist and quiz.
|
||||
- **Multilingual by design** — every language is a real prerendered route with correct `hreflang`; adding one means adding translation files, not touching code.
|
||||
- **Educational quiz** — `/quiz` scores your censorship resistance on a 0–100 scale and points you at concrete fixes in the guide. Server-rendered questions and scoring key (readable with JS off); the score itself is computed locally in the browser, nothing sent or stored.
|
||||
- **Printable and mirrorable** — a single-file offline version ships with every build.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -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>
|
||||
@@ -17,9 +17,18 @@ const t = useT(locale)
|
||||
>
|
||||
EXIT CHAT CONTROL
|
||||
</a>
|
||||
<a href="#toc" class="font-mono text-xs text-dim no-underline hover:text-accent">
|
||||
<a
|
||||
href={`${localePath(locale)}/#toc`.replace('//', '/')}
|
||||
class="font-mono text-xs text-dim no-underline hover:text-accent"
|
||||
>
|
||||
{t('nav.toc')}
|
||||
</a>
|
||||
<a
|
||||
href={`${localePath(locale)}/quiz`.replace('//', '/')}
|
||||
class="font-mono text-xs text-dim no-underline hover:text-accent"
|
||||
>
|
||||
{t('nav.quiz')}
|
||||
</a>
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<nav aria-label={t('nav.language')}>
|
||||
<details class="lang-menu" data-lang-menu>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"bands": [
|
||||
{ "id": "exposed", "min": 0, "max": 24 },
|
||||
{ "id": "awakening", "min": 25, "max": 49 },
|
||||
{ "id": "resistant", "min": 50, "max": 74 },
|
||||
{ "id": "hardened", "min": 75, "max": 89 },
|
||||
{ "id": "sovereign", "min": 90, "max": 100 }
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"id": "messaging",
|
||||
"anchor": "messagerie",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 2 },
|
||||
{ "id": "o2", "points": 6 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "email",
|
||||
"anchor": "email",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "browser",
|
||||
"anchor": "navigateur",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dns",
|
||||
"anchor": "dns",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 4 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vpn",
|
||||
"anchor": "vpn",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 2 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "passwords",
|
||||
"anchor": "motsdepasse",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "twofa",
|
||||
"anchor": "deuxfa",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 2 },
|
||||
{ "id": "o2", "points": 6 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "storage",
|
||||
"anchor": "stockage",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"anchor": "social",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 7 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "os",
|
||||
"anchor": "os",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 5 },
|
||||
{ "id": "o2", "points": 8 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tor",
|
||||
"anchor": "tor",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 3 },
|
||||
{ "id": "o2", "points": 6 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "money",
|
||||
"anchor": "argent",
|
||||
"options": [
|
||||
{ "id": "o0", "points": 0 },
|
||||
{ "id": "o1", "points": 4 },
|
||||
{ "id": "o2", "points": 6 },
|
||||
{ "id": "o3", "points": 10 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"questions": {
|
||||
"messaging": {
|
||||
"label": "Messaging",
|
||||
"prompt": "How do you talk to friends and family most of the time?",
|
||||
"rec": "Move sensitive conversations to Signal, SimpleX or Session.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "WhatsApp / Messenger / SMS",
|
||||
"hint": "Metadata and centralised platforms stay the weak point."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Telegram for most conversations",
|
||||
"hint": "Better than the big platforms, but the privacy is still limited."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Signal or Molly for most chats",
|
||||
"hint": "Already a solid base for censorship resistance."
|
||||
},
|
||||
"o3": {
|
||||
"label": "SimpleX / Session / Matrix for sensitive use",
|
||||
"hint": "Less dependence on a phone number and a central platform."
|
||||
}
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"prompt": "Which service do you use most for your email?",
|
||||
"rec": "Use Proton Mail or Tuta, and add PGP for the contacts that matter.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Gmail / Outlook / iCloud Mail",
|
||||
"hint": "Storage and content access stay heavily centralised."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Mail or Tuta for everyday use",
|
||||
"hint": "Already a stronger option for privacy."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Proton / Tuta + PGP for some contacts",
|
||||
"hint": "A good level of resistance if you use it consistently."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Regular PGP / OpenPGP with careful metadata habits",
|
||||
"hint": "A much more robust posture."
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"label": "Browser & search",
|
||||
"prompt": "Which browser and search engine do you use most?",
|
||||
"rec": "Drop Chrome + Google for a privacy-respecting browser and search engine.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Chrome + Google",
|
||||
"hint": "Convenient, but highly trackable."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Firefox or Brave with a private search engine",
|
||||
"hint": "A real step forward for privacy."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Brave / Firefox + DuckDuckGo / Startpage / SearXNG",
|
||||
"hint": "Already a solid daily baseline."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad Browser / LibreWolf / Tor Browser by context",
|
||||
"hint": "A stronger posture against surveillance."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS",
|
||||
"prompt": "How do you resolve domain names?",
|
||||
"rec": "Switch to encrypted DNS with a minimal, privacy-first resolver.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Your ISP or router DNS",
|
||||
"hint": "The easiest option, but often the least private."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Encrypted DNS from a mainstream provider",
|
||||
"hint": "A good first step."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Quad9 / Mullvad DNS / NextDNS",
|
||||
"hint": "A real improvement if you want to limit traceability."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Encrypted DNS plus a deliberate anti-tracking strategy",
|
||||
"hint": "A more deliberate and robust posture."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"label": "VPN",
|
||||
"prompt": "What kind of VPN do you use?",
|
||||
"rec": "Use a reputable no-log VPN such as Mullvad, Proton VPN or IVPN.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "No VPN",
|
||||
"hint": "The most exposed case for network traceability."
|
||||
},
|
||||
"o1": {
|
||||
"label": "A free or dubious VPN",
|
||||
"hint": "Can be worse than none if the provider is opaque."
|
||||
},
|
||||
"o2": {
|
||||
"label": "A reputable no-log VPN with a clear policy",
|
||||
"hint": "A real improvement to privacy."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad / Proton VPN / IVPN for regular use",
|
||||
"hint": "A more serious and coherent posture."
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwords": {
|
||||
"label": "Passwords",
|
||||
"prompt": "How do you manage your passwords?",
|
||||
"rec": "Adopt a password manager with unique, long passwords everywhere.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Reused passwords or saved in the browser",
|
||||
"hint": "High risk in a breach or account compromise."
|
||||
},
|
||||
"o1": {
|
||||
"label": "A basic password manager",
|
||||
"hint": "Already a very good step forward."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Bitwarden / KeePassXC / Proton Pass",
|
||||
"hint": "A healthy posture that is simple to maintain."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Password manager + unique, long passwords",
|
||||
"hint": "The most robust way to reduce damage from a breach."
|
||||
}
|
||||
}
|
||||
},
|
||||
"twofa": {
|
||||
"label": "Two-factor auth",
|
||||
"prompt": "What level of two-factor authentication do you use?",
|
||||
"rec": "Move 2FA to an authenticator app or, better, a hardware key.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "No 2FA",
|
||||
"hint": "The weakest level of account protection."
|
||||
},
|
||||
"o1": {
|
||||
"label": "SMS or phone call",
|
||||
"hint": "Better than nothing, but very fragile."
|
||||
},
|
||||
"o2": {
|
||||
"label": "TOTP in an app (Aegis / Ente Auth)",
|
||||
"hint": "A good everyday protection layer."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Hardware key or phishing-resistant 2FA",
|
||||
"hint": "The strongest practical resistance level."
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"label": "Storage",
|
||||
"prompt": "Where do you store your sensitive files?",
|
||||
"rec": "Move sensitive files to Proton Drive, Cryptomator or encrypted local storage.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Google Drive / iCloud / OneDrive",
|
||||
"hint": "Very convenient, but centralised."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Drive or a basic encrypted storage service",
|
||||
"hint": "A real improvement."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Cryptomator or local encrypted storage",
|
||||
"hint": "A more robust posture."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Local storage + self-hosting / advanced encryption",
|
||||
"hint": "The most serious level for sovereignty."
|
||||
}
|
||||
}
|
||||
},
|
||||
"social": {
|
||||
"label": "Social",
|
||||
"prompt": "Where do you spend most of your social life online?",
|
||||
"rec": "Anchor your presence on decentralised networks and lower platform dependence.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Meta / X / big platforms only",
|
||||
"hint": "The simplest, but the most exposed to pressure."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Mastodon / Matrix / Nostr occasionally",
|
||||
"hint": "A useful diversification."
|
||||
},
|
||||
"o2": {
|
||||
"label": "A mix of decentralised networks and direct contact",
|
||||
"hint": "A more independent posture."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Alternative networks and low platform dependence",
|
||||
"hint": "That is the true logic of resistance."
|
||||
}
|
||||
}
|
||||
},
|
||||
"os": {
|
||||
"label": "Operating system",
|
||||
"prompt": "What operating system do you use most?",
|
||||
"rec": "Move toward GrapheneOS on mobile and Linux on the desktop.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Stock Android / iOS / Windows",
|
||||
"hint": "Very convenient, but much less privacy-respecting."
|
||||
},
|
||||
"o1": {
|
||||
"label": "A more private Android / Linux / macOS",
|
||||
"hint": "Already a good improvement."
|
||||
},
|
||||
"o2": {
|
||||
"label": "GrapheneOS / Linux / BSD",
|
||||
"hint": "A stronger base."
|
||||
},
|
||||
"o3": {
|
||||
"label": "GrapheneOS + Linux + careful setup discipline",
|
||||
"hint": "Already a very strong posture."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tor": {
|
||||
"label": "Tor & anonymity",
|
||||
"prompt": "How much do you use Tor / Tails / anonymity tools?",
|
||||
"rec": "Build a Tor (and, when needed, Tails) habit for your most sensitive tasks.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Almost never",
|
||||
"hint": "Very exposed if the threat ever becomes serious."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Occasionally, without much discipline",
|
||||
"hint": "A first step, but still fragile."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Tor for some sensitive tasks",
|
||||
"hint": "Already more serious."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Tor + Tails with strong discipline",
|
||||
"hint": "The most suitable level for a high-threat context."
|
||||
}
|
||||
}
|
||||
},
|
||||
"money": {
|
||||
"label": "Money",
|
||||
"prompt": "How do you handle your digital money?",
|
||||
"rec": "Add privacy-aware payment options and separate identities where you can.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Bank cards and standard accounts only",
|
||||
"hint": "Traceability is high."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Some more privacy-aware payment tools",
|
||||
"hint": "A first effort at separation."
|
||||
},
|
||||
"o2": {
|
||||
"label": "A mix of private accounts and limited crypto use",
|
||||
"hint": "Already more deliberate."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Stronger financial-sovereignty practices",
|
||||
"hint": "A much more serious posture."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bands": {
|
||||
"exposed": {
|
||||
"title": "Exposed",
|
||||
"summary": "Your setup is still heavily tied to the big platforms. The first steps are simple and high-impact."
|
||||
},
|
||||
"awakening": {
|
||||
"title": "Awakening",
|
||||
"summary": "You have started to diversify your tools. A few deliberate changes could make a real difference."
|
||||
},
|
||||
"resistant": {
|
||||
"title": "Resistant",
|
||||
"summary": "Your setup is already serious. You have a solid base of censorship and surveillance resistance."
|
||||
},
|
||||
"hardened": {
|
||||
"title": "Hardened",
|
||||
"summary": "You run a hardened stack with a strong pseudonymous baseline. The next focus is discipline and compartmentalisation."
|
||||
},
|
||||
"sovereign": {
|
||||
"title": "Sovereign",
|
||||
"summary": "Your posture is already very strong. You are close to high digital sovereignty, with demanding technical choices."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"questions": {
|
||||
"messaging": {
|
||||
"label": "Messagerie",
|
||||
"prompt": "Comment échangez-vous le plus souvent avec vos proches ?",
|
||||
"rec": "Basculez vos conversations sensibles vers Signal, SimpleX ou Session.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "WhatsApp / Messenger / SMS",
|
||||
"hint": "Les métadonnées et les plateformes centralisées restent le point faible."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Telegram pour la plupart des conversations",
|
||||
"hint": "Mieux que les grands réseaux, mais la confidentialité reste limitée."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Signal ou Molly pour la plupart des échanges",
|
||||
"hint": "C’est déjà une bonne base pour la résistance à la censure."
|
||||
},
|
||||
"o3": {
|
||||
"label": "SimpleX / Session / Matrix pour les usages sensibles",
|
||||
"hint": "Moins de dépendance au numéro de téléphone et à la plateforme."
|
||||
}
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"prompt": "Quel service utilisez-vous le plus pour vos e-mails ?",
|
||||
"rec": "Utilisez Proton Mail ou Tuta, et ajoutez PGP pour les contacts qui comptent.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Gmail / Outlook / iCloud Mail",
|
||||
"hint": "Le stockage et l’accès au contenu restent très centralisés."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Mail ou Tuta au quotidien",
|
||||
"hint": "Déjà une option plus sérieuse pour la confidentialité."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Proton / Tuta + PGP pour certains contacts",
|
||||
"hint": "Un bon niveau de résistance si vous êtes régulier."
|
||||
},
|
||||
"o3": {
|
||||
"label": "PGP / OpenPGP régulier et attention aux métadonnées",
|
||||
"hint": "Une posture beaucoup plus robuste."
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"label": "Navigateur & recherche",
|
||||
"prompt": "Quels navigateur et moteur de recherche utilisez-vous le plus ?",
|
||||
"rec": "Quittez Chrome + Google pour un navigateur et un moteur respectueux de la vie privée.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Chrome + Google",
|
||||
"hint": "Confortable, mais fortement traçable."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Firefox ou Brave avec un moteur privé",
|
||||
"hint": "Un vrai pas en avant pour la vie privée."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Brave / Firefox + DuckDuckGo / Startpage / SearXNG",
|
||||
"hint": "C’est déjà une bonne base quotidienne."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad Browser / LibreWolf / Tor Browser selon le contexte",
|
||||
"hint": "Une posture plus sérieuse face à la surveillance."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS",
|
||||
"prompt": "Comment résolvez-vous les noms de domaine ?",
|
||||
"rec": "Passez à un DNS chiffré avec un résolveur minimaliste et respectueux.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Le DNS de votre FAI ou de votre box",
|
||||
"hint": "Le plus simple, mais souvent le moins privé."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Un DNS chiffré chez un fournisseur grand public",
|
||||
"hint": "Un bon premier pas."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Quad9 / Mullvad DNS / NextDNS",
|
||||
"hint": "Une vraie amélioration pour limiter la traçabilité."
|
||||
},
|
||||
"o3": {
|
||||
"label": "DNS chiffré + stratégie anti-tracking assumée",
|
||||
"hint": "Une posture plus consciente et plus robuste."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"label": "VPN",
|
||||
"prompt": "Quel type de VPN utilisez-vous ?",
|
||||
"rec": "Choisissez un VPN honnête sans journalisation : Mullvad, Proton VPN ou IVPN.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Pas de VPN",
|
||||
"hint": "Le cas le plus exposé pour la traçabilité réseau."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Un VPN gratuit ou douteux",
|
||||
"hint": "Parfois pire que rien si le fournisseur est opaque."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Un VPN honnête, no-log, avec une vraie politique",
|
||||
"hint": "Une vraie amélioration pour la confidentialité."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad / Proton VPN / IVPN au quotidien",
|
||||
"hint": "Une posture plus sérieuse et cohérente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwords": {
|
||||
"label": "Mots de passe",
|
||||
"prompt": "Comment gérez-vous vos mots de passe ?",
|
||||
"rec": "Adoptez un gestionnaire de mots de passe avec des mots de passe uniques et longs.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Mots de passe réutilisés ou stockés dans le navigateur",
|
||||
"hint": "Risque élevé en cas de fuite ou de compromission."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Un gestionnaire de mots de passe basique",
|
||||
"hint": "Déjà un très bon pas en avant."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Bitwarden / KeePassXC / Proton Pass",
|
||||
"hint": "Une posture saine, simple à maintenir."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Gestionnaire + mots de passe uniques et longs",
|
||||
"hint": "Le niveau le plus solide pour limiter les dégâts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"twofa": {
|
||||
"label": "Double authentification",
|
||||
"prompt": "Quel niveau de double authentification utilisez-vous ?",
|
||||
"rec": "Passez la 2FA sur une application, ou mieux, une clé matérielle.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Pas de 2FA",
|
||||
"hint": "Le plus faible niveau de protection des comptes."
|
||||
},
|
||||
"o1": {
|
||||
"label": "SMS ou appel",
|
||||
"hint": "Mieux que rien, mais très fragile."
|
||||
},
|
||||
"o2": {
|
||||
"label": "TOTP sur une application (Aegis / Ente Auth)",
|
||||
"hint": "Une bonne protection au quotidien."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Clé matérielle ou 2FA résistante au phishing",
|
||||
"hint": "Le meilleur niveau de résistance pratique."
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"label": "Stockage",
|
||||
"prompt": "Où stockez-vous vos fichiers sensibles ?",
|
||||
"rec": "Déplacez vos fichiers sensibles vers Proton Drive, Cryptomator ou un stockage local chiffré.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Google Drive / iCloud / OneDrive",
|
||||
"hint": "Très pratique, mais centralisé."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Drive ou un stockage chiffré de base",
|
||||
"hint": "Une vraie amélioration."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Cryptomator ou un stockage local chiffré",
|
||||
"hint": "Une posture plus robuste."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Stockage local + auto-hébergement / chiffrement avancé",
|
||||
"hint": "Le niveau le plus sérieux pour la souveraineté."
|
||||
}
|
||||
}
|
||||
},
|
||||
"social": {
|
||||
"label": "Réseaux sociaux",
|
||||
"prompt": "Où passez-vous l’essentiel de votre vie sociale en ligne ?",
|
||||
"rec": "Ancrez votre présence sur des réseaux décentralisés et réduisez la dépendance aux plateformes.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Meta / X / grands réseaux uniquement",
|
||||
"hint": "Le plus simple, mais le plus exposé aux pressions."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Mastodon / Matrix / Nostr de façon ponctuelle",
|
||||
"hint": "Une diversification utile."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Un mix de réseaux décentralisés et de contacts directs",
|
||||
"hint": "Une posture plus indépendante."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Réseaux alternatifs et faible dépendance aux plateformes",
|
||||
"hint": "C’est la vraie logique de résistance."
|
||||
}
|
||||
}
|
||||
},
|
||||
"os": {
|
||||
"label": "Système d’exploitation",
|
||||
"prompt": "Quel système d’exploitation utilisez-vous le plus ?",
|
||||
"rec": "Évoluez vers GrapheneOS sur mobile et Linux sur l’ordinateur.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Android / iOS / Windows standard",
|
||||
"hint": "Très pratique, mais beaucoup moins respectueux de la vie privée."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Un Android plus privé / Linux / macOS",
|
||||
"hint": "Déjà un bon progrès."
|
||||
},
|
||||
"o2": {
|
||||
"label": "GrapheneOS / Linux / BSD",
|
||||
"hint": "Une base plus sérieuse."
|
||||
},
|
||||
"o3": {
|
||||
"label": "GrapheneOS + Linux + discipline d’installation",
|
||||
"hint": "Déjà une posture très forte."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tor": {
|
||||
"label": "Tor & anonymat",
|
||||
"prompt": "Dans quelle mesure utilisez-vous Tor / Tails / l’anonymat ?",
|
||||
"rec": "Prenez l’habitude de Tor (et de Tails au besoin) pour vos tâches les plus sensibles.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Jamais ou presque jamais",
|
||||
"hint": "Très exposé si la menace devient sérieuse."
|
||||
},
|
||||
"o1": {
|
||||
"label": "De temps en temps, sans discipline",
|
||||
"hint": "Un premier pas, mais encore fragile."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Tor pour certaines tâches sensibles",
|
||||
"hint": "Déjà plus sérieux."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Tor + Tails avec une discipline forte",
|
||||
"hint": "Le niveau le plus adapté à une menace élevée."
|
||||
}
|
||||
}
|
||||
},
|
||||
"money": {
|
||||
"label": "Argent",
|
||||
"prompt": "Comment gérez-vous votre argent numérique ?",
|
||||
"rec": "Ajoutez des moyens de paiement plus privés et séparez vos identités quand c’est possible.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Cartes bancaires et comptes standards uniquement",
|
||||
"hint": "La traçabilité est forte."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Quelques outils de paiement plus privés",
|
||||
"hint": "Un premier effort de séparation."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Un mix de comptes privés et de crypto limitée",
|
||||
"hint": "Déjà plus réfléchi."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Pratiques de souveraineté financière plus fortes",
|
||||
"hint": "Une posture beaucoup plus sérieuse."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bands": {
|
||||
"exposed": {
|
||||
"title": "Exposé",
|
||||
"summary": "Votre pile est encore très dépendante des grandes plateformes. Les premières étapes sont simples et à fort impact."
|
||||
},
|
||||
"awakening": {
|
||||
"title": "Éveil",
|
||||
"summary": "Vous avez commencé à diversifier vos outils. Quelques changements bien choisis feraient une vraie différence."
|
||||
},
|
||||
"resistant": {
|
||||
"title": "Résistant",
|
||||
"summary": "Votre configuration est déjà sérieuse. Vous avez une vraie base de résistance à la censure et à la surveillance."
|
||||
},
|
||||
"hardened": {
|
||||
"title": "Durci",
|
||||
"summary": "Vous avez une pile plus dure et un socle pseudonyme solide. L’objectif devient la discipline et la séparation des identités."
|
||||
},
|
||||
"sovereign": {
|
||||
"title": "Souverain",
|
||||
"summary": "Votre posture est déjà très robuste. Vous êtes proche d’une forte souveraineté numérique, avec des choix techniques exigeants."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
{
|
||||
"_machineTranslated": "2026-07-10",
|
||||
"questions": {
|
||||
"messaging": {
|
||||
"label": "Berichten",
|
||||
"prompt": "Hoe communiceert u meestal met vrienden en familie?",
|
||||
"rec": "Verplaats gevoelige gesprekken naar Signal, SimpleX of Session.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "WhatsApp / Messenger / SMS",
|
||||
"hint": "Metadata en gecentraliseerde platforms blijven de zwakke plek."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Telegram voor de meeste gesprekken",
|
||||
"hint": "Beter dan de grote platforms, maar de privacy blijft beperkt."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Signal of Molly voor de meeste chats",
|
||||
"hint": "Al een stevige basis voor weerstand tegen censuur."
|
||||
},
|
||||
"o3": {
|
||||
"label": "SimpleX / Session / Matrix voor gevoelig gebruik",
|
||||
"hint": "Minder afhankelijk van een telefoonnummer en een centraal platform."
|
||||
}
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"prompt": "Welke dienst gebruikt u het meest voor uw e-mail?",
|
||||
"rec": "Gebruik Proton Mail of Tuta en voeg PGP toe voor de contacten die ertoe doen.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Gmail / Outlook / iCloud Mail",
|
||||
"hint": "Opslag en toegang tot inhoud blijven sterk gecentraliseerd."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Mail of Tuta voor dagelijks gebruik",
|
||||
"hint": "Al een sterkere optie voor privacy."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Proton / Tuta + PGP voor sommige contacten",
|
||||
"hint": "Een goed niveau van weerstand als u het consequent gebruikt."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Regelmatig PGP / OpenPGP en aandacht voor metadata",
|
||||
"hint": "Een veel robuustere houding."
|
||||
}
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
"label": "Browser & zoeken",
|
||||
"prompt": "Welke browser en zoekmachine gebruikt u het meest?",
|
||||
"rec": "Laat Chrome + Google los voor een privacyvriendelijke browser en zoekmachine.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Chrome + Google",
|
||||
"hint": "Handig, maar sterk traceerbaar."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Firefox of Brave met een private zoekmachine",
|
||||
"hint": "Een echte stap vooruit voor privacy."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Brave / Firefox + DuckDuckGo / Startpage / SearXNG",
|
||||
"hint": "Al een stevige dagelijkse basis."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad Browser / LibreWolf / Tor Browser naar context",
|
||||
"hint": "Een sterkere houding tegen surveillance."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"label": "DNS",
|
||||
"prompt": "Hoe lost u domeinnamen op?",
|
||||
"rec": "Ga over op versleutelde DNS met een minimale, privacyvriendelijke resolver.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "De DNS van uw provider of router",
|
||||
"hint": "De makkelijkste optie, maar vaak de minst private."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Versleutelde DNS bij een mainstream provider",
|
||||
"hint": "Een goede eerste stap."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Quad9 / Mullvad DNS / NextDNS",
|
||||
"hint": "Een echte verbetering als u traceerbaarheid wilt beperken."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Versleutelde DNS plus een bewuste anti-trackingstrategie",
|
||||
"hint": "Een meer bewuste en robuuste houding."
|
||||
}
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"label": "VPN",
|
||||
"prompt": "Welke soort VPN gebruikt u?",
|
||||
"rec": "Kies een betrouwbare no-log VPN zoals Mullvad, Proton VPN of IVPN.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Geen VPN",
|
||||
"hint": "Het meest blootgestelde geval voor netwerktraceerbaarheid."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Een gratis of twijfelachtige VPN",
|
||||
"hint": "Kan slechter zijn dan geen als de provider ondoorzichtig is."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Een betrouwbare no-log VPN met een duidelijk beleid",
|
||||
"hint": "Een echte verbetering voor privacy."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Mullvad / Proton VPN / IVPN voor regelmatig gebruik",
|
||||
"hint": "Een serieuzere en coherentere houding."
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwords": {
|
||||
"label": "Wachtwoorden",
|
||||
"prompt": "Hoe beheert u uw wachtwoorden?",
|
||||
"rec": "Gebruik een wachtwoordmanager met unieke, lange wachtwoorden.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Hergebruikte wachtwoorden of opgeslagen in de browser",
|
||||
"hint": "Hoog risico bij een lek of accountcompromittering."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Een basis wachtwoordmanager",
|
||||
"hint": "Al een heel goede stap vooruit."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Bitwarden / KeePassXC / Proton Pass",
|
||||
"hint": "Een gezonde houding die eenvoudig te onderhouden is."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Wachtwoordmanager + unieke, lange wachtwoorden",
|
||||
"hint": "De meest robuuste manier om schade door een lek te beperken."
|
||||
}
|
||||
}
|
||||
},
|
||||
"twofa": {
|
||||
"label": "Tweefactorauth",
|
||||
"prompt": "Welk niveau van tweefactorauthenticatie gebruikt u?",
|
||||
"rec": "Verplaats 2FA naar een authenticator-app of, beter, een hardwaretoken.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Geen 2FA",
|
||||
"hint": "Het zwakste niveau van accountbescherming."
|
||||
},
|
||||
"o1": {
|
||||
"label": "SMS of telefoontje",
|
||||
"hint": "Beter dan niets, maar zeer fragiel."
|
||||
},
|
||||
"o2": {
|
||||
"label": "TOTP in een app (Aegis / Ente Auth)",
|
||||
"hint": "Een goede dagelijkse beschermingslaag."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Hardwaretoken of phishing-resistente 2FA",
|
||||
"hint": "Het sterkste praktische weerstandsniveau."
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"label": "Opslag",
|
||||
"prompt": "Waar bewaart u uw gevoelige bestanden?",
|
||||
"rec": "Verplaats gevoelige bestanden naar Proton Drive, Cryptomator of versleutelde lokale opslag.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Google Drive / iCloud / OneDrive",
|
||||
"hint": "Zeer handig, maar gecentraliseerd."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Proton Drive of een basis versleutelde opslagdienst",
|
||||
"hint": "Een echte verbetering."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Cryptomator of lokaal versleutelde opslag",
|
||||
"hint": "Een robuustere houding."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Lokale opslag + zelfhosting / geavanceerde versleuteling",
|
||||
"hint": "Het meest serieuze niveau voor soevereiniteit."
|
||||
}
|
||||
}
|
||||
},
|
||||
"social": {
|
||||
"label": "Sociaal",
|
||||
"prompt": "Waar brengt u het grootste deel van uw online sociale leven door?",
|
||||
"rec": "Veranker uw aanwezigheid op gedecentraliseerde netwerken en verlaag de afhankelijkheid van platforms.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Meta / X / alleen grote platforms",
|
||||
"hint": "Het simpelst, maar het meest blootgesteld aan druk."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Mastodon / Matrix / Nostr af en toe",
|
||||
"hint": "Een nuttige diversificatie."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Een mix van gedecentraliseerde netwerken en direct contact",
|
||||
"hint": "Een onafhankelijkere houding."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Alternatieve netwerken en weinig platformafhankelijkheid",
|
||||
"hint": "Dat is de echte logica van weerstand."
|
||||
}
|
||||
}
|
||||
},
|
||||
"os": {
|
||||
"label": "Besturingssysteem",
|
||||
"prompt": "Welk besturingssysteem gebruikt u het meest?",
|
||||
"rec": "Evolueer naar GrapheneOS op mobiel en Linux op de desktop.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Standaard Android / iOS / Windows",
|
||||
"hint": "Zeer handig, maar veel minder privacyvriendelijk."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Een meer private Android / Linux / macOS",
|
||||
"hint": "Al een goede verbetering."
|
||||
},
|
||||
"o2": {
|
||||
"label": "GrapheneOS / Linux / BSD",
|
||||
"hint": "Een sterkere basis."
|
||||
},
|
||||
"o3": {
|
||||
"label": "GrapheneOS + Linux + zorgvuldige setupdiscipline",
|
||||
"hint": "Al een zeer sterke houding."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tor": {
|
||||
"label": "Tor & anonimiteit",
|
||||
"prompt": "In welke mate gebruikt u Tor / Tails / anonimiteit?",
|
||||
"rec": "Bouw een gewoonte op met Tor (en Tails waar nodig) voor uw meest gevoelige taken.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Bijna nooit",
|
||||
"hint": "Zeer blootgesteld als de dreiging serieus wordt."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Af en toe, zonder veel discipline",
|
||||
"hint": "Een eerste stap, maar nog fragiel."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Tor voor sommige gevoelige taken",
|
||||
"hint": "Al serieuzer."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Tor + Tails met sterke discipline",
|
||||
"hint": "Het meest passende niveau voor een hoge dreiging."
|
||||
}
|
||||
}
|
||||
},
|
||||
"money": {
|
||||
"label": "Geld",
|
||||
"prompt": "Hoe gaat u om met uw digitale geld?",
|
||||
"rec": "Voeg privacybewuste betaalmiddelen toe en scheid identiteiten waar mogelijk.",
|
||||
"options": {
|
||||
"o0": {
|
||||
"label": "Alleen bankkaarten en standaardrekeningen",
|
||||
"hint": "De traceerbaarheid is hoog."
|
||||
},
|
||||
"o1": {
|
||||
"label": "Enkele privacybewuste betaaltools",
|
||||
"hint": "Een eerste poging tot scheiding."
|
||||
},
|
||||
"o2": {
|
||||
"label": "Een mix van privérekeningen en beperkte crypto",
|
||||
"hint": "Al meer doordacht."
|
||||
},
|
||||
"o3": {
|
||||
"label": "Sterkere praktijken van financiële soevereiniteit",
|
||||
"hint": "Een veel serieuzere houding."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bands": {
|
||||
"exposed": {
|
||||
"title": "Blootgesteld",
|
||||
"summary": "Uw setup is nog sterk afhankelijk van de grote platforms. De eerste stappen zijn eenvoudig en effectief."
|
||||
},
|
||||
"awakening": {
|
||||
"title": "Ontwaken",
|
||||
"summary": "U bent begonnen met het diversifiëren van uw tools. Een paar bewuste keuzes kunnen echt verschil maken."
|
||||
},
|
||||
"resistant": {
|
||||
"title": "Weerstand",
|
||||
"summary": "Uw setup is al serieus. U hebt een stevige basis voor weerstand tegen censuur en surveillance."
|
||||
},
|
||||
"hardened": {
|
||||
"title": "Aangescherpt",
|
||||
"summary": "U draait een aangescherpte stack met een sterke pseudonieme basis. De volgende focus is discipline en compartimentering."
|
||||
},
|
||||
"sovereign": {
|
||||
"title": "Soeverein",
|
||||
"summary": "Uw positie is al zeer sterk. U bent dicht bij hoge digitale soevereiniteit, met veeleisende technische keuzes."
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -9,7 +9,8 @@
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"themeToggle": "Toggle dark mode",
|
||||
"toTop": "Back to top"
|
||||
"toTop": "Back to top",
|
||||
"quiz": "Quiz"
|
||||
},
|
||||
"banner": {
|
||||
"reviewNote": "Machine-translated section, review pending."
|
||||
@@ -149,5 +150,29 @@
|
||||
"copy": "Copy the link",
|
||||
"copied": "Link copied ✓",
|
||||
"print": "Print / PDF"
|
||||
},
|
||||
"quiz": {
|
||||
"stamp": "Educational quiz · 3 min",
|
||||
"eyebrow": "Estimate your censorship resistance",
|
||||
"title": "Resistance quiz",
|
||||
"lede": "A compass, not a scoreboard: answer twelve quick questions to spot the weak points in your setup — no shame — then follow concrete fixes from the guide.",
|
||||
"privacyNote": "The score is calculated locally in your browser. Nothing is sent, nothing is stored.",
|
||||
"start": "Start the quiz",
|
||||
"readGuide": "Read the full guide",
|
||||
"progress": "{done} / {total} answered",
|
||||
"completePrompt": "Answer all twelve questions to reveal your score.",
|
||||
"resultEyebrow": "Your result",
|
||||
"outOf": "/ 100",
|
||||
"barsHeading": "Score by area",
|
||||
"recHeading": "Your top 3 next steps",
|
||||
"openGuide": "Open guide →",
|
||||
"restart": "Retake the quiz",
|
||||
"copy": "Copy result link",
|
||||
"copied": "Link copied ✓",
|
||||
"share": "Share",
|
||||
"shareX": "Post on X",
|
||||
"shareText": "My censorship-resistance score: {score}/100 — {band}. Check yours:",
|
||||
"bandsHeading": "What the score means",
|
||||
"categoriesHeading": "The twelve areas, and where to improve"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -9,7 +9,8 @@
|
||||
"language": "Langue",
|
||||
"theme": "Thème",
|
||||
"themeToggle": "Basculer le mode sombre",
|
||||
"toTop": "Haut de page"
|
||||
"toTop": "Haut de page",
|
||||
"quiz": "Quiz"
|
||||
},
|
||||
"banner": {
|
||||
"reviewNote": "Section traduite automatiquement, relecture en attente."
|
||||
@@ -149,5 +150,29 @@
|
||||
"copy": "Copier le lien",
|
||||
"copied": "Lien copié ✓",
|
||||
"print": "Imprimer / PDF"
|
||||
},
|
||||
"quiz": {
|
||||
"stamp": "Quiz éducatif · 3 min",
|
||||
"eyebrow": "Évaluer sa résistance à la censure",
|
||||
"title": "Quiz de résistance",
|
||||
"lede": "Une boussole, pas un classement : répondez à douze questions rapides pour repérer les points faibles de votre pile — sans jugement — puis suivez des pistes concrètes issues du guide.",
|
||||
"privacyNote": "Le score est calculé localement dans votre navigateur. Rien n'est envoyé, rien n'est enregistré.",
|
||||
"start": "Commencer le quiz",
|
||||
"readGuide": "Lire le guide complet",
|
||||
"progress": "{done} / {total} répondues",
|
||||
"completePrompt": "Répondez aux douze questions pour révéler votre score.",
|
||||
"resultEyebrow": "Votre résultat",
|
||||
"outOf": "/ 100",
|
||||
"barsHeading": "Score par domaine",
|
||||
"recHeading": "Vos 3 prochaines étapes",
|
||||
"openGuide": "Ouvrir le guide →",
|
||||
"restart": "Refaire le quiz",
|
||||
"copy": "Copier le lien du résultat",
|
||||
"copied": "Lien copié ✓",
|
||||
"share": "Partager",
|
||||
"shareX": "Publier sur X",
|
||||
"shareText": "Mon score de résistance à la censure : {score}/100 — {band}. Évaluez le vôtre :",
|
||||
"bandsHeading": "Ce que le score signifie",
|
||||
"categoriesHeading": "Les douze domaines, et où progresser"
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -9,7 +9,8 @@
|
||||
"language": "Taal",
|
||||
"theme": "Thema",
|
||||
"themeToggle": "Donkere modus wisselen",
|
||||
"toTop": "Terug naar boven"
|
||||
"toTop": "Terug naar boven",
|
||||
"quiz": "Quiz"
|
||||
},
|
||||
"banner": {
|
||||
"reviewNote": "Automatisch vertaalde sectie, nazicht volgt."
|
||||
@@ -149,5 +150,29 @@
|
||||
"copy": "Link kopiëren",
|
||||
"copied": "Link gekopieerd ✓",
|
||||
"print": "Afdrukken / PDF"
|
||||
},
|
||||
"quiz": {
|
||||
"stamp": "Educatieve quiz · 3 min",
|
||||
"eyebrow": "Schat uw weerstand tegen censuur in",
|
||||
"title": "Weerstandsquiz",
|
||||
"lede": "Een kompas, geen ranglijst: beantwoord twaalf snelle vragen om de zwakke plekken in uw setup te vinden — zonder oordeel — en volg dan concrete oplossingen uit de gids.",
|
||||
"privacyNote": "De score wordt lokaal in uw browser berekend. Er wordt niets verzonden en niets opgeslagen.",
|
||||
"start": "Start de quiz",
|
||||
"readGuide": "Lees de volledige gids",
|
||||
"progress": "{done} / {total} beantwoord",
|
||||
"completePrompt": "Beantwoord alle twaalf vragen om uw score te onthullen.",
|
||||
"resultEyebrow": "Uw resultaat",
|
||||
"outOf": "/ 100",
|
||||
"barsHeading": "Score per gebied",
|
||||
"recHeading": "Uw top 3 volgende stappen",
|
||||
"openGuide": "Open de gids →",
|
||||
"restart": "Doe de quiz opnieuw",
|
||||
"copy": "Kopieer resultaatlink",
|
||||
"copied": "Link gekopieerd ✓",
|
||||
"share": "Delen",
|
||||
"shareX": "Plaats op X",
|
||||
"shareText": "Mijn score voor weerstand tegen censuur: {score}/100 — {band}. Test de uwe:",
|
||||
"bandsHeading": "Wat de score betekent",
|
||||
"categoriesHeading": "De twaalf gebieden, en waar te verbeteren"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -8,17 +8,22 @@ import '../styles/components.css'
|
||||
|
||||
interface Props {
|
||||
locale: Locale
|
||||
// Subpage slug appended after the locale prefix ('' = home). Drives the
|
||||
// canonical + hreflang URLs so /quiz and /fr/quiz point at themselves.
|
||||
path?: string
|
||||
}
|
||||
|
||||
const { locale } = Astro.props
|
||||
const { locale, path = '' } = Astro.props
|
||||
const t = useT(locale)
|
||||
const site = Astro.site!
|
||||
|
||||
const canonical = new URL(`${localePath(locale)}/`.replace('//', '/'), site)
|
||||
const alternates = locales.map((l) => ({
|
||||
hreflang: l,
|
||||
href: new URL(`${localePath(l)}/`.replace('//', '/'), site).href,
|
||||
}))
|
||||
const localeRoot = (l: Locale) => `${localePath(l)}/`.replace('//', '/')
|
||||
const localeHref = (l: Locale) =>
|
||||
new URL(path ? `${localeRoot(l)}${path}` : localeRoot(l), site).href
|
||||
|
||||
const canonical = new URL(localeHref(locale))
|
||||
const alternates = locales.map((l) => ({ hreflang: l, href: localeHref(l) }))
|
||||
const xDefaultHref = new URL(path ? `/${path}` : '/', site).href
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -30,7 +35,7 @@ const alternates = locales.map((l) => ({
|
||||
<meta name="description" content={t('site.description')} />
|
||||
<link rel="canonical" href={canonical.href} />
|
||||
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
<link rel="alternate" hreflang="x-default" href={new URL('/', site).href} />
|
||||
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import observatoryCore from '../data/observatory.json'
|
||||
import directoryCore from '../data/directory.json'
|
||||
import checklistCore from '../data/checklist.json'
|
||||
import alliesCore from '../data/allies.json'
|
||||
import quizCore from '../data/quiz.json'
|
||||
|
||||
import enEvents from '../i18n/content/en/events.json'
|
||||
import enObservatory from '../i18n/content/en/observatory.json'
|
||||
@@ -33,6 +34,9 @@ import nlObservatory from '../i18n/content/nl/observatory.json'
|
||||
import nlDirectory from '../i18n/content/nl/directory.json'
|
||||
import nlChecklist from '../i18n/content/nl/checklist.json'
|
||||
import nlAllies from '../i18n/content/nl/allies.json'
|
||||
import enQuiz from '../i18n/content/en/quiz.json'
|
||||
import frQuiz from '../i18n/content/fr/quiz.json'
|
||||
import nlQuiz from '../i18n/content/nl/quiz.json'
|
||||
|
||||
/* ─── enums (mirroring pr1's content model) ────────────────────────────── */
|
||||
|
||||
@@ -252,3 +256,121 @@ export function loadDataset<N extends DatasetName>(name: N, locale: Locale): Dat
|
||||
...(def.overlaySchema.parse(prose[row.id]) as Record<string, string>),
|
||||
})) as DatasetShapes[N][]
|
||||
}
|
||||
|
||||
/* ─── quiz (nested: questions × options + bands) ───────────────────────────
|
||||
The censorship-resistance quiz doesn't fit the flat id→prose overlay model
|
||||
of loadDataset (each question owns four options), so it gets its own core
|
||||
schema and loader. It stays OUT of DATASET_NAMES/registry on purpose — the
|
||||
generic dataset tests iterate that list and assume the flat shape. Parity
|
||||
(questions, per-question options, bands) is still checked in all three
|
||||
locales via assertOverlayParity, so a drifted translation fails the build. */
|
||||
|
||||
const quizOptionCoreSchema = z
|
||||
.object({ id: slug, points: z.number().int().min(0).max(10) })
|
||||
.strict()
|
||||
const quizQuestionCoreSchema = z
|
||||
.object({ id: slug, anchor: slug, options: z.array(quizOptionCoreSchema).min(2) })
|
||||
.strict()
|
||||
const quizBandCoreSchema = z
|
||||
.object({
|
||||
id: slug,
|
||||
min: z.number().int().min(0).max(100),
|
||||
max: z.number().int().min(0).max(100),
|
||||
})
|
||||
.strict()
|
||||
const quizCoreSchema = z
|
||||
.object({
|
||||
bands: z.array(quizBandCoreSchema).min(1),
|
||||
questions: z.array(quizQuestionCoreSchema).min(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const quizOptionProseSchema = z
|
||||
.object({ label: z.string().min(1), hint: z.string().min(1) })
|
||||
.strict()
|
||||
const quizQuestionProseSchema = z
|
||||
.object({
|
||||
label: z.string().min(1),
|
||||
prompt: z.string().min(1),
|
||||
rec: z.string().min(1),
|
||||
options: z.record(z.string(), quizOptionProseSchema),
|
||||
})
|
||||
.strict()
|
||||
const quizBandProseSchema = z
|
||||
.object({ title: z.string().min(1), summary: z.string().min(1) })
|
||||
.strict()
|
||||
const quizOverlaySchema = z
|
||||
.object({
|
||||
questions: z.record(z.string(), quizQuestionProseSchema),
|
||||
bands: z.record(z.string(), quizBandProseSchema),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export interface QuizOption {
|
||||
id: string
|
||||
points: number
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
export interface QuizQuestion {
|
||||
id: string
|
||||
anchor: string
|
||||
label: string
|
||||
prompt: string
|
||||
rec: string
|
||||
options: QuizOption[]
|
||||
}
|
||||
export interface QuizBand {
|
||||
id: string
|
||||
min: number
|
||||
max: number
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
export interface Quiz {
|
||||
bands: QuizBand[]
|
||||
questions: QuizQuestion[]
|
||||
}
|
||||
|
||||
const quizOverlays: Record<Locale, OverlayFile> = { en: enQuiz, fr: frQuiz, nl: nlQuiz }
|
||||
|
||||
/**
|
||||
* Validates the language-neutral quiz core and the requested locale overlay,
|
||||
* checks id parity for bands, questions and each question's options, then
|
||||
* returns the fully merged, localized quiz in authoring order.
|
||||
*/
|
||||
export function loadQuiz(locale: Locale): Quiz {
|
||||
const core = quizCoreSchema.parse(quizCore)
|
||||
const overlay = quizOverlaySchema.parse(overlayEntries(quizOverlays[locale]))
|
||||
|
||||
assertOverlayParity(
|
||||
core.bands.map((b) => b.id),
|
||||
overlay.bands,
|
||||
`quiz.bands/${locale}`,
|
||||
)
|
||||
assertOverlayParity(
|
||||
core.questions.map((q) => q.id),
|
||||
overlay.questions,
|
||||
`quiz.questions/${locale}`,
|
||||
)
|
||||
|
||||
const bands = core.bands.map((band) => ({ ...band, ...overlay.bands[band.id] }))
|
||||
const questions = core.questions.map((question) => {
|
||||
const prose = overlay.questions[question.id]
|
||||
assertOverlayParity(
|
||||
question.options.map((o) => o.id),
|
||||
prose.options,
|
||||
`quiz.${question.id}.options/${locale}`,
|
||||
)
|
||||
return {
|
||||
id: question.id,
|
||||
anchor: question.anchor,
|
||||
label: prose.label,
|
||||
prompt: prose.prompt,
|
||||
rec: prose.rec,
|
||||
options: question.options.map((option) => ({ ...option, ...prose.options[option.id] })),
|
||||
}
|
||||
})
|
||||
|
||||
return { bands, questions }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro'
|
||||
import Quiz from '../../components/Quiz.astro'
|
||||
import { translatedLocales, type Locale } from '../../i18n/config'
|
||||
|
||||
export function getStaticPaths() {
|
||||
return translatedLocales.map((lang) => ({ params: { lang } }))
|
||||
}
|
||||
|
||||
const locale = Astro.params.lang as Locale
|
||||
---
|
||||
|
||||
<Base locale={locale} path="quiz">
|
||||
<Quiz locale={locale} />
|
||||
</Base>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro'
|
||||
import Quiz from '../components/Quiz.astro'
|
||||
import { defaultLocale } from '../i18n/config'
|
||||
---
|
||||
|
||||
<Base locale={defaultLocale} path="quiz">
|
||||
<Quiz locale={defaultLocale} />
|
||||
</Base>
|
||||
@@ -742,4 +742,48 @@
|
||||
.hero-meta .tag.p3 {
|
||||
--dotc: var(--lvl3);
|
||||
}
|
||||
|
||||
/* ─── quiz (progress + per-area score bars) ─── */
|
||||
/* Widths come from --quiz-progress / --quiz-bar, set by the quiz script via
|
||||
the CSSOM. style-src 'self' blocks inline style="" attributes but allows
|
||||
programmatic style writes, so the bars stay CSP-clean. */
|
||||
.quiz-progress {
|
||||
height: 9px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.quiz-progress__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--quiz-progress, 0);
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-bright));
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
.quiz-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5.5rem, 8rem) 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
.quiz-bar {
|
||||
height: 8px;
|
||||
background: var(--surface-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.quiz-bar__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--quiz-bar, 0);
|
||||
background: var(--accent);
|
||||
border-radius: inherit;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quiz-progress__fill,
|
||||
.quiz-bar__fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { defaultLocale, locales } from '../../src/i18n/locales'
|
||||
|
||||
// The generic nojs/zero-request specs only visit the three home pages, so the
|
||||
// /quiz route gets its own coverage: it must be readable with JS off (the
|
||||
// twelve questions and the scoring key are server-rendered), reveal a score
|
||||
// once fully answered (JS enhancement), and never touch a third-party host.
|
||||
const quizPaths = locales.map((l) => (l === defaultLocale ? '/quiz' : `/${l}/quiz`))
|
||||
|
||||
for (const path of quizPaths) {
|
||||
test(`quiz is readable and self-contained without JS on ${path}`, async ({ page }) => {
|
||||
await page.goto(path)
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
// all twelve questions are prerendered as native fieldsets
|
||||
await expect(page.locator('[data-quiz-question]')).toHaveCount(12)
|
||||
await expect(page.locator('[data-quiz-question]').first()).toBeVisible()
|
||||
// the scoring key (bands + per-area guidance) is always visible
|
||||
await expect(page.locator('#quiz-key')).toBeVisible()
|
||||
await expect(page.locator('[data-quiz-cat-key] [data-cat-link]').first()).toBeVisible()
|
||||
// the computed result panel stays hidden until answered (needs JS)
|
||||
await expect(page.locator('[data-quiz-result]')).toBeHidden()
|
||||
// the TOC ("Contents") nav link must target the home guide, not a dead
|
||||
// in-page #toc that only exists on the guide page
|
||||
await expect(page.locator('#toc')).toHaveCount(0)
|
||||
await expect(page.locator('header a[href$="/#toc"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test(`quiz has zero third-party requests on ${path}`, async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL!).origin
|
||||
const offenders: string[] = []
|
||||
page.on('request', (req) => {
|
||||
const url = new URL(req.url())
|
||||
if (url.origin !== origin && url.protocol !== 'data:') offenders.push(req.url())
|
||||
})
|
||||
await page.goto(path, { waitUntil: 'networkidle' })
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
await page.waitForTimeout(500)
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test('answering every question reveals a 100/100 sovereign score', async ({
|
||||
page,
|
||||
javaScriptEnabled,
|
||||
}) => {
|
||||
test.skip(javaScriptEnabled === false, 'scoring is a JS enhancement')
|
||||
await page.goto('/quiz')
|
||||
const questions = page.locator('[data-quiz-question]')
|
||||
const count = await questions.count()
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
// the strongest option is last in every question and worth the max points
|
||||
await questions.nth(i).locator('input[type="radio"]').last().check()
|
||||
}
|
||||
const result = page.locator('[data-quiz-result]')
|
||||
await expect(result).toBeVisible()
|
||||
await expect(page.locator('[data-quiz-score]')).toHaveText('100')
|
||||
await expect(page.locator('[data-quiz-recs] > *')).toHaveCount(3)
|
||||
// the live progress counter tracks answers (regression: it once stuck at 0)
|
||||
await expect(page.locator('[data-quiz-progress-text]')).toContainText('12 / 12')
|
||||
})
|
||||
Reference in New Issue
Block a user