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
+2 -1
View File
@@ -11,8 +11,9 @@ In July 2026, the European Parliament let the "Chat Control" machinery advance
Its promises, all enforced by tests: Its promises, all enforced by tests:
- **Static and self-contained** — no tracker, no cookie, not a single request to a third-party domain. - **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. - **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 0100 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. - **Printable and mirrorable** — a single-file offline version ships with every build.
## Stack ## Stack
+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>
+10 -1
View File
@@ -17,9 +17,18 @@ const t = useT(locale)
> >
EXIT CHAT CONTROL EXIT CHAT CONTROL
</a> </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')} {t('nav.toc')}
</a> </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"> <div class="ms-auto flex items-center gap-3">
<nav aria-label={t('nav.language')}> <nav aria-label={t('nav.language')}>
<details class="lang-menu" data-lang-menu> <details class="lang-menu" data-lang-menu>
+131
View File
@@ -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 }
]
}
]
}
+302
View File
@@ -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."
}
}
}
+302
View File
@@ -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": "Cest 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 laccè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": "Cest 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 lessentiel 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": "Cest la vraie logique de résistance."
}
}
},
"os": {
"label": "Système dexploitation",
"prompt": "Quel système dexploitation utilisez-vous le plus ?",
"rec": "Évoluez vers GrapheneOS sur mobile et Linux sur lordinateur.",
"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 dinstallation",
"hint": "Déjà une posture très forte."
}
}
},
"tor": {
"label": "Tor & anonymat",
"prompt": "Dans quelle mesure utilisez-vous Tor / Tails / lanonymat ?",
"rec": "Prenez lhabitude 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 cest 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. Lobjectif 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 dune forte souveraineté numérique, avec des choix techniques exigeants."
}
}
}
+303
View File
@@ -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
View File
@@ -9,7 +9,8 @@
"language": "Language", "language": "Language",
"theme": "Theme", "theme": "Theme",
"themeToggle": "Toggle dark mode", "themeToggle": "Toggle dark mode",
"toTop": "Back to top" "toTop": "Back to top",
"quiz": "Quiz"
}, },
"banner": { "banner": {
"reviewNote": "Machine-translated section, review pending." "reviewNote": "Machine-translated section, review pending."
@@ -149,5 +150,29 @@
"copy": "Copy the link", "copy": "Copy the link",
"copied": "Link copied ✓", "copied": "Link copied ✓",
"print": "Print / PDF" "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
View File
@@ -9,7 +9,8 @@
"language": "Langue", "language": "Langue",
"theme": "Thème", "theme": "Thème",
"themeToggle": "Basculer le mode sombre", "themeToggle": "Basculer le mode sombre",
"toTop": "Haut de page" "toTop": "Haut de page",
"quiz": "Quiz"
}, },
"banner": { "banner": {
"reviewNote": "Section traduite automatiquement, relecture en attente." "reviewNote": "Section traduite automatiquement, relecture en attente."
@@ -149,5 +150,29 @@
"copy": "Copier le lien", "copy": "Copier le lien",
"copied": "Lien copié ✓", "copied": "Lien copié ✓",
"print": "Imprimer / PDF" "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
View File
@@ -9,7 +9,8 @@
"language": "Taal", "language": "Taal",
"theme": "Thema", "theme": "Thema",
"themeToggle": "Donkere modus wisselen", "themeToggle": "Donkere modus wisselen",
"toTop": "Terug naar boven" "toTop": "Terug naar boven",
"quiz": "Quiz"
}, },
"banner": { "banner": {
"reviewNote": "Automatisch vertaalde sectie, nazicht volgt." "reviewNote": "Automatisch vertaalde sectie, nazicht volgt."
@@ -149,5 +150,29 @@
"copy": "Link kopiëren", "copy": "Link kopiëren",
"copied": "Link gekopieerd ✓", "copied": "Link gekopieerd ✓",
"print": "Afdrukken / PDF" "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
View File
@@ -8,17 +8,22 @@ import '../styles/components.css'
interface Props { interface Props {
locale: Locale 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 t = useT(locale)
const site = Astro.site! const site = Astro.site!
const canonical = new URL(`${localePath(locale)}/`.replace('//', '/'), site) const localeRoot = (l: Locale) => `${localePath(l)}/`.replace('//', '/')
const alternates = locales.map((l) => ({ const localeHref = (l: Locale) =>
hreflang: l, new URL(path ? `${localeRoot(l)}${path}` : localeRoot(l), site).href
href: new URL(`${localePath(l)}/`.replace('//', '/'), 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> <!doctype html>
@@ -30,7 +35,7 @@ const alternates = locales.map((l) => ({
<meta name="description" content={t('site.description')} /> <meta name="description" content={t('site.description')} />
<link rel="canonical" href={canonical.href} /> <link rel="canonical" href={canonical.href} />
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.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="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" /> <link rel="apple-touch-icon" href="/favicon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
+122
View File
@@ -17,6 +17,7 @@ import observatoryCore from '../data/observatory.json'
import directoryCore from '../data/directory.json' import directoryCore from '../data/directory.json'
import checklistCore from '../data/checklist.json' import checklistCore from '../data/checklist.json'
import alliesCore from '../data/allies.json' import alliesCore from '../data/allies.json'
import quizCore from '../data/quiz.json'
import enEvents from '../i18n/content/en/events.json' import enEvents from '../i18n/content/en/events.json'
import enObservatory from '../i18n/content/en/observatory.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 nlDirectory from '../i18n/content/nl/directory.json'
import nlChecklist from '../i18n/content/nl/checklist.json' import nlChecklist from '../i18n/content/nl/checklist.json'
import nlAllies from '../i18n/content/nl/allies.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) ────────────────────────────── */ /* ─── 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>), ...(def.overlaySchema.parse(prose[row.id]) as Record<string, string>),
})) as DatasetShapes[N][] })) 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 }
}
+15
View File
@@ -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>
+9
View File
@@ -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>
+44
View File
@@ -742,4 +742,48 @@
.hero-meta .tag.p3 { .hero-meta .tag.p3 {
--dotc: var(--lvl3); --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;
}
}
} }
+60
View File
@@ -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')
})