Commit a7ae5dd7 by PLN (Algolia)

tsa: Clarté TSA app — quiz flow, results, /tsa + /asd

Same grammar as Clarté TDAH (clarte kit: ProgressHeader, QuestionCard,
OptionPills, useDigitKeys), linear 25-item flow: consent-gated home with
optional gender, intro, auto-advancing questions, recap with jump,
results with total band + percentiles vs autistic and non-autistic
norms, five interpretation caveats, copyable consult sentence, jspdf
report. FR at /tsa, EN at /asd, mirroring /tdah + /adhd.
parent c8548e01
import ClarteTsa from '@/components/tsa/ClarteTsa';
export const metadata = {
title: 'Clarté TSA: Autistic Camouflaging Self-Assessment (CAT-Q)',
description: 'A structured self-assessment of autistic camouflaging in adults using the CAT-Q (Hull et al., 2019): 25 items, compensation / masking / assimilation subscales, percentiles against autistic and non-autistic norms. Free, private, 100% local.',
};
export default function Page() {
return <ClarteTsa lang="en" />;
}
import ClarteTsa from '@/components/tsa/ClarteTsa';
export const metadata = {
title: 'Clarté TSA : Auto-évaluation du camouflage autistique (CAT-Q)',
description: 'Auto-évaluation du camouflage autistique chez l’adulte avec le CAT-Q (Hull et al., 2019) : 25 items, sous-échelles compensation / masquage / assimilation, percentiles vs normes autistes et non-autistes. Gratuit, confidentiel, 100 % local.',
};
export default function Page() {
return <ClarteTsa lang="fr" />;
}
'use client';
/* Clarté TSA — racine. Écran unique, une question à la fois, même grammaire de
flux que Clarté TDAH mais linéaire (25 items CAT-Q, pas de porte ni jalon) :
accueil (consentement bloquant, genre facultatif) → intro → 25 questions →
récapitulatif → résultats. Persistance : contrat catq:v1 — {responses,
gender, pos, currentSection, consented?}. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CATQ_ITEMS, TOTAL_QUESTIONS, GENDERS,
} from './data';
import { dict as dictFr } from './i18n/fr';
import { dict as dictEn } from './i18n/en';
import {
readState, writeState, clearState, storageAvailable,
} from './engine';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import QuestionCard from '@/components/clarte/QuestionCard';
import OptionPills from '@/components/clarte/OptionPills';
import useDigitKeys from '@/components/clarte/useDigitKeys';
import ResultsView from './ResultsView';
import styles from './tsa.module.css';
const NBSP = ' ';
function questionnaireNodes() {
const nodes = [{ type: 'intro', id: 'catq' }];
CATQ_ITEMS.forEach((q) => nodes.push({ type: 'question', q }));
nodes.push({ type: 'recap' });
return nodes;
}
export default function ClarteTsa({ lang }) {
const t = lang === 'en' ? dictEn : dictFr;
const [mounted, setMounted] = useState(false);
const [state, setState] = useState(null); // {responses, gender, pos, consented}
const [storageOk, setStorageOk] = useState(true);
const [route, setRouteLocal] = useState({ view: 'home' });
useEffect(() => {
setMounted(true);
setStorageOk(storageAvailable());
const saved = readState();
setState(saved
? { responses: saved.responses || {}, gender: saved.gender || null,
pos: typeof saved.pos === 'number' ? saved.pos : 0,
consented: !!saved.consented }
: { responses: {}, gender: null, pos: 0, consented: false });
}, []);
const persist = useCallback((next) => {
setState(next);
return writeState(next);
}, []);
/* Entrée dans le flux : mémorise la position pour la reprise. */
const goFlow = useCallback((pos) => {
setState((s) => {
const n = { ...s, pos };
writeState(n);
return n;
});
setRouteLocal({ view: 'flow', pos });
}, []);
const nodes = useMemo(() => questionnaireNodes(), []);
const commitAnswer = useCallback((qid, value) => {
setState((s) => {
const responses = { ...s.responses, [qid]: value };
const next = { ...s, responses };
writeState(next);
return next;
});
}, []);
const restart = useCallback(() => {
setState((s) => {
const n = { responses: {}, gender: s.gender, pos: 0, consented: false };
writeState(n);
return n;
});
setRouteLocal({ view: 'home' });
}, []);
if (!mounted || !state) return <div className={styles.root} />;
const answered = Object.keys(state.responses).length;
if (route.view === 'home') {
return (
<HomeView
t={t} styles={styles} state={state} storageOk={storageOk}
answered={answered} total={TOTAL_QUESTIONS}
onGender={(g) => persist({ ...state, gender: g })}
onStart={(pos) => { persist({ ...state, consented: true }); goFlow(answered > 0 ? Math.max(pos, 1) : 0); }}
onRestart={restart}
/>
);
}
if (route.view === 'results') {
return (
<ResultsView
t={t} styles={styles} responses={state.responses} gender={state.gender} lang={lang}
onHome={() => setRouteLocal({ view: 'home' })}
onReview={() => goFlow(firstQuestionNode(state.responses))}
onRestart={restart}
/>
);
}
// flux : intro / question / récapitulatif
const pos = Math.max(0, Math.min(route.pos ?? state.pos ?? 0, nodes.length - 1));
const node = nodes[pos];
if (node.type === 'recap') {
return (
<RecapView
t={t} styles={styles} responses={state.responses} nodes={nodes}
onJump={(i) => goFlow(i)}
onResults={() => setRouteLocal({ view: 'results' })}
onHome={() => setRouteLocal({ view: 'home' })}
/>
);
}
if (node.type === 'intro') {
return (
<div className={styles.interstitial}>
<h1>{t['intro.catq.h']}</h1>
<p className={styles.muted}>{answered} {lang === 'en' ? 'answered' : 'répondu(es)'} · {TOTAL_QUESTIONS} {lang === 'en' ? 'in total' : 'au total'}</p>
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={() => goFlow(pos + 1)}>
{t['intro.continue.btn']}
</button>
<p>{t['intro.catq.p']}</p>
</div>
);
}
const q = node.q;
return (
<QuestionScreen
key={q.id}
q={q} t={t} styles={styles}
value={state.responses[q.id]}
answered={answered} total={TOTAL_QUESTIONS}
onPick={(v) => {
commitAnswer(q.id, v);
const nextIsQuestion = nodes[pos + 1] && nodes[pos + 1].type === 'question';
if (nextIsQuestion) {
setTimeout(() => setRouteLocal((r) => (r.view === 'flow' ? { view: 'flow', pos: pos + 1 } : r)), 350);
} else {
setRouteLocal({ view: 'flow', pos: pos + 1 });
}
}}
onNext={() => setRouteLocal({ view: 'flow', pos: Math.min(pos + 1, nodes.length - 1) })}
onPrev={() => setRouteLocal({ view: 'flow', pos: Math.max(pos - 1, 0) })}
/>
);
}
function firstQuestionNode(responses) {
for (let i = 0; i < CATQ_ITEMS.length; i++) {
if (responses[CATQ_ITEMS[i].id] === undefined) return i + 1; /* +1 : l'intro */
}
return questionnaireNodes().length - 2; /* dernière question */
}
function HomeView({ t, styles, state, storageOk, answered, total, onGender, onStart, onRestart }) {
const [consent, setConsent] = useState(!!state.consented);
const pos = typeof state.pos === 'number' ? state.pos : 0;
return (
<div className={styles.root}>
<h1>{t['header.logo.h1']}<span className={styles.tagline}>{t['header.subtitle']}</span></h1>
<h2>{t['home.h2']}</h2>
<p>{t['home.intro.p']}</p>
<div className={styles.cards}>
<div className={styles.card}>
<h4>{t['home.card.catq.h4']}</h4>
<p>{t['home.card.catq.p']}</p>
</div>
<div className={styles.card}>
<h4>{t['home.card.norms.h4']}</h4>
<p>{t['home.card.norms.p']}</p>
</div>
<div className={styles.card}>
<h4>{t['home.card.privacy.h4']}</h4>
<p>{t['home.card.privacy.p']}</p>
</div>
</div>
<div className={styles.genderRow}>
<fieldset>
<legend>{t['home.gender.label']}</legend>
{GENDERS.map((g) => (
<label key={g}>
<input
type="radio" name="catq-gender" value={g}
checked={state.gender === g}
onChange={() => onGender(g)}
/>
<span>{t['home.gender.' + g]}</span>
</label>
))}
</fieldset>
</div>
<div className={styles.warning}>
<h4>{t['home.warning.h4']}</h4>
<ul>
<li><strong>{t['home.warning.li1']}</strong></li>
<li>{t['home.warning.li2']}</li>
<li>{t['home.warning.li3']}</li>
<li>{t['home.warning.li4']}</li>
<li>{t['home.warning.li5']}</li>
</ul>
</div>
{answered > 0 && (
<div className={styles.resume}>
<p>{t['resume.count'].replace('{n}', String(answered)).replace(/question\(s\)/, answered > 1 ? 'questions' : 'question')}</p>
<div className={styles.nav}>
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={() => onStart(pos)}>{t['resume.primary']}</button>
<button className={styles.btn} onClick={onRestart}>{t['home.restart.btn']}</button>
</div>
</div>
)}
{answered === 0 && (
<div className={styles.consent}>
<label>
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
<span>{t['home.consent.label']}</span>
</label>
<button className={`${styles.primaryBtn} ${styles.btn}`} disabled={!consent} onClick={() => onStart(pos)}>
{t['home.start.btn']}
</button>
</div>
)}
<p className={styles.muted}>{storageOk ? t['home.storage.line'] : '⚠ localStorage'}</p>
</div>
);
}
/* Écran question : même grammaire que TDAH (QuestionCard + OptionPills +
useDigitKeys), Likert 1–7, touches 1–7, flèches, Entrée. Auto-avance portée
par le parent. */
function QuestionScreen({ q, t, styles, value, answered, total, onPick, onNext, onPrev }) {
useDigitKeys(7, (i) => onPick(i + 1));
useEffect(function arrowsAndEnter() {
function onKey(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (event.target && event.target.isContentEditable)) return;
if (event.key === 'ArrowRight' || event.key === 'Enter') { event.preventDefault(); onNext(); }
else if (event.key === 'ArrowLeft') { event.preventDefault(); onPrev(); }
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cardRef = useRef(null);
useEffect(function focusQuestion() {
const node = cardRef.current && cardRef.current.querySelector('h2');
if (node) { node.setAttribute('tabindex', '-1'); node.focus({ preventScroll: true }); }
}, []);
const options = [1, 2, 3, 4, 5, 6, 7].map((v) => ({ value: v, label: t['catq.option.' + v] }));
return (
<div>
<ProgressHeader
answered={answered}
total={total}
labels={{
answeredLine: (n, m) => progressLine(t, n, m),
fullLine: (n) => progressLine(t, n, 0),
}}
/>
<div ref={cardRef} className={styles.quizCard}>
<QuestionCard
ribbon={t['catq.likert.h2'] + NBSP + '· ' + q.num + ' / ' + TOTAL_QUESTIONS}
question={t[q.t]}
footer={
<div className={styles.nav}>
<button className={styles.linkbtn} onClick={onPrev}>{t['intro.back'] || '←'}</button>
<button className={styles.linkbtn} onClick={onNext}>{t['intro.skip.btn']}</button>
</div>
}
>
<OptionPills options={options} value={value != null ? value : null} onChange={onPick} indexHint />
</QuestionCard>
</div>
</div>
);
}
function progressLine(t, n, m) {
let s = t['progress.answered'];
s = s.replace(/\{n\}/g, String(n)).replace(/\{m\}/g, String(m));
s = s.replace(/question\(s\)/g, n > 1 ? 'questions' : 'question');
s = s.replace(/répondue\(s\)/g, n > 1 ? 'répondues' : 'répondue');
s = s.replace(/\s*·\s*(il en reste\s*0|0 to go)/, '');
return s;
}
function RecapView({ t, styles, responses, nodes, onJump, onResults, onHome }) {
const answeredCount = Object.keys(responses).length;
const unanswered = TOTAL_QUESTIONS - answeredCount;
const firstMissing = nodes.findIndex((n) => n.type === 'question' && responses[n.q.id] === undefined);
const optionLabel = (qid) => {
const v = responses[qid];
if (v === undefined) return null;
return t['catq.option.' + v] || String(v);
};
return (
<div className={styles.root}>
<ProgressHeader
answered={answeredCount}
total={TOTAL_QUESTIONS}
labels={{ answeredLine: (n, m) => progressLine(t, n, m), fullLine: (n) => progressLine(t, n, 0) }}
/>
<h1>{t['recap.title']}</h1>
<div className={styles.recapGroup}>
<ul>
{nodes.filter((n) => n.type === 'question').map((n) => {
const label = optionLabel(n.q.id);
return (
<li key={n.q.id}>
<button type="button" className={styles.recapRow} onClick={() => onJump(nodeIndex(nodes, n.q.id))}>
<span className={styles.recapText}>{t[n.q.t]}</span>
<span className={label ? styles.recapChip : styles.recapChipEmpty}>
{label || t['recap.unanswered.chip']}
</span>
</button>
</li>
);
})}
</ul>
</div>
<div className={styles.nav}>
<button className={styles.btn} onClick={onHome}>{t['recap.backHome']}</button>
{unanswered === 0 ? (
<button className={`${styles.primaryBtn} ${styles.btn}`} onClick={onResults}>{t['recap.complete.btn']}</button>
) : (
<>
<span className={styles.muted}>{t['recap.missing.count'].replace('{n}', String(unanswered)).replace(/question\(s\)/, unanswered > 1 ? 'questions' : 'question')}</span>
<button className={styles.btn} onClick={() => onJump(firstMissing)}>{t['recap.missing.go']}</button>
</>
)}
</div>
</div>
);
}
function nodeIndex(nodes, qid) {
return nodes.findIndex((n) => n.type === 'question' && n.q.id === qid);
}
export { questionnaireNodes, firstQuestionNode, TOTAL_QUESTIONS };
'use client';
/* Clarté TSA — résultats : rappel non-diagnostique, score total + bande
qualitative + percentiles appariés au genre (Hull 2020 via Φ normale),
trois sous-échelles, cinq points d'interprétation prudents (fiche
NovoPsych), phrase copiable, PDF. */
import { useMemo, useState } from 'react';
import { calculateResults } from './engine';
import { SUBSCALES } from './data';
import { buildReport } from './pdf';
export default function ResultsView({ t, styles, responses, gender, lang, onHome, onReview, onRestart }) {
const r = useMemo(() => calculateResults(responses, gender), [responses, gender]);
const [copied, setCopied] = useState(false);
const fill = (s) => (s || '')
.replace('{score}', String(r.total.score))
.replace('{band}', t['results.band.' + r.total.band])
.replace('{pNA}', String(r.total.pNonAutistic))
.replace('{pA}', String(r.total.pAutistic))
.replace('{genre}', t['results.gender.' + (gender || 'unspecified')]);
const summaryText = fill(t['results.summary.text']);
const consultSentence = t['results.consult.sentence']
.replace('{score}', String(r.total.score))
.replace('{band}', t['results.band.' + r.total.band])
.replace('{comp}', String(r.subscales.compensation.score)).replace('{compMax}', String(r.subscales.compensation.max))
.replace('{mask}', String(r.subscales.masking.score)).replace('{maskMax}', String(r.subscales.masking.max))
.replace('{assim}', String(r.subscales.assimilation.score)).replace('{assimMax}', String(r.subscales.assimilation.max));
const copySentence = () => {
const done = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); };
try {
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(consultSentence).then(done, () => {}); return; }
} catch (e) {}
try {
const inp = document.createElement('textarea');
inp.value = consultSentence; document.body.appendChild(inp); inp.select();
if (document.execCommand('copy')) done();
document.body.removeChild(inp);
} catch (e) {}
};
return (
<div className={styles.root}>
<p className={styles.warningInline}><strong>{t['results.warning.p']}</strong></p>
<h2>{t['results.summary.h2']}</h2>
<p className={styles.scoreBig}>
{r.total.score}<small> / 175 · {t['results.band.' + r.total.band]}</small>
</p>
<p className={styles.summaryText}>{summaryText}</p>
<h2>{t['results.sub.h2']}</h2>
{SUBSCALES.map((sub) => {
const s = r.subscales[sub.key];
const line = t['results.sub.line']
.replace('{name}', t[sub.nameKey])
.replace('{score}', String(s.score)).replace('{max}', String(s.max))
.replace('{pNA}', String(s.pNonAutistic)).replace('{pA}', String(s.pAutistic))
.replace('{band}', t['results.band.' + descriptorKey(s)]);
return (
<div key={sub.key} className={styles.card}>
<h4>{t[sub.nameKey]}</h4>
<p>{t[sub.descKey]}</p>
<p className={styles.subLine}>{line}</p>
</div>
);
})}
<h2>{t['results.percentile.h2']}</h2>
<p>{t['results.percentile.p']}</p>
<h2>{t['results.caveats.h2']}</h2>
<ul>
<li>{t['results.caveat.1']}</li>
<li>{t['results.caveat.2']}</li>
<li>{t['results.caveat.3']}</li>
<li>{t['results.caveat.4']}</li>
<li>{t['results.caveat.5']}</li>
</ul>
<h2>{t['results.consult.title']}</h2>
<p><strong>{t['results.consult.sentence.label']}</strong></p>
<div className={styles.consultRow}>
<p>{consultSentence}</p>
<button className={styles.btn} onClick={copySentence}>{copied ? '✓' : t['recap.share.copy']}</button>
</div>
<div className={styles.nav}>
<button className={styles.btn} onClick={onHome}>{t['recap.backHome']}</button>
<button className={styles.btn} onClick={onReview}>{t['recap.review.fromresults']}</button>
<button className={styles.btn} onClick={() => buildReport(t, responses, { ...r, gender }, lang, summaryText)}>{t['results.download.btn']}</button>
<button className={styles.btn} onClick={onRestart}>{t['results.restart.btn']}</button>
</div>
<h2>{t['results.source.h2']}</h2>
<p className={styles.muted}>{t['results.source.p']}</p>
</div>
);
}
/* Bande qualitative d'une subscale : mêmes tranches de percentiles que le
total, appliquées au percentile non-autiste (la référence du descripteur). */
function descriptorKey(s) {
const p = s.pNonAutistic;
if (p <= 5) return 'extremely_low';
if (p <= 24) return 'low';
if (p <= 75) return 'moderate';
if (p <= 94) return 'high';
return 'extremely_high';
}
/* Clarté TSA — compte rendu PDF : même structure que le rapport TDAH
(titre + avertissement, synthèse, scores, annexe des 25 réponses,
références, pied de page non-diagnostique). */
import { CATQ_ITEMS } from './data';
export async function buildReport(t, responses, r, lang, summaryText) {
let JsPDF;
try { ({ jsPDF: JsPDF } = await import('jspdf')); }
catch (e) { console.error('jspdf unavailable', e); return; }
const doc = new JsPDF({ unit: 'pt', format: 'a4' });
const W = doc.internal.pageSize.getWidth();
const H = doc.internal.pageSize.getHeight();
let y = 50;
function ensure(h) {
if (y + h > H - 50) { footer(); doc.addPage(); y = 50; }
}
function footer() {
const i = doc.internal.getNumberOfPages();
doc.setFontSize(8); doc.setTextColor(130);
doc.text(t['pdf.pagefooter'].replace('{i}', String(i)), 50, H - 30);
}
function text(s, size = 10, bold = false, x = 50) {
doc.setFont('helvetica', bold ? 'bold' : 'normal');
doc.setFontSize(size);
const lines = doc.splitTextToSize(s, W - x - 50);
lines.forEach((line) => { ensure(14); doc.text(line, x, y); y += 14; });
}
text(t['pdf.title'], 16, true); y += 6;
text(t['pdf.warning.p'], 9); y += 10;
text(t['pdf.summary.h'], 13, true);
text(summaryText || '', 10); y += 10;
text(t['pdf.section1.h'], 12, true);
text(t['results.summary.text']
.replace('{score}', String(r.total.score))
.replace('{band}', t['results.band.' + r.total.band])
.replace('{pNA}', String(r.total.pNonAutistic))
.replace('{pA}', String(r.total.pAutistic))
.replace('{genre}', t['results.gender.' + (r.gender || 'unspecified')]), 10);
y += 8;
text(t['pdf.annexe.h'], 12, true);
CATQ_ITEMS.forEach((q) => {
const v = responses[q.id];
const answer = v === undefined ? t['pdf.unanswered'] : t['catq.option.' + v] || String(v);
text('• ' + t[q.t] + ' — ' + answer, 9);
});
text(t['pdf.refs.h'], 12, true);
text(t['pdf.refs.items'], 8);
footer();
doc.save(lang === 'en' ? 'catq-self-assessment-report.pdf' : 'compte-rendu-catq.pdf');
}
/* Clarté TSA — app shell. Même famille visuelle que TDAH (tdah.module.css) :
les tokens vivent globalement (styles/clarte-tokens.css). */
.root { max-width: 40rem; margin: 0 auto; padding: var(--sp-4) var(--sp-3) var(--sp-6); font-family: var(--font-clarte); background: var(--paper); color: var(--ink); line-height: 1.65; }
.root h1 { font-size: var(--fs-h1); line-height: 1.3; }
.root h1 .tagline { display: block; font-size: var(--fs-small); font-weight: 400; color: var(--ink-faint); }
.root h2 { font-size: 1.25rem; margin: var(--sp-4) 0 var(--sp-2); }
.root p { max-width: var(--measure-prose); }
.muted { color: var(--ink-faint); font-size: var(--fs-small); }
.cards { display: grid; gap: var(--sp-2); margin: var(--sp-3) 0; }
.card { background: #fff; border: 1px solid var(--line-strong); border-radius: var(--radius); padding: var(--sp-3); }
.card h4 { margin: 0 0 var(--sp-1); font-size: var(--fs-body); }
.card p { margin: 0; font-size: var(--fs-small); color: var(--ink); }
.warning { background: #fff; border: 1px solid var(--line-strong); border-radius: var(--radius); padding: var(--sp-3); margin: var(--sp-3) 0; }
.warning h4 { margin: 0 0 var(--sp-2); font-size: var(--fs-body); }
.warning ul { margin: 0; padding-left: 1.1rem; }
.warning li { margin: var(--sp-1) 0; font-size: var(--fs-small); }
.btn { border: 1px solid var(--line-strong); background: #fff; border-radius: var(--radius); padding: .6rem 1.1rem; font: inherit; color: var(--ink); cursor: pointer; }
.btn:hover { border-color: var(--teal); }
.btn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.btn:disabled { background: var(--paper); color: var(--ink-faint); border-color: var(--line); cursor: default; }
.primaryBtn { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
.primaryBtn:hover { background: var(--teal-hover); }
.primaryBtn:disabled { background: var(--teal); color: #fff; opacity: 1; border-color: var(--line-strong); }
.linkbtn { background: none; border: none; color: var(--teal-text); text-decoration: underline; cursor: pointer; font: inherit; padding: 0; }
.linkbtn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.nav { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); margin-top: var(--sp-4); flex-wrap: wrap; }
.consent { margin: var(--sp-4) 0; }
.consent label { display: flex; gap: .6rem; align-items: baseline; font-size: var(--fs-small); }
.consent button { margin-top: var(--sp-3); }
.resume { margin: var(--sp-4) 0; padding: var(--sp-3); background: var(--teal-soft); border-radius: var(--radius); }
.quizCard h2:focus { outline: 2px solid var(--teal); outline-offset: 4px; border-radius: 4px; }
.note { margin-top: var(--sp-3); }
.note summary { color: var(--ink-faint); font-size: var(--fs-small); cursor: pointer; }
.note p { font-size: var(--fs-small); color: var(--ink-faint); }
.genderRow { margin: var(--sp-3) 0; }
.genderRow fieldset { border: none; margin: 0; padding: 0; }
.genderRow legend { font-size: var(--fs-small); margin-bottom: var(--sp-1); }
.genderRow label { display: flex; gap: .5rem; align-items: baseline; font-size: var(--fs-small); margin: var(--sp-1) 0; cursor: pointer; }
.interstitial { max-width: 34rem; margin: var(--sp-6) auto; }
.recapGroup h3 { font-size: var(--fs-body); margin: var(--sp-3) 0 var(--sp-1); color: var(--teal-text); }
.recapGroup ul { list-style: none; padding: 0; margin: 0; }
.recapRow { width: 100%; display: flex; justify-content: space-between; gap: var(--sp-2); background: none; border: none; border-bottom: 1px solid var(--line); padding: var(--sp-1) 0; font: inherit; text-align: left; cursor: pointer; }
.recapRow:hover { border-bottom-color: var(--teal); }
.recapText { font-size: var(--fs-small); }
.recapChip { font-size: var(--fs-caption); color: var(--teal-text); white-space: nowrap; }
.recapChipEmpty { font-size: var(--fs-caption); color: var(--ink-faint); white-space: nowrap; }
.summaryText { font-size: var(--fs-question); }
.warningInline strong { color: var(--danger); }
.scoreBig { font-size: 2.4rem; font-weight: 700; line-height: 1.2; margin: var(--sp-2) 0; }
.scoreBig small { font-size: var(--fs-body); font-weight: 400; color: var(--ink-faint); }
.subLine { border-bottom: 1px solid var(--line); padding: var(--sp-1) 0; font-size: var(--fs-small); }
.consultRow { display: flex; gap: var(--sp-2); align-items: baseline; flex-wrap: wrap; }
@media (max-width: 480px) {
.root { padding: var(--sp-3) var(--sp-2) var(--sp-5); }
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment