Commit 079e98fc by PLN (Algolia)

Rebuild couple in React on the Clarté kit; fix ICP persona findings

parent a67bddeb
export const meta = {
name: 'icp-user-test',
description: 'Persona agents drive the real site (Playwright), give ICP-user feedback, synthesize a report',
whenToUse: 'After a build exists in out/ (yarn build). Use for any me.nech.pl subsite: pass routes + personas via args.',
phases: [
{ title: 'Drive', detail: 'one agent per persona × route, Playwright session + screenshots' },
{ title: 'Synthesize', detail: 'merge into one actionable feedback report' },
],
}
// Usage: Workflow({name: 'icp-user-test', args: {
// routes: [{url: '/tdah/', name: 'TDAH'}], // served from out/ on localhost
// personas: [...], // optional, defaults below
// port: 4300, // base port; per-agent offset added
// }})
const routes = (args && args.routes) || [
{ url: '/tdah/', name: 'Clarté TDAH' },
{ url: '/couple/', name: 'Clarté couple' },
]
const port = (args && args.port) || 4300
const PERSONAS = [
{ key: 'first-timer', brief: 'First-time visitor on a phone (375px viewport), tired, evening, slightly anxious about what they are about to answer. Notices welcome, trust cues, whether the first step feels safe and obvious. Answers a handful of questions for real, then stops where they would naturally stop.' },
{ key: 'keyboard-native', brief: 'Power user on desktop who refuses the mouse: digits to answer, Enter/arrows to navigate. Reports every place the flow demands pointer interaction, every focus trap, every lost keystroke. Uses a 1280px viewport.' },
{ key: 'returning-skeptic', brief: 'Returning visitor mid-questionnaire (reload midway to test resume), skeptical of anything that smells diagnostic, allergic to marketing cadence and emoji. Reloads the page mid-flow, checks their answers survived, judges whether the score framing shames or steadies. Phone viewport.' },
]
const personas = (args && args.personas) || PERSONAS
const FINDINGS = {
type: 'object',
properties: {
persona: { type: 'string' },
route: { type: 'string' },
first_impression: { type: 'string' },
friction: { type: 'array', items: { type: 'object', properties: {
severity: { type: 'string', enum: ['blocker', 'annoyance', 'nit'] },
where: { type: 'string' }, what: { type: 'string' }, quote: { type: 'string' } },
required: ['severity', 'where', 'what'] } },
trust: { type: 'integer', minimum: 1, maximum: 5 },
care: { type: 'integer', minimum: 1, maximum: 5 },
drop_off: { type: 'string' },
console_errors: { type: 'array', items: { type: 'string' } },
screenshots: { type: 'array', items: { type: 'string' } },
},
required: ['persona', 'route', 'first_impression', 'friction', 'trust', 'care'],
}
const CTX = `You are role-playing a real user with a specific mindset, testing a real web app the way that user would. The site is the static export of /home/pln/Work/Perso/www (served from out/). Playwright + chromium are installed (repo devDependency @playwright/test; browsers in ~/.cache/ms-playwright).
Method (do exactly this):
1. Serve the build: \`cd /home/pln/Work/Perso/www && python3 -m http.server PORT --directory out\` in the BACKGROUND (use run_in_background or nohup with a log file; note the PID you spawned — you may only wait on PIDs you spawned).
2. Write a small Playwright driver (node, CommonJS, require('@playwright/test').chromium — actually const { chromium } = require('playwright/test') or require('@playwright/test').chromium both work) to /tmp/icp-<persona>.js. It should: launch chromium headless, set the viewport your persona uses, goto http://localhost:PORT<url>, and perform your persona's journey with real interactions (page.click, page.fill, page.keyboard.press, page.reload). Take screenshots at: first paint, mid-flow, and every moment your persona would pause — save to /tmp/icp-<persona>-<n>.png (fullPage: false, readable size). Capture console errors via page.on('console'/'pageerror').
3. Run the driver in the foreground (bounded Bash with generous timeout, retry once on flake). Poll the server's log only if something fails; never end your turn to wait on it.
4. READ the screenshots with the Read tool (they render visually) and judge them with your persona's eyes: contrast, hierarchy, warmth, anything clinical-cold or AI-flavored.
5. Kill only the server PID you spawned (kill <pid>), by PID never by name.
Your final text is structured data for an orchestrator, not prose for a human. Frictions must be specific: exact screen, exact string quote, what a real user would feel. Do not invent problems to seem thorough; do not flatter. If the flow is good, say where and why in first_impression.`
phase('Drive')
const sessions = await parallel(personas.flatMap(p =>
routes.map(r => () =>
agent(`${CTX}\n\nYOUR PERSONA: ${p.brief}\n\nROUTE under test: ${r.url} (${r.name}). Start from http://localhost:${port}${r.url} . Interact genuinely per your persona. Return via StructuredOutput with persona="${p.key}" and route="${r.name}".`,
{ label: `icp:${p.key}:${r.name}`, phase: 'Drive', model: 'opus', schema: FINDINGS })
)
))
phase('Synthesize')
const results = sessions.filter(Boolean)
const report = await agent(`You are the synthesizer of an ICP user-testing round on me.nech.pl subsites. Below are persona session reports (JSON). Merge them into ONE feedback report written to /home/pln/Work/Perso/Diag_ADHD/redesign/icp-feedback-<YYYYMMDD>.md (use the actual date via \`date +%Y%m%d\`): executive summary (3 lines), top frictions ranked (deduplicated across personas, each with where/what/proposed fix direction), trust & care scores per route, console errors, and what worked (keep list — so fixes do not break it). Be blunt; no hedging stacks; no em dashes.
SESSION REPORTS:
${JSON.stringify(results, null, 1)}
${`Do the work YOURSELF with direct tool calls; do not spawn subagents. Stay in your turn until the file exists.`}
Return via StructuredOutput: {path, top_frictions: [one line each], verdict: 'ship-ready'|'fix-first'}.`,
{ label: 'synthesize', phase: 'Synthesize', model: 'opus', effort: 'high', schema: { type: 'object', properties: { path: { type: 'string' }, top_frictions: { type: 'array', items: { type: 'string' } }, verdict: { type: 'string', enum: ['ship-ready', 'fix-first'] } }, required: ['path', 'top_frictions', 'verdict'] } })
return { report, sessions: results.map(r => ({ persona: r.persona, route: r.route, trust: r.trust, care: r.care, frictions: r.friction.length })) }
\ No newline at end of file
/* Follow-up: verify partial-state card after reopening + persistence. */
const { chromium } = require('@playwright/test');
const BASE = 'http://localhost:4317/couple.html';
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true, locale: 'fr-FR',
});
const page = await context.newPage();
const consoleErrors = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', (e) => consoleErrors.push(e.message));
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
await page.goto(BASE, { waitUntil: 'networkidle' });
await pause(500);
await page.getByRole('button', { name: /TEST DU BONHEUR CONJUGAL/i }).first().click();
await pause(400);
await page.getByRole('button', { name: 'Commencer', exact: true }).first().click();
await pause(400);
await page.getByRole('button', { name: 'Satisfait', exact: true }).click();
await pause(350);
await page.getByRole('button', { name: 'Satisfait', exact: true }).click();
await pause(350);
await page.getByRole('button', { name: 'Neutre', exact: true }).click();
await pause(350);
// Tired: close the tab. Reopen = reload (route resets to home).
await page.reload({ waitUntil: 'networkidle' });
await pause(600);
await page.screenshot({ path: '/tmp/icp-first-timer-15.png' }); // top of home, first card state
// Reopen the half-done questionnaire
await page.getByRole('button', { name: /TEST DU BONHEUR CONJUGAL/i }).first().click();
await pause(500);
await page.screenshot({ path: '/tmp/icp-first-timer-16.png' }); // should be Question 4, answers kept
console.log('CONSOLE_ERRORS:' + JSON.stringify(consoleErrors));
await browser.close();
})().catch((e) => { console.error('DRIVER_FAILED: ' + e.message); process.exit(1); });
import ClarteCouple from '@/components/couple/ClarteCouple';
export const metadata = {
title: 'Clarté · Espace couple',
description:
'Des questionnaires pour mettre des mots, à deux, sur ce qui se passe entre vous. Tout reste sur cet appareil.',
robots: { index: false, follow: false },
};
export default function CouplePage() {
return <ClarteCouple />;
}
import '@/styles/tokens.css'; import '@/styles/tokens.css';
import '@/styles/clarte-tokens.css';
import '@/styles/globals.css'; import '@/styles/globals.css';
import '@/styles/main.css'; import '@/styles/main.css';
import '@/styles/masonry.css'; import '@/styles/masonry.css';
......
'use client';
/* Clarté Button — the single-class .btn family from the redesign spec §1.2.
variants: 'secondary' (default, white on paper) | 'primary' (solid teal).
sizes: 'md' (default) | 'sm'. */
import styles from './button.module.css';
// Side-effect import: ships the :root tokens (palette, type scale, focus ring) with the
// component. Every kit component does this so the kit is self-contained.
import './tokens.module.css';
export default function Button({
variant = 'secondary',
size = 'md',
disabled = false,
type = 'button',
onClick,
children,
...rest
}) {
const classes = [styles.btn];
if (variant === 'primary') classes.push(styles.btnPrimary);
if (size === 'sm') classes.push(styles.btnSm);
return (
<button type={type} className={classes.join(' ')} disabled={disabled} onClick={onClick} {...rest}>
{children}
</button>
);
}
'use client';
/* Clarté OptionPills — vertical stack of full-width options (≥600px: horizontal wrap).
Real <button> semantics with aria-pressed; selected = teal fill + white text.
Controlled: {options, value, onChange}. Keyboard: the PARENT handles digit keys via
useDigitKeys(n, onPick) exported from the kit — one shared implementation for both apps. */
import styles from './option-pills.module.css';
import './tokens.module.css';
export default function OptionPills({ options, value, onChange, indexHint }) {
if (!Array.isArray(options)) return null;
return (
<div className={styles.pills} role="group">
{options.map((option, index) => {
const selected = value != null && option.value === value;
return (
<button
key={option.value ?? index}
type="button"
className={`${styles.pill} ${selected ? styles.pillSelected : ''}`}
aria-pressed={selected}
disabled={Boolean(option.disabled)}
onClick={() => onChange && onChange(option.value)}
>
{indexHint && <span className={styles.hint}>{index + 1}</span>}
{option.label}
</button>
);
})}
</div>
);
}
'use client';
/* Clarté ProgressHeader — sticky progress band.
i18n lives with the caller: `labels.answeredLine(n, m)` renders "{n} … · il en reste {m}"
and `labels.fullLine(n)` renders the m = 0 variant (copy-fr.md §12 shapes).
When `milestone` is non-null it replaces the count line with a 200ms crossfade.
`hidden` removes the band entirely (home/intro surfaces). */
import styles from './progress-header.module.css';
import './tokens.module.css';
export default function ProgressHeader({ answered, total, milestone = null, labels, hidden = false }) {
if (hidden || !labels) return null;
const fraction = total > 0 ? Math.min(Math.max(answered / total, 0), 1) : 0;
const m = Math.max(total - answered, 0);
const countLine = m === 0 ? labels.fullLine(answered) : labels.answeredLine(answered, m);
return (
<header className={styles.progress}>
<div className={styles.layers}>
<p
className={`${styles.layer} ${milestone != null ? styles.layerHidden : ''}`}
aria-hidden={milestone != null}
>
{countLine}
</p>
{milestone != null && (
<p className={styles.layer} role="status">
{milestone}
</p>
)}
</div>
<div className={styles.fill}>
<i className={styles.fillBar} style={{ width: `${fraction * 100}%` }} />
</div>
</header>
);
}
'use client';
/* Clarté QuestionCard — the one-question screen shell.
`ribbon` is the block label (e.g. "ASRS v1.1 · Partie A"), `question` the item text,
`children` the answer area, `footer` the nav-button slot. The card is min-height 60vh
so options stay in a stable vertical position across questions. */
import styles from './question-card.module.css';
import './tokens.module.css';
export default function QuestionCard({ ribbon = null, question, children, footer = null }) {
return (
<section className={styles.card}>
{ribbon != null && ribbon !== '' && <p className={styles.ribbon}>{ribbon}</p>}
<h2 className={styles.question}>{question}</h2>
<div className={styles.answer}>{children}</div>
{footer != null && <footer className={styles.footer}>{footer}</footer>}
</section>
);
}
# Clarté UI kit
Shared client-side kit for the Clarté mini-apps (TDAH at `/tdah`+`/adhd`, Couple at `/couple`).
Design authority: `Diag_ADHD/redesign/couple-redesign-spec.md` §1/§8; committed identity:
paper `#faf8f4`, ink `#3a3630`, teal `#3e7d78`, Atkinson 400/700, 40rem column.
## What exists (`import { … } from '@/components/clarte'`)
- `Button` — `.btn`/`.btn--primary` family, variants `secondary|primary`, sizes `md|sm`,
full state matrix (hover/focus-visible/active/disabled incl. solid primary disabled). Spec §1.2.
- `ProgressHeader` — sticky band; props `{answered, total, milestone?, labels:{answeredLine(n,m), fullLine(n)}, hidden}`;
3px fill `answered/total`, 300ms ease-out, tabular-nums; milestone crossfades over the count line (200ms).
- `QuestionCard` — one-question shell: `{ribbon, question, children, footer}`; card max-width 34rem,
min-height 60vh (stable option position); question 18px/1.5, weight 500, max-width 44ch, `text-wrap: balance`.
- `OptionPills` — `{options:[{value,label,disabled?}], value, onChange}`; full-width vertical stack
(≥600px: wrap); real buttons, `aria-pressed`, min-height 44px, selected = teal fill + white text.
- `useDigitKeys(n, onPick)` — one document keydown listener per mount; digits 1..n call
`onPick(index)` (zero-based); ignores input/textarea/select/contentEditable and ctrl/meta/alt.
- `Slider` — `{min,max,value,anchors:[left,right],onChange,ariaLabel}`; native range, teal accent,
`<output>` readout, verbatim anchors (never invent middle labels); caller handles auto-advance.
- `TextArea` — controlled; `{value,onChange,onDebouncedChange,rows,maxLength,placeholder,ariaLabel,id}`;
`onDebouncedChange` fires 300ms after the last keystroke and on blur; no autofocus; caller owns storage.
- `usePersistentState(key, {fallback, validate?})` — localStorage-backed state; corrupt records fall
back, never crash. `migrateLegacy(prefix)` lists/parses existing keys for migrations and export.
## Storage contracts (existing answers MUST survive the rebuild)
- `couple:v1:q:<qid>` = `{answers: Record<string, number|string|{text}>, pos}` (free text = `{text}`
object, opt-out sentinel = `"optout:<id>"` string, keys `String(index)` with `s<si>:<index>` prefix).
- `couple:v1:session` = `{created, respondent_label}`.
- `clarte:v1` = `{responses, currentSection, lastMilestone?}`.
Full details in the comment block at the top of `usePersistentState.js`. Do not invent new shapes.
## i18n convention
Dictionaries live with each app. The kit never hardcodes user-visible strings: components receive
label functions (`labels.answeredLine(n, m)`), strings (`anchors`), or copy via props. All kit
components are client components (`'use client'`) and import `tokens.module.css` (the `:root` token
block + Atkinson `@font-face`) as a side effect, so the kit is self-contained.
'use client';
/* Clarté Slider — numeric range (e.g. communication 1..9). Native input[type=range],
accent-color teal, verbatim anchor labels below (never invent labels for the middle
steps). <output> readout above. onChange receives the NUMBER (valueAsNumber); the
caller handles auto-advance on commit. */
import { useId } from 'react';
import styles from './slider.module.css';
import './tokens.module.css';
export default function Slider({ min = 1, max = 9, value, anchors = [], onChange, ariaLabel }) {
const id = useId();
const left = anchors[0] ?? '';
const right = anchors[1] ?? '';
function handleChange(event) {
const next = event.target.valueAsNumber;
onChange && onChange(Number.isNaN(next) ? event.target.value : next);
}
return (
<div className={styles.rangeblock}>
<output className={styles.rangeOut} htmlFor={id}>
{value}
</output>
<input
id={id}
type="range"
className={styles.range}
min={min}
max={max}
step={1}
value={value}
onChange={handleChange}
aria-label={ariaLabel}
/>
<div className={styles.anchors}>
<span>{left}</span>
<span>{right}</span>
</div>
</div>
);
}
'use client';
/* Clarté TextArea — free-text answers, controlled.
Autosave contract: the CALLER owns storage. onChange fires immediately (keeps the
controlled value honest); onDebouncedChange fires 300ms after the last keystroke and
is also flushed on blur (blur precedes button clicks, so nothing is lost on nav).
No autofocus: on a small viewport the keyboard overlay would cover the question —
the keyboard opens on tap only. */
import { useEffect, useRef } from 'react';
import styles from './text-area.module.css';
import './tokens.module.css';
export default function TextArea({
value,
onChange,
onDebouncedChange,
rows = 3,
maxLength,
placeholder,
ariaLabel,
id,
}) {
const timerRef = useRef(null);
// Last typed value, kept in a ref (updated in handleChange, NOT at render — a
// re-render with a stale controlled prop must not clobber what was just typed)
// so a blur flush reports what was actually typed.
const typedRef = useRef(value);
function flush() {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (typeof onDebouncedChange === 'function') onDebouncedChange(typedRef.current);
}
useEffect(() => {
return () => {
if (timerRef.current != null) clearTimeout(timerRef.current);
};
}, []);
function handleChange(event) {
const next = event.target.value;
typedRef.current = next;
onChange && onChange(next);
if (typeof onDebouncedChange !== 'function') return;
if (timerRef.current != null) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
onDebouncedChange(next);
}, 300);
}
return (
<textarea
id={id}
className={styles.textarea}
value={value ?? ''}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
aria-label={ariaLabel}
onChange={handleChange}
onBlur={flush}
/>
);
}
/* .btn family — full state matrix from couple-redesign-spec.md §1.2.
Selector convention: component base = single class (0,1,0); state = same class +
pseudo-class, max (0,2,0). No compound ancestor selectors anywhere — this kills the
white-on-white bug class (.nav button outranking .cta) at the convention level. */
.btn {
border: 1px solid var(--line-strong);
background: #fff;
border-radius: var(--radius);
padding: 0.6rem 1.1rem;
font: inherit;
font-family: var(--font-clarte);
font-size: var(--fs-body);
color: var(--ink);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.btn:hover {
border-color: var(--teal);
}
.btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.btn:active {
background: var(--teal-soft);
}
.btn:disabled {
background: var(--paper);
color: var(--ink-faint);
border-color: var(--line);
cursor: default;
}
/* Primary variant. Declared after the base states so its :disabled override wins. */
.btnPrimary {
background: var(--teal);
border-color: var(--teal);
color: #fff;
font-weight: 700;
}
.btnPrimary:hover {
background: var(--teal-hover);
}
.btnPrimary:focus-visible {
outline-color: var(--focus-ring-primary-color);
}
/* Disabled is a solid style, not an opacity hack: teal stays teal, border goes quiet. */
.btnPrimary:disabled {
background: var(--teal);
color: #fff;
opacity: 1;
border-color: var(--line-strong);
}
/* Sizes (md is the base padding above; sm tightens it) */
.btnSm {
padding: 0.45rem 0.85rem;
font-size: var(--fs-small);
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
}
/* Clarté UI kit — barrel. Import from '@/components/clarte'. */
export { default as Button } from './Button';
export { default as ProgressHeader } from './ProgressHeader';
export { default as QuestionCard } from './QuestionCard';
export { default as OptionPills } from './OptionPills';
export { default as Slider } from './Slider';
export { default as TextArea } from './TextArea';
export { default as useDigitKeys } from './useDigitKeys';
export { usePersistentState, migrateLegacy } from './usePersistentState';
/* OptionPills — full-width vertical stack by default, horizontal wrap at ≥600px.
Selected = teal fill + white text (4.76:1, passes AA at 16px). No font-weight flip on
selection: the fill carries the state, a bold flip would reflow the label. */
.pills {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: var(--sp-3) 0;
max-width: var(--measure-question);
}
.pill {
text-align: left;
min-height: 44px;
padding: 0.75rem 0.9rem;
border-radius: var(--radius);
border: 1px solid var(--line-strong);
background: #fff;
font: inherit;
font-family: var(--font-clarte);
font-size: var(--fs-body);
color: var(--ink);
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
}
.pill:hover {
border-color: var(--teal);
}
.pill:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.pill:active {
background: var(--teal-soft);
}
.pill:disabled {
background: var(--paper);
color: var(--ink-faint);
border-color: var(--line);
cursor: default;
}
.pillSelected {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.pillSelected:hover {
border-color: var(--teal);
}
.pillSelected:focus-visible {
outline-color: var(--focus-ring-primary-color);
}
/* A selected-then-disabled state keeps its teal fill readable. */
.pillSelected:disabled {
background: var(--teal);
color: #fff;
opacity: 1;
}
@media (min-width: 600px) {
.pills {
flex-direction: row;
flex-wrap: wrap;
}
.pill {
flex: 1 1 auto;
text-align: center;
}
}
@media (prefers-reduced-motion: reduce) {
.pill {
transition: none;
}
}
.hint {
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
margin-right: .5rem;
font-size: var(--fs-small);
}
.pillSelected .hint { color: #fff; }
/* ProgressHeader — sticky band, single progress semantic per surface.
Line shapes come from the caller (copy-fr.md §12): the kit never hardcodes French. */
.progress {
position: sticky;
top: 0;
z-index: 2;
background: var(--paper);
padding: 0.6rem 1.25rem;
border-bottom: 1px solid var(--line);
font-family: var(--font-clarte);
font-size: var(--fs-caption);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
max-width: 40rem;
margin: 0 auto;
}
/* The two text layers occupy the same grid cell; opacity swaps give the 200ms
crossfade between count line and milestone. The hidden layer is aria-hidden. */
.layers {
display: grid;
}
.layer {
grid-area: 1 / 1;
transition: opacity 0.2s ease;
}
.layerHidden {
opacity: 0;
pointer-events: none;
}
.fill {
height: 3px;
background: var(--line);
border-radius: 2px;
margin-top: 0.35rem;
overflow: hidden;
}
.fillBar {
display: block;
height: 100%;
background: var(--teal);
transition: width 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.layer,
.fillBar {
transition: none;
}
}
/* QuestionCard — the one-question screen shell.
min-height keeps the answer area at a stable vertical position between questions
(no jumping options as text length varies). */
.card {
max-width: 34rem;
min-height: 60vh;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: 0 var(--sp-3) var(--sp-4);
}
.ribbon {
font-family: var(--font-clarte);
font-size: var(--fs-caption);
color: var(--teal-text);
margin: var(--sp-4) 0 var(--sp-2);
}
.question {
font-family: var(--font-clarte);
font-size: var(--fs-question); /* 18px */
line-height: 1.5;
font-weight: 500;
color: var(--ink);
margin: var(--sp-2) 0 var(--sp-4);
max-width: 44ch;
text-wrap: balance;
}
/* Answer area grows; the footer sits at the card bottom so navigation buttons
keep their position across questions of different text lengths. */
.answer {
flex: 1 0 auto;
}
.footer {
margin-top: auto;
padding-top: var(--sp-3);
}
/* Slider — native input[type=range], teal accent, verbatim anchor captions.
The caller handles auto-advance; this component only commits values. */
.rangeblock {
margin: var(--sp-3) 0;
max-width: var(--measure-question);
}
.rangeOut {
display: block;
font-family: var(--font-clarte);
font-size: var(--fs-question);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--teal-text);
margin-bottom: var(--sp-2);
}
.range {
width: 100%;
max-width: var(--measure-question);
accent-color: var(--teal);
}
.range:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.anchors {
display: flex;
justify-content: space-between;
font-family: var(--font-clarte);
font-size: var(--fs-small);
color: var(--ink-faint);
margin-top: var(--sp-1);
}
/* TextArea — native textarea (product register, not a reinvented one).
Placeholder #6d675f on #fff = 5.59:1, passes 4.5:1 (the UA default ~#767676 fails). */
.textarea {
font-family: var(--font-clarte);
font-size: var(--fs-body);
color: var(--ink);
background: #fff;
width: 100%;
max-width: var(--measure-question);
min-height: 7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
resize: vertical;
}
.textarea:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.textarea::placeholder {
color: var(--ink-faint);
opacity: 1;
}
/* Composition class for the Clarté apps. Design tokens themselves live in
styles/clarte-tokens.css (global, imported by app/layout.tsx) — this module
exists so components can reference a hashed local class per CSS-modules rules. */
.clarte {
font-family: var(--font-clarte);
background: var(--paper);
color: var(--ink);
}
'use client';
/* Clarté useDigitKeys — shared digit-key handling for both apps.
useDigitKeys(n, onPick) attaches ONE document-level keydown listener per mount and
keeps it for the component's lifetime (n and onPick are read from refs, so identity
churn re-renders never re-attach). Pressing a digit 1..n calls onPick(index) with the
ZERO-BASED option index (digit 1 → index 0). Digits beyond n are ignored.
Ignored on purpose: events targeting input/textarea/select/contentEditable (so the
slider, free text and the respondent field keep their native keys), any of
ctrl/meta/alt held, and anything that is not a plain '1'..'9' key value. */
import { useEffect, useRef } from 'react';
export default function useDigitKeys(n, onPick) {
const countRef = useRef(n);
const pickRef = useRef(onPick);
countRef.current = n;
pickRef.current = onPick;
useEffect(() => {
function handleKey(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (target) {
const tag = (target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (target.isContentEditable) return;
}
const digit = /^([1-9])$/.exec(event.key);
if (!digit) return;
const index = Number(digit[1]) - 1;
if (index >= countRef.current) return;
if (typeof pickRef.current === 'function') pickRef.current(index);
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, []);
}
'use client';
/* ============================================================================
STORAGE CONTRACTS — do not invent new shapes. Existing user answers MUST survive
the rebuild; both apps read and write exactly these records.
Couple app (public/couple, keys prefixed `couple:v1:`):
couple:v1:q:<qid> = { answers: Record<string, number | string | { text }>, pos }
- answer keys are String(item.index) with an `s<si>:<index>`
prefix for duplicated indexes
- numeric/likert answers are plain numbers
- free-text answers are `{ text }` objects (never a bare
string — a bare "optout:…" would collide with the sentinel)
- opt-out sentinels stay strings: "optout:<id>"
couple:v1:session = { created, respondent_label }
couple:v1:corrupt:* = quarantined raw records (written by the couple engine on
JSON failure — never parsed, never deleted here)
TDAH app (planned /tdah, /adhd):
clarte:v1 = { responses, currentSection, lastMilestone? }
Anything the kit persists goes through usePersistentState(key, shape); anything an
app persists under these keys must match these shapes so a rebuild is invisible to
the person who answered last week. migrateLegacy(prefix) lists and parses existing
keys for one-off migrations and the export feature.
========================================================================== */
import { useCallback, useState } from 'react';
function readStorage(key, shape) {
if (typeof window === 'undefined') return shape.fallback;
let raw = null;
try {
raw = window.localStorage.getItem(key);
} catch (e) {
return shape.fallback; // storage unavailable (private mode): memory-only this session
}
if (raw == null) return shape.fallback;
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (e) {
return shape.fallback; // corrupt record: fall back, never crash the app
}
if (shape.validate && !shape.validate(parsed)) return shape.fallback;
return parsed;
}
/* usePersistentState(key, shape) — shape = { fallback, validate?: (value) => boolean }.
Returns [value, setValue]. Reads once (lazy useState initializer) with the JSON
schema check; writes on every set, guarded — a storage failure degrades to an
in-memory value for the session instead of losing the render. */
export function usePersistentState(key, shape) {
const [value, setValue] = useState(() => readStorage(key, shape));
const set = useCallback(
(next) => {
setValue((prev) => {
const resolved = typeof next === 'function' ? next(prev) : next;
try {
window.localStorage.setItem(key, JSON.stringify(resolved));
} catch (e) {
// private mode / quota: keep the session in-memory, do not crash
}
return resolved;
});
},
[key]
);
return [value, set];
}
/* migrateLegacy(prefix) — list localStorage keys under `prefix` and return parsed
records: [{ key, value }] on success, [{ key, value: null, corrupt: true }] when a
record fails JSON.parse. Read-only; callers decide what to migrate or delete. */
export function migrateLegacy(prefix) {
if (typeof window === 'undefined') return [];
let records = [];
try {
records = Object.keys(window.localStorage)
.filter((key) => key.lastIndexOf(prefix, 0) === 0)
.map((key) => {
let raw = null;
try {
raw = window.localStorage.getItem(key);
} catch (e) {
return { key, value: null, corrupt: true };
}
if (raw == null) return { key, value: null, corrupt: true };
try {
return { key, value: JSON.parse(raw) };
} catch (e) {
return { key, value: null, corrupt: true, raw };
}
});
} catch (e) {
return [];
}
return records;
}
'use client';
/* Clarté couple : composant racine. Routage entre les vues (accueil, intro,
question, feuille de travail, récapitulatif, vue partagée), sonde de
stockage, bannière de navigation privée, dispatch du lien de partage (#p=).
Le rendu est différé après le montage : les vues lisent localStorage, qui
n'existe pas au rendu serveur du build statique. */
import { useCallback, useEffect, useState } from 'react';
import { COUPLE_DATA } from './data';
import { decodePayload, storageAvailable } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
import HomeView from './HomeView';
import IntroView from './IntroView';
import QuizView from './QuizView';
import WorksheetView from './WorksheetView';
import RecapView from './RecapView';
import SharedView, { SharedErrorView } from './SharedView';
function questionnaireById(qid) {
var q = null;
COUPLE_DATA.questionnaires.forEach(function (x) {
if (x.id === qid) q = x;
});
return q;
}
export default function ClarteCouple() {
const [mounted, setMounted] = useState(false);
const [route, setRoute] = useState({ view: 'home' });
const [storageOk, setStorageOk] = useState(true);
const [writeFailed, setWriteFailed] = useState(false);
useEffect(() => {
setMounted(true);
setStorageOk(storageAvailable());
var hash = typeof location !== 'undefined' ? location.hash : '';
if (hash.lastIndexOf('#p=', 0) === 0) {
var payload = decodePayload(hash.slice(3));
setRoute(payload ? { view: 'shared', payload: payload } : { view: 'sharedError' });
} else {
setRoute({ view: 'home' });
}
}, []);
const trackWrite = useCallback((ok) => {
if (!ok) setWriteFailed(true);
}, []);
const goHome = useCallback(() => setRoute({ view: 'home' }), []);
const goIntro = useCallback((qid) => setRoute({ view: 'intro', qid: qid }), []);
const goRecap = useCallback((qid) => setRoute({ view: 'recap', qid: qid }), []);
const goQuiz = useCallback((qid, pos) => {
// Le focus ne traverse jamais les écrans : un anneau de sélection resté
// sur la pilule cliquée se lirait « déjà répondu » sur la question suivante.
if (typeof document !== 'undefined' && document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
var q = questionnaireById(qid);
if (!q) return;
var total = q.sections.reduce(function (a, s) {
return a + (s.items || []).length;
}, 0);
var p = Math.min(Math.max(0, Math.floor(+pos) || 0), total);
if (p >= total) setRoute({ view: 'recap', qid: qid });
else if (q.type === 'worksheet') setRoute({ view: 'worksheet', qid: qid, pos: p });
else setRoute({ view: 'quiz', qid: qid, pos: p });
}, []);
if (!mounted) return null;
const banner = !storageOk || writeFailed ? strings.bannerStorage : null;
const withBanner = (extra) => (
<div className={styles.root}>
<main className={styles.main}>
{banner && <p className={styles.banner} role="status">{banner}</p>}
{extra}
</main>
</div>
);
if (route.view === 'shared') return withBanner(<SharedView payload={route.payload} />);
if (route.view === 'sharedError') return withBanner(<SharedErrorView />);
const q = route.qid ? questionnaireById(route.qid) : null;
switch (route.view) {
case 'intro':
return withBanner(
<IntroView q={q} goHome={goHome} goQuiz={goQuiz} goRecap={goRecap} />
);
case 'quiz':
return withBanner(
<QuizView
key={route.qid + ':' + route.pos}
q={q}
pos={route.pos}
goIntro={goIntro}
goHome={goHome}
goQuiz={goQuiz}
goRecap={goRecap}
trackWrite={trackWrite}
/>
);
case 'worksheet':
return withBanner(
<WorksheetView
key={route.qid + ':' + route.pos}
q={q}
pos={route.pos}
goIntro={goIntro}
goHome={goHome}
goQuiz={goQuiz}
goRecap={goRecap}
trackWrite={trackWrite}
/>
);
case 'recap':
return withBanner(
<RecapView q={q} goHome={goHome} goQuiz={goQuiz} trackWrite={trackWrite} />
);
case 'home':
default:
return withBanner(
<HomeView
goIntro={goIntro}
goQuiz={goQuiz}
goRecap={goRecap}
trackWrite={trackWrite}
/>
);
}
}
'use client';
/* Clarté couple : accueil. Cartes des 8 questionnaires avec état, champ
« Je réponds en tant que », divulgation de visibilité persistante, export
JSON (un appui) et suppression totale (deux appuis, panneau en ligne).
Le composant remonte à chaque retour sur l'accueil : les états des cartes
sont relus à chaque rendu, donc toujours frais après une suppression. */
import { useEffect, useState } from 'react';
import Button from '@/components/clarte/Button';
import { COUPLE_DATA } from './data';
import {
allItems,
displayTitle,
answeredCount,
deleteAllCoupleKeys,
downloadExport,
firstUnanswered,
readRecord,
readSession,
writeSession,
} from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
function cardClick(q, goIntro, goQuiz, goRecap) {
var st = readRecord(q.id);
var total = allItems(q).length;
var n = st ? answeredCount(q, st) : 0;
if (!st || n === 0) goIntro(q.id);
else if (n >= total) goRecap(q.id);
else goQuiz(q.id, firstUnanswered(q, st));
}
export default function HomeView({ goIntro, goQuiz, goRecap, trackWrite }) {
const [session, setSession] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
useEffect(() => {
setSession(readSession());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!session) return null;
var handleRespondent = function (event) {
var next = {
created: session.created,
respondent_label: event.target.value.trim() || 'moi',
};
setSession(next);
trackWrite(writeSession(next));
};
var handleDelete = function () {
deleteAllCoupleKeys();
setConfirmingDelete(false);
setSession(readSession());
};
return (
<div>
<h1 className={styles.title}>{strings.home.h1}</h1>
<p className={styles.muted}>{strings.home.intro}</p>
<ul className={styles.cardlist}>
{COUPLE_DATA.questionnaires.map(function (q) {
var total = allItems(q).length;
var st = readRecord(q.id);
var n = st ? answeredCount(q, st) : 0;
return (
<li key={q.id}>
<button
type="button"
className={styles.cardbtn}
onClick={() => cardClick(q, goIntro, goQuiz, goRecap)}
>
<span>{displayTitle(q.title)}</span>
{st && n >= total ? (
<span className={styles.state}>{strings.home.cardDone}</span>
) : n > 0 ? (
<span className={styles.state}>{strings.home.cardPartial(n, total)}</span>
) : (
<span className={styles.meta}>
{q.type === 'worksheet'
? strings.home.cardWorksheet(total)
: strings.home.cardQuestions(total)}
</span>
)}
</button>
</li>
);
})}
</ul>
<div className={styles.who}>
<label htmlFor="respondent-label">{strings.home.whoLabel}</label>
<input
id="respondent-label"
maxLength={40}
value={session.respondent_label}
onChange={handleRespondent}
/>
</div>
{/* Visibilité partagée : à dire avant la première séance, ça change la façon de répondre. */}
<p className={styles.muted}>{strings.home.disclosure}</p>
<p className={styles.muted}>{strings.home.closing}</p>
<footer className={styles.pagefoot}>
<p>{strings.home.footLine}</p>
<p className={styles.footlinks}>
{confirmingDelete ? (
<>
{strings.home.deleteConfirm}{' '}
<Button onClick={handleDelete}>{strings.home.deleteYes}</Button>{' '}
<button
type="button"
className={styles.linkbtn}
onClick={() => setConfirmingDelete(false)}
>
{strings.home.deleteNo}
</button>
</>
) : (
<>
<button type="button" className={styles.linkbtn} onClick={() => downloadExport()}>
{strings.home.exportBtn}
</button>
{' · '}
<button
type="button"
className={`${styles.linkbtn} ${styles.linkbtnDanger}`}
onClick={() => setConfirmingDelete(true)}
>
{strings.home.deleteBtn}
</button>
</>
)}
</p>
</footer>
</div>
);
}
'use client';
/* Clarté couple : écran d'intro d'un questionnaire. Titre, intro et
instructions verbatim (donnée source), méta « N questions · vous pouvez
arrêter et reprendre », champs d'en-tête de la feuille de travail (folio
uniquement), CTA unique qui route selon l'état (Commencer / Reprendre à la
question N / Voir mes réponses). */
import { useState } from 'react';
import Button from '@/components/clarte/Button';
import { answeredCount, displayTitle, firstUnanswered, readRecord, writeRecord } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function IntroView({ q, goHome, goQuiz, goRecap, trackWrite = function () {} }) {
const [headerDraft, setHeaderDraft] = useState(null);
if (!q) return null;
var items = q.sections.reduce(function (a, s) { return a + (s.items || []).length; }, 0);
var isWorksheet = q.type === 'worksheet';
var st = readRecord(q.id);
var n = st ? answeredCount(q, st) : 0;
var fu = st ? firstUnanswered(q, st) : 0;
var hasHeaderFields = isWorksheet && q.header_fields && q.header_fields.length;
// Reprendre toujours par la première question sans réponse (jamais st.pos,
// qui recule au retour arrière et file devant via Passer).
var start = function () {
var s2 = readRecord(q.id);
var n2 = s2 ? answeredCount(q, s2) : 0;
if (!s2 || n2 === 0) {
goQuiz(q.id, 0);
return;
}
if (n2 >= items) {
goRecap(q.id);
return;
}
goQuiz(q.id, firstUnanswered(q, s2));
};
var setHeaderField = function (f, value) {
var target = readRecord(q.id) || { answers: {}, pos: 0, header: {} };
if (!target.header) target.header = {};
target.header[f.id] = value;
setHeaderDraft(target.header);
trackWrite(writeRecord(q.id, target));
};
// Libellé figé à l'entrée sur l'écran (le moteur vanilla ne re-rendait pas
// pendant la saisie des champs d'en-tête) ; l'action, elle, relit tout.
const [ctaLabel] = useState(function () {
return st == null
? strings.intro.start
: n === items
? strings.intro.seeRecap
: strings.intro.resume(fu + 1);
});
return (
<div>
<h1 className={styles.title}>{displayTitle(q.title)}</h1>
<p className={styles.muted}>{strings.intro.meta(items, isWorksheet)}</p>
{/* Le CTA passe devant la prose verbatim (retour ICP finding 7) : une
soirée fatiguée voit le geste possible avant l'essai de 1985. */}
<div className={styles.nav}>
<Button onClick={goHome}>{strings.intro.back}</Button>
<Button variant="primary" onClick={start}>
{ctaLabel}
</Button>
</div>
{q.intro && <p>{q.intro}</p>}
{q.instructions && <p>{q.instructions}</p>}
{hasHeaderFields && (
<div className={styles.headfields}>
{q.header_fields.map(function (f) {
var id = 'hf-' + f.id;
var value =
(headerDraft && typeof headerDraft[f.id] === 'string' && headerDraft[f.id]) ||
(st && st.header && typeof st.header[f.id] === 'string' && st.header[f.id]) ||
'';
return (
<div key={f.id} className={styles.headfield}>
<label htmlFor={id}>{f.label}</label>
<input
id={id}
type={f.type === 'date' ? 'date' : 'text'}
value={value}
onChange={function (event) {
setHeaderField(f, event.target.value);
}}
/>
</div>
);
})}
<p className={styles.muted}>{strings.intro.headerFieldsNote}</p>
</div>
)}
</div>
);
}
'use client';
/* Clarté couple : écran question (un item par écran). Échelle résolue par
section (fallback default_scale) ; rendu selon le type : pilules (likert /
single_choice), curseur (numeric_range 1..9), zone de texte (free_text).
Sentinelles « optout:<id> » en sous-boutons pointillés, exclues de tous les
totaux. Clavier : chiffres = réponse (useDigitKeys du kit), flèches =
navigation, Entrée = avancer ; le curseur avance au relâchement du pointeur
(change natif), pas à l'ajustement au clavier.
Le parent monte ce composant avec key=qid:pos : chaque question est un
montage frais, comme le re-rendu complet du moteur d'origine ; l'état local
(curseur) repart donc de la valeur enregistrée. */
import { useEffect, useRef, useState } from 'react';
import Button from '@/components/clarte/Button';
import OptionPills from '@/components/clarte/OptionPills';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import QuestionCard from '@/components/clarte/QuestionCard';
import Slider from '@/components/clarte/Slider';
import TextArea from '@/components/clarte/TextArea';
import useDigitKeys from '@/components/clarte/useDigitKeys';
import { COUPLE_DATA } from './data';
import {
allItems,
freeTextSizes,
optValue,
readRecord,
resolveScale,
writeRecord,
} from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function QuizView({ q, pos, goIntro, goHome, goQuiz, goRecap, trackWrite }) {
if (!q) return null;
var items = allItems(q);
var total = items.length;
var cur = items[pos];
if (!cur) return null;
var scale = resolveScale(cur.section, q);
var type = scale ? scale.type : null;
var st = readRecord(q.id) || { answers: {}, pos: 0 };
var current = st.answers[cur.key];
var optionCount = scale && scale.options ? scale.options.length : 0;
var isRange = type === 'numeric_range' && scale.min != null && scale.max != null;
/* Le curseur garde son état de saisie local ; flush et commit sont exposés
à la navigation via un registre (la navigation avant/arrière purge le
différé, comme le moteur purge son texte libre). */
var flushRegistry = useRef(null);
function registerFlush(flush) {
flushRegistry.current = flush;
}
function pick(value) {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
fresh.answers[cur.key] = value;
fresh.pos = pos + 1;
trackWrite(writeRecord(q.id, fresh));
goQuiz(q.id, pos + 1);
}
function goNext() {
if (flushRegistry.current) flushRegistry.current();
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
fresh.pos = pos + 1;
trackWrite(writeRecord(q.id, fresh));
goQuiz(q.id, pos + 1);
}
function goPrev() {
if (flushRegistry.current) flushRegistry.current();
goQuiz(q.id, pos - 1);
}
// Contrat : chaque écran de question enregistre la position courante.
useEffect(function persistPos() {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
if (fresh.pos !== pos) {
fresh.pos = pos;
trackWrite(writeRecord(q.id, fresh));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ---------- clavier ----------
Chiffres : useDigitKeys (kit) — son écouteur ignore input/textarea, donc
le curseur et le texte libre gardent leurs touches natives.
Flèches et Entrée : couche propre (hors champs de saisie), comme le
keymap global du moteur d'origine. */
var digitCount = isRange ? scale.max : optionCount;
useDigitKeys(digitCount, function (index) {
if (isRange) {
// le chiffre engage la valeur, sans avancer
var n = index + 1;
if (rangeApi.current) {
rangeApi.current.set(n);
} else {
setSliderDisplay(n);
commitSliderValue(n);
}
} else if (optionCount) {
pick(scale.options[index].value);
}
});
useEffect(function arrowsAndEnter() {
function onKey(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
var tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (event.target && event.target.isContentEditable) return;
if (event.key === 'ArrowRight') {
event.preventDefault();
goNext();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
goPrev();
} else if (event.key === 'Enter') {
event.preventDefault();
goNext();
}
}
document.addEventListener('keydown', onKey);
return function () {
document.removeEventListener('keydown', onKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Focus sur la question (annonce lecteur d'écran), jamais sur la zone de texte.
useEffect(function focusQuestion() {
if (type === 'free_text') return;
var node = cardRef.current && cardRef.current.querySelector('h2');
if (node) {
node.setAttribute('tabindex', '-1');
node.focus({ preventScroll: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
var cardRef = useRef(null);
var rangeApi = useRef(null);
const [sliderDisplay, setSliderDisplay] = useState(
typeof current === 'number' ? current : scale.min
);
function commitSliderValue(n) {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
if (typeof n === 'number' && !isNaN(n)) fresh.answers[cur.key] = n;
trackWrite(writeRecord(q.id, fresh));
}
var sizes = type === 'free_text' ? freeTextSizes(cur.item) : null;
var optOuts = (cur.item.extra_options || [])
.map(function (k) {
var o = (COUPLE_DATA.opt_out_options || {})[k];
return o ? { id: k, label: o.label } : null;
})
.filter(Boolean);
var showKeyhint = isRange || optionCount > 0;
return (
<div>
<ProgressHeader
answered={pos + 1}
total={total}
labels={{
answeredLine: function (n) {
return strings.quiz.questionOf(n, total);
},
fullLine: function (n) {
return strings.quiz.questionOf(n, total);
},
}}
/>
<div ref={cardRef} className={styles.quizCard}>
<QuestionCard
ribbon={cur.section.title || null}
question={cur.item.text}
footer={
<div className={styles.nav}>
<div className={styles.navGroup}>
<Button size="sm" onClick={goHome}>{strings.quiz.home}</Button>
<Button
size="sm"
onClick={function () {
if (pos === 0) goIntro(q.id);
else goPrev();
}}
>
{strings.quiz.prev}
</Button>
</div>
<Button
size="sm"
onClick={function () {
if (pos + 1 < total) goNext();
else goRecap(q.id);
}}
>
{pos + 1 < total ? strings.quiz.skip : strings.quiz.toRecap}
</Button>
</div>
}
>
{cur.section.section_instructions && (
<p className={styles.sectionNote}>{cur.section.section_instructions}</p>
)}
{type === 'free_text' && (
<div>
<p className={styles.sectionNote}>{strings.quiz.freeTextPrompt}</p>
<TextArea
value={
current && typeof current === 'object' && typeof current.text === 'string'
? current.text
: ''
}
rows={sizes.rows}
maxLength={sizes.max}
ariaLabel={cur.item.text}
onDebouncedChange={function (value) {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
if (value.trim() === '') delete fresh.answers[cur.key];
else fresh.answers[cur.key] = { text: value };
trackWrite(writeRecord(q.id, fresh));
}}
/>
</div>
)}
{isRange && (
<RangeInput
scale={scale}
initial={sliderDisplay}
ariaLabel={cur.item.text}
onDisplay={setSliderDisplay}
onCommit={commitSliderValue}
onAdvance={goNext}
registerApi={function (api) {
rangeApi.current = api;
flushRegistry.current = api ? api.flush : null;
}}
/>
)}
{optionCount > 0 && (
<OptionPills
options={scale.options}
value={typeof current === 'number' || typeof current === 'string' ? current : null}
onChange={pick}
indexHint
/>
)}
{!type && <p className={styles.muted}>{strings.quiz.unknownFormat}</p>}
{optOuts.length > 0 && (
<div className={styles.optoutGroup}>
{optOuts.map(function (o) {
var selected = current === optValue(o.id);
return (
<button
key={o.id}
type="button"
className={`${styles.optout} ${selected ? styles.optoutSelected : ''}`}
aria-pressed={selected}
onClick={function () {
pick(optValue(o.id));
}}
>
{o.label}
</button>
);
})}
</div>
)}
{showKeyhint && <p className={styles.keyhintOnly}>{strings.quiz.keyhint}</p>}
</QuestionCard>
</div>
</div>
);
}
/* Curseur numérique : commit différé à la saisie (250 ms), avance au
relâchement du pointeur (change natif), Entrée avance, les flèches ajustent
sans avancer. React ne distingue pas input et change pour les range : la
couche native écoute le « change » réel. */
function RangeInput({ scale, initial, ariaLabel, onDisplay, onCommit, onAdvance, registerApi }) {
const [value, setValue] = useState(initial);
var valueRef = useRef(initial);
var timer = useRef(null);
var viaKeyboard = useRef(false);
var wrapRef = useRef(null);
function set(n) {
// engagement direct (touche chiffre) : purge le différé, affiche, commit
if (timer.current != null) {
clearTimeout(timer.current);
timer.current = null;
}
valueRef.current = n;
setValue(n);
onDisplay(n);
onCommit(n);
}
function flush() {
if (timer.current != null) {
clearTimeout(timer.current);
timer.current = null;
onCommit(valueRef.current);
}
}
useEffect(function () {
registerApi({ flush: flush, set: set });
return function () {
registerApi(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function handleInput(n) {
valueRef.current = n;
setValue(n);
onDisplay(n);
if (timer.current != null) clearTimeout(timer.current);
timer.current = setTimeout(function () {
timer.current = null;
onCommit(n);
}, 250);
}
useEffect(function () {
var wrap = wrapRef.current;
if (!wrap) return;
function onChange(event) {
if (!event.target || event.target.tagName !== 'INPUT') return;
onCommit(valueRef.current);
if (viaKeyboard.current) {
viaKeyboard.current = false;
} else {
onAdvance();
}
}
function onKeyDown(event) {
if (!event.target || event.target.tagName !== 'INPUT') return;
if (event.key === 'Enter') {
event.preventDefault();
flush();
onAdvance();
} else {
viaKeyboard.current = true; // flèches : ajuste la valeur sans avancer
}
}
wrap.addEventListener('change', onChange);
wrap.addEventListener('keydown', onKeyDown);
return function () {
wrap.removeEventListener('change', onChange);
wrap.removeEventListener('keydown', onKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div ref={wrapRef}>
<Slider
min={scale.min}
max={scale.max}
value={value}
anchors={[
(scale.anchors || {})[String(scale.min)] || '',
(scale.anchors || {})[String(scale.max)] || '',
]}
onChange={handleInput}
ariaLabel={ariaLabel}
/>
</div>
);
}
'use client';
/* Clarté couple : récapitulatif. Machine à états (n = 0 → « Commencer » ;
partiel → détails ouverts si total ≤ 25, résumé avec compte, Entrée =
Reprendre ; complet → ouvert + « Recommencer » avec confirmation). Score
uniquement pour les instruments sum_all_items : pourcentage et bande
seulement à n = total (première bande dont le min est atteint, sinon la
dernière), hors-échelle compté à part. Lien de partage : payload encodé en
base64url dans le fragment (#p=), avertissement affiché. */
import { useEffect, useRef, useState } from 'react';
import Button from '@/components/clarte/Button';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import {
allItems,
displayTitle,
answeredCount,
computeScore,
findBand,
firstUnanswered,
optionLabel,
readRecord,
readSession,
serializeAnswers,
shareUrl,
writeRecord,
} from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function RecapView({ q, goHome, goQuiz, trackWrite }) {
const [shareOpen, setShareOpen] = useState(false);
if (!q) return null;
var items = allItems(q);
var total = items.length;
var st = readRecord(q.id) || { answers: {}, pos: 0 };
var n = answeredCount(q, st);
var optCount = 0;
items.forEach(function (r) {
var v = st.answers[r.key];
if (typeof v === 'string' && v.lastIndexOf('optout:', 0) === 0) optCount++;
});
var sc = computeScore(q, st);
var complete = !!(sc && n === total && optCount === 0);
var band = complete ? findBand(q, sc.pct) : null;
var startContinue = function () {
if (n === 0) {
goQuiz(q.id, 0);
return;
}
if (n < total) {
goQuiz(q.id, firstUnanswered(q, st));
return;
}
if (window.confirm(strings.recap.restartConfirm)) {
trackWrite(writeRecord(q.id, { answers: {}, pos: 0 }));
goQuiz(q.id, 0);
}
};
// Entrée = continuer, mais seulement quand rien n'est à risque : en état
// complet, Entrée ne déclenche jamais la remise à zéro.
var nAtMount = n;
useEffect(function enterKey() {
if (!(nAtMount > 0 && nAtMount < total)) return;
function onKey(event) {
if (event.ctrlKey || event.metaKey || event.altKey) return;
var tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (event.key === 'Enter') {
event.preventDefault();
startContinue();
}
}
document.addEventListener('keydown', onKey);
return function () {
document.removeEventListener('keydown', onKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
var ctaLabel =
n === 0 ? strings.recap.ctaEmpty : n < total ? strings.recap.ctaPartial : strings.recap.ctaComplete;
return (
<div>
<ProgressHeader
answered={n}
total={total}
labels={{
answeredLine: function (count) {
return strings.recap.answeredOf(count, total);
},
fullLine: function (count) {
return strings.recap.answeredOf(count, total);
},
}}
/>
<h1 className={styles.title}>{displayTitle(q.title)}</h1>
<p>{strings.recap.lede}</p>
{n === 0 && <p>{strings.recap.empty}</p>}
{sc && (
<div className={styles.resultBand}>
{complete && (
<>
<div className={styles.score}>{sc.pct}{' '}%</div>
<p>{strings.recap.scoreComplete}</p>
<p className={styles.muted}>
{strings.recap.origLabel} : « {band.label} »
</p>
</>
)}
{!complete && <p>{strings.recap.progressPartial(n, total)}</p>}
{optCount > 0 && <p>{strings.recap.optOutLine(optCount)}</p>}
<p className={styles.muted}>{strings.recap.scoreMusing}</p>
</div>
)}
{/* L'instruction arithmétique du support papier (total à reporter à la main)
ne sert plus à rien : l'app a déjà calculé. On ne garde le texte
d'origine que s'il apporte une lecture, pas un blanc à remplir. */}
{sc && complete && q.scoring && q.scoring.source_instruction && q.scoring.source_instruction.indexOf('___') === -1 && (
<blockquote className={styles.orig}>{q.scoring.source_instruction}</blockquote>
)}
{!sc && n > 0 && <p>{strings.recap.unscored(n, total)}</p>}
{n > 0 && (
<details open={n === total || total <= 25}>
<summary className={styles.summary}>{strings.recap.reviewSummary(n, total)}</summary>
{n < total && <p className={styles.muted}>{strings.recap.remaining(total - n)}</p>}
<table className={styles.recapTable}>
<tbody>
{st.header &&
(q.header_fields || []).map(function (f) {
var val = st.header[f.id];
if (typeof val !== 'string' || !val) return null;
return (
<tr key={'hf-' + f.id}>
<td>{f.label}</td>
<td>{val}</td>
</tr>
);
})}
{items.map(function (r) {
var v = st.answers[r.key];
if (v === undefined) return null;
var isFree = v && typeof v === 'object';
return (
<tr key={r.key}>
<td>{r.item.text}</td>
<td className={isFree ? styles.recapFree : null}>
{optionLabel(q, r.section, v)}
</td>
</tr>
);
})}
</tbody>
</table>
</details>
)}
{n > 0 && (
<>
{!shareOpen && (
<Button onClick={function () { setShareOpen(true); }}>
{strings.recap.share.trigger}
</Button>
)}
{shareOpen && <SharePanel q={q} />}
</>
)}
<div className={styles.nav}>
<Button onClick={goHome}>{strings.recap.backHome}</Button>
<Button variant="primary" onClick={startContinue}>
{ctaLabel}
</Button>
</div>
</div>
);
}
/* Panneau de partage en ligne (pas de modale) : avertissement, champ
readonly, copie avec retour « Lien copié » 2 s, repli execCommand. */
function SharePanel({ q }) {
var inputRef = useRef(null);
const [copied, setCopied] = useState(false);
var payload = serializeAnswers(q.id, readRecord(q.id), readSession().respondent_label);
var url = shareUrl(payload);
function flashCopied() {
setCopied(true);
setTimeout(function () {
setCopied(false);
}, 2000);
}
function copy() {
var ok = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(flashCopied, function () {});
ok = true;
}
} catch (e) {}
if (!ok) {
try {
var inp = inputRef.current;
if (inp) {
inp.select();
if (document.execCommand('copy')) flashCopied();
}
} catch (e) {}
}
}
return (
<div className={styles.sharePanel}>
<p>{strings.recap.share.warning}</p>
<div className={styles.shareRow}>
<input
ref={inputRef}
readOnly
value={url}
aria-label={strings.recap.share.inputAria}
onFocus={function (event) {
event.target.select();
}}
/>
<Button onClick={copy}>{copied ? strings.recap.share.copied : strings.recap.share.copy}</Button>
</div>
<p className={styles.muted}>{strings.recap.share.note}</p>
</div>
);
}
'use client';
/* Clarté couple : vue destinataire d'un lien de partage. Lecture seule
absolue : aucun appel au stockage, aucune navigation, aucun score. Le qid
du payload est résolu contre les données ; inconnu ou payload invalide →
SharedErrorView. */
import { COUPLE_DATA } from './data';
import { allItems, displayTitle, optionLabel } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function SharedView({ payload }) {
var q = null;
COUPLE_DATA.questionnaires.forEach(function (x) {
if (x.id === payload.qid) q = x;
});
if (!q) return <SharedErrorView />;
var answers = {};
payload.answered.forEach(function (a) {
if (a && typeof a.i === 'string') answers[a.i] = a.v;
});
var items = allItems(q);
var n = 0;
items.forEach(function (r) {
if (answers[r.key] !== undefined) n++;
});
return (
<div>
<h1 className={styles.title}>{strings.shared.title(displayTitle(q.title))}</h1>
{payload.respondent && <p className={styles.muted}>{strings.shared.by(payload.respondent)}</p>}
<p className={styles.banner} role="status">
{strings.shared.readOnly}
</p>
<details open>
<summary className={styles.summary}>{strings.recap.reviewSummary(n, items.length)}</summary>
<table className={styles.recapTable}>
<tbody>
{items.map(function (r) {
var v = answers[r.key];
if (v === undefined) return null;
var l = optionLabel(q, r.section, v);
if (!l) {
payload.answered.forEach(function (a) {
if (a && a.i === r.key && typeof a.l === 'string' && !l) l = a.l;
});
}
return (
<tr key={r.key}>
<td>{r.item.text}</td>
<td className={v && typeof v === 'object' ? styles.recapFree : null}>{l}</td>
</tr>
);
})}
</tbody>
</table>
</details>
<footer className={styles.pagefoot}>{strings.shared.foot}</footer>
</div>
);
}
export function SharedErrorView() {
return (
<div>
<h1 className={styles.title}>{strings.shared.errorTitle}</h1>
<p>{strings.shared.error}</p>
<p>
<a href={typeof location !== 'undefined' ? location.href.split('#')[0] : '/'}>
{strings.shared.backHome}
</a>
</p>
</div>
);
}
'use client';
/* Clarté couple : feuille de travail (folio « La vie en couple »), une section
par écran. Deux colonnes au bureau quand la section le demande, triées par
item.column. Aucun raccourci clavier : la saisie libre garde toutes les
touches. Les zones de texte sont auto-sauvegardées (différé 300 ms du kit
TextArea + purge au blur, qui précède le clic des boutons). */
import { useEffect, useRef } from 'react';
import Button from '@/components/clarte/Button';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import TextArea from '@/components/clarte/TextArea';
import { allItems, freeTextSizes, readRecord, writeRecord } from './engine';
import { strings } from './strings';
import styles from './couple.module.css';
export default function WorksheetView({ q, pos, goQuiz, trackWrite }) {
if (!q) return null;
var items = allItems(q);
var total = items.length;
var cur = items[pos];
if (!cur) return null;
var section = cur.section;
var start = pos;
while (start > 0 && items[start - 1].section === section) start--;
var end = pos;
while (end + 1 < total && items[end + 1].section === section) end++;
var st = readRecord(q.id) || { answers: {}, pos: 0, header: {} };
useEffect(function persistPos() {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
if (fresh.pos !== start) {
fresh.pos = start;
trackWrite(writeRecord(q.id, fresh));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
var firstLabelRef = useRef(null);
useEffect(function focusFirstLabel() {
if (firstLabelRef.current) firstLabelRef.current.focus();
}, []);
var secItems = items.slice(start, end + 1);
if (section.layout === 'two_columns') {
secItems = secItems
.slice()
.sort(function (a, b) {
return (a.item.column || 99) - (b.item.column || 99);
});
}
function saveField(key, value) {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
if (value.trim() === '') delete fresh.answers[key];
else fresh.answers[key] = { text: value };
trackWrite(writeRecord(q.id, fresh));
}
var goNextWs = function () {
var fresh = readRecord(q.id) || { answers: {}, pos: 0 };
fresh.pos = Math.min(end + 1, total);
trackWrite(writeRecord(q.id, fresh));
goQuiz(q.id, end + 1);
};
var goPrevWs = function () {
goQuiz(q.id, start - 1);
};
return (
<div>
<ProgressHeader
answered={start + 1}
total={total}
labels={{
answeredLine: function (n) {
return strings.quiz.questionOf(n, total);
},
fullLine: function (n) {
return strings.quiz.questionOf(n, total);
},
}}
/>
<p className={styles.wsSection}>{section.title}</p>
{section.section_instructions && (
<p className={styles.sectionNote}>{section.section_instructions}</p>
)}
<div className={section.layout === 'two_columns' ? `${styles.cols} ${styles.colsTwo}` : styles.cols}>
{secItems.map(function (r, i) {
var sizes = freeTextSizes(r.item);
var v = st.answers[r.key];
return (
<div key={r.key} className={styles.freeitem}>
<p
ref={i === 0 ? firstLabelRef : null}
tabIndex={-1}
className={styles.freeitemLabel}
>
{r.item.text}
</p>
<TextArea
value={v && typeof v === 'object' && typeof v.text === 'string' ? v.text : ''}
rows={sizes.rows}
maxLength={sizes.max}
ariaLabel={r.item.text}
onDebouncedChange={function (value) {
saveField(r.key, value);
}}
/>
</div>
);
})}
</div>
<div className={styles.nav}>
{start > 0 && <Button onClick={goPrevWs}>{strings.quiz.prev}</Button>}
<Button onClick={goNextWs}>
{end + 1 < total ? strings.quiz.nextWorksheet : strings.quiz.toRecap}
</Button>
</div>
</div>
);
}
/* Clarté couple : styles propres à l'app (coquille, accueil, récapitulatif,
feuille de travail, panneau de partage). Tout ce qui est bouton, pilule,
curseur, zone de texte ou bande de progression vient du kit @/components/clarte.
Palette et tokens : bloc :root de components/clarte/tokens.module.css. */
.root {
font-family: var(--font-clarte);
color: var(--ink);
background: var(--paper);
min-height: 100vh;
font-size: var(--fs-body);
line-height: 1.5;
}
.main {
max-width: 40rem;
margin: 0 auto;
padding: var(--sp-5) var(--sp-4) var(--sp-6);
}
.title {
font-size: var(--fs-h1);
line-height: 1.25;
text-wrap: balance;
margin: 0 0 var(--sp-3);
}
.muted {
color: var(--ink-faint);
font-size: var(--fs-body);
max-width: var(--measure-prose);
text-wrap: pretty;
}
.banner {
background: var(--teal-soft);
border: 1px solid var(--teal);
border-radius: var(--radius);
color: var(--ink);
padding: 0.6rem 0.9rem;
margin: 0 0 var(--sp-3);
font-size: var(--fs-small);
}
/* ---------- accueil : cartes ---------- */
.cardlist {
list-style: none;
margin: var(--sp-4) 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.cardbtn {
width: 100%;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.8rem;
text-align: left;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: #fff;
font: inherit;
color: var(--ink);
padding: 0.8rem 1rem;
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.cardbtn:hover {
border-color: var(--teal);
}
.cardbtn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.cardbtn:active {
background: var(--teal-soft);
}
.meta,
.state {
font-size: var(--fs-small);
color: var(--ink-faint);
white-space: nowrap;
}
.state {
color: var(--teal-text);
}
/* ---------- accueil : qui répond ---------- */
.who {
display: flex;
align-items: center;
gap: 0.8rem;
margin: var(--sp-4) 0 var(--sp-2);
}
.who label {
color: var(--ink-faint);
font-size: var(--fs-small);
}
.who input {
font: inherit;
font-size: var(--fs-body); /* ≥16px : iOS Safari ne zoome pas au focus */
width: 16ch;
min-height: 44px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
.who input:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
/* ---------- pied de page ---------- */
.pagefoot {
margin-top: var(--sp-5);
padding-top: var(--sp-3);
border-top: 1px solid var(--line);
font-size: var(--fs-small);
color: var(--ink-faint);
}
.footlinks {
margin: var(--sp-2) 0 0;
}
.linkbtn {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-small);
color: var(--teal-text);
text-decoration: underline;
cursor: pointer;
}
.linkbtn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.linkbtnDanger {
color: var(--danger);
}
/* ---------- question : pilotes hors échelle ---------- */
.optout {
text-align: left;
min-height: 44px;
padding: 0.65rem 0.9rem;
border-radius: var(--radius);
border: 1px dashed var(--line-strong);
background: none;
font: inherit;
font-size: var(--fs-body);
color: var(--ink-faint);
cursor: pointer;
transition: border-color 0.12s ease, background 0.12s ease;
}
.optout:hover {
border-color: var(--teal);
}
.optout:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.optout:active {
background: var(--teal-soft);
}
.optoutSelected {
background: var(--teal-soft);
border-color: var(--teal);
color: var(--ink);
}
.optoutGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: var(--measure-question);
margin-top: 0.5rem;
}
.keyhint {
font-size: var(--fs-caption);
color: var(--ink-faint);
margin: var(--sp-2) 0;
}
.sectionNote {
color: var(--ink-faint);
font-size: var(--fs-body);
margin: 0 0 var(--sp-2);
max-width: var(--measure-question);
text-wrap: pretty;
}
.wsSection {
font-size: var(--fs-small);
color: var(--ink-faint);
margin: var(--sp-4) 0 0.4rem;
}
/* ---------- navigation entre écrans ---------- */
.nav {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: var(--sp-4);
max-width: 34rem;
margin-left: auto;
margin-right: auto;
padding: 0 var(--sp-3);
}
/* ---------- champ d'en-tête (feuille de travail) ---------- */
.headfields {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin: var(--sp-3) 0;
max-width: var(--measure-question);
}
.headfield {
display: flex;
align-items: center;
gap: 0.8rem;
}
.headfield label {
font-size: var(--fs-small);
color: var(--ink-faint);
min-width: 6ch;
}
.headfield input {
font: inherit;
font-size: var(--fs-body);
min-height: 44px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
.headfield input:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
/* ---------- feuille de travail : grille de sections ---------- */
.freeitem {
margin: var(--sp-3) 0;
}
.freeitemLabel {
font-size: var(--fs-question);
font-weight: 500;
line-height: 1.5;
margin: 0 0 var(--sp-1);
max-width: 44ch;
text-wrap: balance;
}
.cols {
display: grid;
grid-template-columns: 1fr;
gap: 0 var(--sp-4);
}
@media (min-width: 640px) {
.colsTwo {
grid-template-columns: 1fr 1fr;
}
}
/* ---------- récapitulatif ---------- */
.summary {
color: var(--ink);
font-weight: 700;
font-size: var(--fs-body);
padding: 0.6rem 0;
cursor: pointer;
}
.summary:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.recapTable {
border-collapse: collapse;
width: 100%;
font-size: var(--fs-small);
margin: var(--sp-2) 0;
}
.recapTable th,
.recapTable td {
text-align: left;
vertical-align: top;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--line);
}
.recapFree {
white-space: pre-wrap;
word-break: break-word;
}
.resultBand {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: #fff;
padding: var(--sp-3) var(--sp-4);
margin: var(--sp-3) 0;
}
.score {
font-size: var(--fs-score);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--teal);
line-height: 1.2;
}
.orig {
border-left: 3px solid var(--teal);
margin: var(--sp-3) 0;
padding: 0.2rem 0 0.2rem 1rem;
color: var(--ink);
font-size: var(--fs-small);
max-width: var(--measure-prose);
}
.sharePanel {
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: #fff;
padding: var(--sp-3);
margin: var(--sp-3) 0;
}
.shareRow {
display: flex;
gap: 0.6rem;
margin: var(--sp-2) 0;
}
.shareRow input {
font: inherit;
font-size: var(--fs-small);
flex: 1 1 auto;
min-height: 44px;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
background: #fff;
color: var(--ink);
}
.shareRow input:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
@media (max-width: 480px) {
.main {
padding: var(--sp-3) var(--sp-3) var(--sp-5);
}
.cardbtn {
flex-wrap: wrap;
}
.meta,
.state {
white-space: normal;
}
.recapTable th,
.recapTable td {
display: block;
border-bottom: none;
padding: 0.15rem 0;
}
.recapTable tr {
display: block;
border-bottom: 1px solid var(--line);
padding: 0.5rem 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cardbtn,
.optout {
transition: none;
}
}
/* Le titre focalisé programmatiquement : anneau doux de la maison, pas la
boîte noire d'un champ de saisie (retour ICP finding 5). */
.quizCard h2:focus {
outline: 2px solid var(--teal);
outline-offset: 4px;
border-radius: 4px;
}
/* L'astuce clavier n'a rien à faire sur un écran tactile (retour ICP finding 6). */
@media not ((hover: hover) and (pointer: fine)) {
.keyhintOnly { display: none; }
}
/* Deux idées distinctes respirent (retour ICP finding 8). */
.muted + .muted { margin-top: var(--sp-3, 1rem); }
.navGroup { display: flex; gap: .6rem; }
window.COUPLE_DATA = /* Clarté couple : données des questionnaires, copie verbatim de public/couple/data.js
{ (fidelity policy : les textes des items sont la source clinicienne, zéro retouche).
Généré depuis la source ; ne pas éditer ici à la main, éditer la source et reconvertir. */
export const COUPLE_DATA = {
"schema_version": "1.0.0", "schema_version": "1.0.0",
"language": "fr", "language": "fr",
"generated_at": "2026-09-21", "generated_at": "2026-09-21",
...@@ -1886,5 +1888,4 @@ window.COUPLE_DATA = ...@@ -1886,5 +1888,4 @@ window.COUPLE_DATA =
"Deux répondants : prévoir dès le modèle de données un `respondent_id` et un `session_id`, puisque bonheur_conjugal recommande explicitement que le/la partenaire réponde aussi et qu'on compare les réponses.", "Deux répondants : prévoir dès le modèle de données un `respondent_id` et un `session_id`, puisque bonheur_conjugal recommande explicitement que le/la partenaire réponde aussi et qu'on compare les réponses.",
"Les textes proviennent des supports d'une praticienne identifiable (logo Alexandra Vatimbella sur 5 des 8 PDF). Usage personnel : sans objet. Toute diffusion de l'app au-delà de vous deux exige son accord." "Les textes proviennent des supports d'une praticienne identifiable (logo Alexandra Vatimbella sur 5 des 8 PDF). Usage personnel : sans objet. Toute diffusion de l'app au-delà de vous deux exige son accord."
] ]
} };
;
/* Clarté couple : moteur (logique pure + stockage).
Portage de public/couple/app.js : les contrats de stockage et de score sont
identiques au moteur d'origine, les réponses existantes survivent au
remplacement. Toute la logique est ici, sans DOM, pour rester testable.
Contrats (components/clarte/README.md) :
couple:v1:q:<qid> = { answers: Record<string, number | string | { text }>, pos }
couple:v1:session = { created, respondent_label }
couple:v1:corrupt:<qid>:<ts> = enregistrement corrompu archivé, jamais écrasé.
*/
import { COUPLE_DATA } from './data';
export const LS = 'couple:v1:';
export const OPT = 'optout:';
export function optValue(k) {
return OPT + k;
}
/* ---------- clés de réponses ----------
Clé de stockage = String(item.index) (contrat). Deux instruments réutilisent
la numérotation d'origine section par section (pensees_negatives,
inhibiteurs_excitateurs) : là, un index nu écraserait une réponse d'une
autre section : les répétitions sont préfixées `s<si>:`. */
export function allItems(q) {
var out = [];
var seen = {};
(q.sections || []).forEach(function (s, si) {
(s.items || []).forEach(function (it) {
var key = seen[it.index] === undefined ? String(it.index) : 's' + si + ':' + it.index;
seen[it.index] = true;
out.push({ item: it, section: s, key: key });
});
});
return out;
}
export function resolveScale(section, q) {
var key = (section && section.scale) || q.default_scale;
var s = typeof key === 'string' ? COUPLE_DATA.scale_library[key] : key || null;
return s && s.type ? s : null;
}
export function answeredCount(q, st) {
var n = 0;
var items = allItems(q);
for (var i = 0; i < items.length; i++) {
if (st.answers[items[i].key] !== undefined) n++;
}
return n;
}
export function firstUnanswered(q, st) {
var items = allItems(q);
for (var i = 0; i < items.length; i++) {
if (st.answers[items[i].key] === undefined) return i;
}
return items.length;
}
/* Titre affiché : la donnée verbatim est parfois en capitales (TEST DU
BONHEUR CONJUGAL) ou se termine par un point ; la casse d'affichage est
normalisée, la donnée source reste intacte. */
export function displayTitle(t) {
if (typeof t !== 'string' || t === '') return t;
var s = t.replace(/[. \t ]+$/, '');
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}
/* pos == total → récapitulatif ; 'abc' → 0, jamais NaN. */
export function sanitizePos(pos, total) {
return Math.min(Math.max(0, Math.floor(+pos) || 0), total);
}
/* Les scores n'additionnent que des nombres : hors échelle (sentinelle) et
texte libre ({text}) sont exclus des totaux. */
export function computeScore(q, st) {
var sc = q.scoring || {};
if (sc.method !== 'sum_all_items') return null;
var sum = 0;
var maxSum = 0;
var n = 0;
var items = allItems(q);
for (var i = 0; i < items.length; i++) {
var v = st.answers[items[i].key];
if (v === undefined || typeof v !== 'number') continue; // hors échelle : exclu des totaux
n++;
sum += v;
maxSum += 4;
}
return maxSum > 0 ? { n: n, pct: Math.round((sum / maxSum) * 100) } : null;
}
export function optionLabel(q, section, v) {
if (v && typeof v === 'object' && typeof v.text === 'string') return v.text;
if (typeof v === 'string' && v.lastIndexOf(OPT, 0) === 0) {
var o = (COUPLE_DATA.opt_out_options || {})[v.slice(OPT.length)];
return o ? o.label : '';
}
var s = resolveScale(section, q);
if (!s) return '';
if (s.options) {
for (var i = 0; i < s.options.length; i++) {
if (s.options[i].value === v) return s.options[i].label;
}
}
return '';
}
/* Première bande dont le minimum est atteint ; sinon la dernière (les
chevauchements entre bandes sont tranchés au premier min). */
export function findBand(q, pct) {
var bands = (q.scoring || {}).bands || [];
var band = null;
for (var i = 0; i < bands.length; i++) {
if (pct >= bands[i].min) {
band = bands[i];
break;
}
}
if (!band && bands.length) band = bands[bands.length - 1];
return band;
}
export function freeTextSizes(it) {
var si = it.suggested_input;
return {
rows: si === 'texte long' ? 9 : si === '3 champs libres' ? 6 : 3,
max: si === 'texte long' ? 5000 : 600,
};
}
/* Une écriture de réponse tronquée (texte vide après trim) supprime la clé. */
export function setAnswer(st, key, value) {
if (typeof value === 'string' && value.trim() === '') delete st.answers[key];
else if (value && typeof value === 'object' && typeof value.text === 'string' && value.text.trim() === '')
delete st.answers[key];
else st.answers[key] = value;
return st;
}
/* ---------- lien de partage ----------
Encodé en base64url, non chiffré, avertissement affiché à la création
(décision enregistrée, context.md §9). Le payload vit dans le fragment de
l'URL (#p=), jamais en paramètre de requête : il n'atteint jamais les
journaux du serveur. Si un jour on chiffre : WebCrypto AES-GCM avec clé en
#k=, branché ici. */
export function serializeAnswers(qid, st, respondentLabel) {
var q = null;
COUPLE_DATA.questionnaires.forEach(function (x) {
if (x.id === qid) q = x;
});
if (!q) return null;
var out = { qid: qid, title: q.title, respondent: respondentLabel, answered: [] };
if (st) {
allItems(q).forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined) return;
var entry = { i: r.key, v: v };
var l = optionLabel(q, r.section, v);
if (l) entry.l = l;
out.answered.push(entry);
});
var sc = computeScore(q, st);
if (sc) out.pct = sc.pct;
}
return out;
}
export function answersToBase64url(obj) {
var bytes = new TextEncoder().encode(JSON.stringify(obj));
var bin = '';
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function utf8Decode(bytes) {
var pct = [];
for (var i = 0; i < bytes.length; i++) pct.push('%' + ('00' + bytes[i].toString(16)).slice(-2));
return decodeURIComponent(pct.join(''));
}
export function decodePayload(s) {
try {
var b64 = s.replace(/-/g, '+').replace(/_/g, '/');
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var obj = JSON.parse(utf8Decode(bytes));
if (!obj || typeof obj !== 'object' || typeof obj.qid !== 'string' || !Array.isArray(obj.answered))
return null;
return obj;
} catch (e) {
return null;
}
}
export function shareUrl(payload) {
if (typeof location === 'undefined') return '';
return location.href.split('#')[0] + '#p=' + answersToBase64url(payload);
}
/* ---------- stockage ----------
Sonde de démarrage, mise en quarantaine : navigation privée ou stockage
désactivé → mémoire volatile, prévenue à chaque écran, plutôt que perdre en
silence. probeStorage() est appelé une fois au montage. */
export function storageAvailable() {
try {
window.localStorage.setItem(LS + 'probe', '1');
window.localStorage.removeItem(LS + 'probe');
return true;
} catch (e) {
return false;
}
}
export function readRecord(qid) {
var raw = null;
try {
raw = window.localStorage.getItem(LS + 'q:' + qid);
} catch (e) {
return null;
}
if (raw == null) return null;
var v = null;
try {
v = JSON.parse(raw);
} catch (e) {
// un enregistrement corrompu est archivé, pas écrasé par la prochaine écriture
try {
window.localStorage.setItem(LS + 'corrupt:' + qid + ':' + Date.now(), raw);
} catch (e2) {}
return null;
}
if (!v || typeof v !== 'object' || !v.answers || typeof v.answers !== 'object') return null;
return v;
}
export function writeRecord(qid, st) {
try {
window.localStorage.setItem(LS + 'q:' + qid, JSON.stringify(st));
return true;
} catch (e) {
return false;
}
}
export function readSession() {
var s = null;
try {
s = JSON.parse(window.localStorage.getItem(LS + 'session'));
} catch (e) {
s = null;
}
if (!s || typeof s !== 'object') s = { created: new Date().toISOString(), respondent_label: 'moi' };
if (typeof s.created !== 'string') s.created = new Date().toISOString();
if (typeof s.respondent_label !== 'string' || !s.respondent_label) s.respondent_label = 'moi';
return s;
}
export function writeSession(s) {
try {
window.localStorage.setItem(LS + 'session', JSON.stringify(s));
return true;
} catch (e) {
return false;
}
}
/* Suppression complète : on itère localStorage PAR INDEX DE CLÉ, on collecte
chaque clé couple:v1:*, on les efface. Les valeurs ne sont jamais lues. */
export function deleteAllCoupleKeys() {
var keys = [];
try {
for (var i = 0; i < window.localStorage.length; i++) {
var k = window.localStorage.key(i);
if (k && k.lastIndexOf(LS, 0) === 0) keys.push(k);
}
} catch (e) {
return;
}
keys.forEach(function (k) {
try {
window.localStorage.removeItem(k);
} catch (e) {}
});
}
/* ---------- export (un appui, non destructif) ---------- */
export function buildExportPayload() {
var session = readSession();
return {
schema: 'couple:v1',
exported_at: new Date().toISOString(),
respondent: session.respondent_label,
questionnaires: COUPLE_DATA.questionnaires.map(function (q) {
return serializeAnswers(q.id, readRecord(q.id), session.respondent_label);
}),
};
}
export function exportFilename() {
return 'clarte-couple-' + new Date().toISOString().slice(0, 10) + '.json';
}
export function downloadExport() {
var payload = buildExportPayload();
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = exportFilename();
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () {
URL.revokeObjectURL(url);
}, 0);
}
/* Clarté couple : chaînes visibles de l'app (voix de l'app uniquement ; les
textes des items et les titres de sections restent la donnée verbatim).
Typographie française : apostrophe typographique (U+2019), espace insécable
avant : ; ! ? et dans « ». Zéro tiret cadratin, zéro tiret demi-cadratin. */
export const strings = {
bannerStorage:
'Impossible d’enregistrer sur cet appareil (navigation privée ?). Vos réponses seront perdues à la fermeture de la page.',
home: {
h1: 'Espace couple',
intro:
'Des questionnaires pour mettre des mots, à deux, sur ce qui se passe entre vous. Répondez librement, à votre rythme. Tout reste sur cet appareil, rien n’est envoyé nulle part.',
disclosure:
'Si vous partagez cet appareil, l’autre personne pourra lire vos réponses. C’est voulu : les questionnaires servent aussi à en parler à deux.',
closing:
'Ces questionnaires ne sont pas un jugement ni un diagnostic : ils servent de support à la discussion. Un score bas n’est pas un échec, c’est une information.',
whoLabel: 'Je réponds en tant que',
footLine: 'Aucun compte, aucune connexion : vos réponses restent sur cet appareil.',
exportBtn: 'Exporter toutes mes réponses',
deleteBtn: 'Supprimer toutes mes réponses',
deleteConfirm: 'Effacer définitivement tout ce qui est enregistré sur cet appareil ?',
deleteYes: 'Supprimer définitivement',
deleteNo: 'Annuler',
cardDone: 'Terminé · voir mes réponses',
cardQuestions: (total) => total + ' questions',
cardWorksheet: (total) => total + ' écrits à compléter',
cardPartial: (n, total) => 'En cours · ' + n + ' sur ' + total,
},
intro: {
meta: (total, isWorksheet) =>
(isWorksheet ? total + ' écrits' : total + ' questions') +
' · vous pouvez arrêter et reprendre quand vous voulez.',
headerFieldsNote: 'Votre nom et la date restent sur cet appareil.',
back: 'Retour',
start: 'Commencer',
resume: (firstUnanswered1Based) => 'Reprendre à la question ' + firstUnanswered1Based,
seeRecap: 'Voir mes réponses',
more: 'En savoir plus',
},
quiz: {
keyhint: 'Au clavier : chiffres pour répondre, Entrée ou flèches pour avancer.',
home: 'Accueil',
prev: 'Précédente',
skip: 'Passer la question',
toRecap: 'Voir le récapitulatif',
nextWorksheet: 'Suivant',
freeTextPrompt: 'Ici, la réponse s’écrit : prenez le temps qui vous faut, quelques mots suffisent.',
unknownFormat: 'Cette question n’a pas de format de réponse connu.',
questionOf: (pos1Based, total) => 'Question ' + pos1Based + ' sur ' + total,
},
recap: {
lede: 'Merci d’avoir pris ce temps. Voici où vous en êtes. Ce n’est pas un diagnostic.',
empty: 'Aucune réponse pour l’instant. Le questionnaire sera là quand vous serez prêt·e.',
scoreComplete: 'Score complet.',
progressPartial: (n, total) =>
'Avancement : ' +
n +
(n === 1 ? ' réponse' : ' réponses') +
' sur ' +
total +
'. Il évolue à chaque nouvelle réponse.',
origLabel: 'Lecture du support d’origine',
optOutLine: (k) =>
k === 1
 ? '1 réponse marquée hors échelle, non comptée dans le score.'
 : k + ' réponses marquées hors échelle, non comptées dans le score.',
scoreMusing:
'Ce chiffre décrit un moment, pas une personne ni un couple. S’il vous surprend ou vous pèse, apportez-le en séance plutôt que de le garder pour vous.',
unscored: (n, total) =>
'Vous avez répondu à ' +
n +
' questions sur ' +
total +
'. Ce questionnaire ne se calcule pas : vos réponses sont là pour nourrir votre réflexion ou une discussion à deux.',
reviewSummary: (n, total) => 'Revoir mes réponses (' + n + ' sur ' + total + ')',
remaining: (k) =>
k === 1
? 'Il reste 1 question sans réponse.'
: 'Il reste ' + k + ' questions sans réponse.',
backHome: 'Retour à l’accueil',
ctaEmpty: 'Commencer',
ctaPartial: 'Reprendre',
ctaComplete: 'Recommencer',
restartConfirm: 'Recommencer ce questionnaire ? Vos réponses actuelles seront effacées.',
answeredOf: (n, total) => n + (n === 1 ? ' réponse' : ' réponses') + ' sur ' + total,
share: {
trigger: 'Créer un lien de partage',
warning:
'Ce lien contient vos réponses, encodées mais non chiffrées : quiconque possède le lien peut les lire. Envoyez-le seulement à qui de droit.',
inputAria: 'Lien de partage',
copy: 'Copier le lien',
copied: 'Lien copié',
note:
'La personne qui ouvre ce lien voit vos réponses en lecture seule, rien n’est enregistré sur son appareil.',
},
},
shared: {
title: (qTitle) => 'Réponses partagées : ' + qTitle,
by: (respondent) => 'Partagé par ' + respondent,
readOnly:
'Vue en lecture seule. Ce lien ne peut pas être révoqué ; demandez un nouveau lien si les réponses changent.',
foot: 'Ces réponses s’affichent depuis le lien ; rien n’est envoyé ni stocké ici.',
errorTitle: 'Lien de partage',
error:
'Lien incomplet ou altéré. Demandez à la personne qui l’a envoyé d’en créer un nouveau.',
backHome: 'Retour à l’accueil',
},
};
...@@ -151,13 +151,99 @@ const EF_OPTIONS = [ ...@@ -151,13 +151,99 @@ const EF_OPTIONS = [
]; ];
// ============================================ // ============================================
// ANSWER PROGRESS (counter, fill line, milestones)
// Derived from the question lists above — totals are never hardcoded.
// ============================================
const ASRS_IDS = [...ASRS_QUESTIONS.part_a, ...ASRS_QUESTIONS.part_b].map(q => q.id);
const DIVA_IDS = [
...DIVA_INATTENTION, ...DIVA_HYPERACTIVITY,
...DIVA_CHILDHOOD, ...DIVA_IMPAIRMENT
].map(q => q.id);
const EXECUTIVE_IDS = Object.values(EXEC_FUNCTIONS).flatMap(c => c.questions.map(q => q.id));
const ALL_QUESTION_IDS = [...ASRS_IDS, ...DIVA_IDS, ...EXECUTIVE_IDS];
const TOTAL_QUESTIONS = ALL_QUESTION_IDS.length; // 70 = 18 ASRS + 28 DSM-5 + 24 fonctions exécutives
// Milestone boundaries: end of the ASRS block, half of the total,
// end of the DSM-5 block, end of everything. Copy finals from
// copy-fr.md §12 (progress.answered, milestones). If this tree is ever
// translated, copy-en.md §12 gives: "{n} of {total} answered · {m} to go",
// "First part done, well underway.", "You are halfway through.",
// "Two parts done, one to go.", "That's everything. Thank you for getting clear."
const PROGRESS_COPY = {
countPlural: (n, m) => `${n} questions répondues · il en reste ${m}`,
countSingular: (n, m) => `${n} question répondue · il en reste ${m}`,
countDone: (n) => `${n} questions répondues`,
milestones: {
'asrs.done': 'Première partie terminée, bien avancé.',
'half': 'Vous êtes à la moitié.',
'dsm.done': 'Deux parties terminées, reste la dernière.',
'all.done': "C'est complet. Merci d'avoir été au clair."
}
};
const MILESTONES = [
{ key: 'asrs.done', at: ASRS_IDS.length },
{ key: 'half', at: Math.floor(TOTAL_QUESTIONS / 2) },
{ key: 'dsm.done', at: ASRS_IDS.length + DIVA_IDS.length },
{ key: 'all.done', at: TOTAL_QUESTIONS }
];
function formatCountLine(answered, total) {
const remaining = total - answered;
if (remaining === 0) return PROGRESS_COPY.countDone(answered);
if (answered <= 1) return PROGRESS_COPY.countSingular(answered, remaining);
return PROGRESS_COPY.countPlural(answered, remaining);
}
// Highest milestone whose boundary the answered count has crossed, or null.
function crossedMilestone(answered) {
let hit = null;
for (const m of MILESTONES) {
if (answered >= m.at) hit = m;
else break;
}
return hit;
}
// Refresh the sticky answer-progress band: count line, 3px fill, and a
// milestone in the count line's slot when a boundary is just crossed
// (shown for the current screen, then the count line returns). The last
// milestone shown is persisted so a reload does not re-fire it.
function updateAnswerProgress() {
const band = document.getElementById('answer-progress');
const countEl = document.getElementById('answer-count');
const fill = document.getElementById('answer-fill');
if (!band || !countEl || !fill) return;
const visible = state.currentSection === 'asrs'
|| state.currentSection === 'diva'
|| state.currentSection === 'executive';
band.hidden = !visible;
if (!visible) return;
const answered = ALL_QUESTION_IDS.filter(id => state.responses[id] !== undefined).length;
fill.style.width = (answered / TOTAL_QUESTIONS * 100) + '%';
const milestone = crossedMilestone(answered);
if (milestone && milestone.key !== state.lastMilestone) {
countEl.textContent = PROGRESS_COPY.milestones[milestone.key];
state.lastMilestone = milestone.key;
saveState();
} else {
countEl.textContent = formatCountLine(answered, TOTAL_QUESTIONS);
}
}
// ============================================
// APP STATE // APP STATE
// ============================================ // ============================================
const state = { const state = {
currentSection: 'home', currentSection: 'home',
responses: {}, responses: {},
results: null results: null,
lastMilestone: null
}; };
// ============================================ // ============================================
...@@ -172,7 +258,8 @@ function saveState() { ...@@ -172,7 +258,8 @@ function saveState() {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ localStorage.setItem(STORAGE_KEY, JSON.stringify({
responses: state.responses, responses: state.responses,
currentSection: state.currentSection currentSection: state.currentSection,
lastMilestone: state.lastMilestone
})); }));
} catch (e) { /* storage unavailable (private mode, quota): skip */ } } catch (e) { /* storage unavailable (private mode, quota): skip */ }
} }
...@@ -195,6 +282,9 @@ function restoreState() { ...@@ -195,6 +282,9 @@ function restoreState() {
if (saved.responses && typeof saved.responses === 'object') { if (saved.responses && typeof saved.responses === 'object') {
Object.assign(state.responses, saved.responses); Object.assign(state.responses, saved.responses);
} }
if (typeof saved.lastMilestone === 'string') {
state.lastMilestone = saved.lastMilestone;
}
if (sections.includes(saved.currentSection)) { if (sections.includes(saved.currentSection)) {
return saved.currentSection; return saved.currentSection;
} }
...@@ -219,6 +309,7 @@ function showSection(sectionId) { ...@@ -219,6 +309,7 @@ function showSection(sectionId) {
target.classList.add('active'); target.classList.add('active');
state.currentSection = sectionId; state.currentSection = sectionId;
updateProgress(); updateProgress();
updateAnswerProgress();
window.scrollTo(0, 0); window.scrollTo(0, 0);
saveState(); saveState();
} }
...@@ -381,6 +472,7 @@ function attachOptionHandlers(container) { ...@@ -381,6 +472,7 @@ function attachOptionHandlers(container) {
input.addEventListener('change', (e) => { input.addEventListener('change', (e) => {
state.responses[e.target.name] = parseInt(e.target.value); state.responses[e.target.name] = parseInt(e.target.value);
saveState(); saveState();
updateAnswerProgress();
const question = e.target.closest('.question'); const question = e.target.closest('.question');
if (question) question.classList.add('answered'); if (question) question.classList.add('answered');
}); });
...@@ -881,6 +973,7 @@ function init() { ...@@ -881,6 +973,7 @@ function init() {
document.getElementById('restart').addEventListener('click', () => { document.getElementById('restart').addEventListener('click', () => {
state.responses = {}; state.responses = {};
state.results = null; state.results = null;
state.lastMilestone = null;
clearSavedState(); clearSavedState();
renderASRS(); renderASRS();
renderDIVA(); renderDIVA();
......
...@@ -51,6 +51,12 @@ ...@@ -51,6 +51,12 @@
<div class="step-dot"></div> <div class="step-dot"></div>
</div> </div>
<p class="progress-label">Accueil</p> <p class="progress-label">Accueil</p>
<div class="answer-progress" id="answer-progress" hidden>
<p class="answer-count" id="answer-count"></p>
<div class="answer-fill-track">
<div class="answer-fill" id="answer-fill"></div>
</div>
</div>
</div> </div>
</nav> </nav>
......
...@@ -264,6 +264,44 @@ header .subtitle { ...@@ -264,6 +264,44 @@ header .subtitle {
color: var(--ink-light); color: var(--ink-light);
} }
/* Answer progress: count line + 3px fill, on questionnaire sections only.
Hidden on home/results via the `hidden` attribute (set in app.js). */
.answer-progress {
margin-top: var(--space-sm);
}
.answer-progress[hidden] {
display: none;
}
.answer-count {
text-align: center;
margin-bottom: var(--space-xs);
font-size: 0.85rem;
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.answer-fill-track {
height: 3px;
background: var(--paper-shadow);
border-radius: var(--radius-full);
overflow: hidden;
}
.answer-fill {
height: 100%;
width: 0;
background: var(--teal-dark);
transition: width 300ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.answer-fill {
transition: none;
}
}
/* ============================================ /* ============================================
MAIN CONTENT MAIN CONTENT
============================================ */ ============================================ */
......
/* Cache-first: la page et ses données chargent hors réseau; les réponses /* Cache-first: la page et ses données chargent hors réseau; les réponses
vivent dans localStorage et ne passent jamais par le réseau. */ vivent dans localStorage et ne passent jamais par le réseau. */
var CACHE = 'clarte-v1'; var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js', var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest', './manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2', './fonts/AtkinsonHyperlegible-Regular.woff2',
......
/* Clarté couple : moteur de questionnaires, 100% local.
Les réponses sont sauvées à chaque sélection dans localStorage :
fermer, recharger ou couper le réseau ne perd jamais un effort.
Score partiel à chaque instant : on note ce qui est répondu. */
(function () {
'use strict';
var DATA = window.COUPLE_DATA;
var SCALES = DATA.scale_library;
var LS = 'couple:v1:';
var app = document.getElementById('app');
var prog = document.getElementById('progress');
var el = function (tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
/* ---------- stockage : sonde de démarrage, mise en quarantaine ----------
Navigation privée ou stockage désactivé : on bascule sur une mémoire
volatile et on prévient à chaque écran, plutôt que de perdre en silence. */
var storageFailed = false;
var LSB = null;
try {
localStorage.setItem(LS + 'probe', '1');
localStorage.removeItem(LS + 'probe');
LSB = localStorage;
} catch (e) {
storageFailed = true;
var backing = {};
LSB = {
getItem: function (k) { return Object.prototype.hasOwnProperty.call(backing, k) ? backing[k] : null; },
setItem: function (k, v) { backing[k] = String(v); },
removeItem: function (k) { delete backing[k]; },
key: function (i) { return Object.keys(backing)[i] !== undefined ? Object.keys(backing)[i] : null; },
get length() { return Object.keys(backing).length; }
};
}
var store = {
get: function (qid) {
var raw = null;
try { raw = LSB.getItem(LS + 'q:' + qid); } catch (e) { return null; }
if (raw == null) return null;
var v = null;
try { v = JSON.parse(raw); } catch (e) {
// un enregistrement corrompu est archivé, pas écrasé par la prochaine écriture
try { LSB.setItem(LS + 'corrupt:' + qid + ':' + Date.now(), raw); } catch (e2) {}
return null;
}
if (!v || typeof v !== 'object' || !v.answers || typeof v.answers !== 'object') return null;
return v;
},
set: function (qid, st) {
try { LSB.setItem(LS + 'q:' + qid, JSON.stringify(st)); }
catch (e) { storageFailed = true; }
}
};
function maybeBanner() {
if (!storageFailed) return;
app.appendChild(el('p', 'banner',
'Impossible d’enregistrer sur cet appareil (navigation privée ?). Vos réponses seront perdues à la fermeture de la page.'));
}
/* ---------- session : qui répond ----------
Base d'identité locale, posée pour le « à deux » et les futurs
échanges de réponses. Les clés de réponses ne bougent pas :
`index` reste la clé de stockage, `source_label` n'est que l'affichage. */
function session() {
var s = null;
try { s = JSON.parse(LSB.getItem(LS + 'session')); } catch (e) { s = null; }
if (!s || typeof s !== 'object') s = { created: new Date().toISOString(), respondent_label: 'moi' };
if (typeof s.created !== 'string') s.created = new Date().toISOString();
if (typeof s.respondent_label !== 'string' || !s.respondent_label) s.respondent_label = 'moi';
return s;
}
function saveSession(s) {
try { LSB.setItem(LS + 'session', JSON.stringify(s)); }
catch (e) { storageFailed = true; }
}
/* Options hors échelle : valeur sentinelle distincte de « non répondu »,
jamais comptée dans les totaux (les scores n'additionnent que des nombres). */
var OPT = 'optout:';
function optValue(k) { return OPT + k; }
/* ---------- sérialisation et lien de partage ----------
Lien encodé en base64url, non chiffré, avertissement affiché à la création
(décision enregistrée dans context.md §9, 2026-09-21). Le payload vit dans
le fragment de l'URL (#p=), jamais en paramètre de requête : il n'atteint
jamais les journaux du serveur. Si un jour on chiffre : WebCrypto AES-GCM
avec clé en #k=, branché ici. */
function computeScore(q, st) {
var sc = q.scoring || {};
if (sc.method !== 'sum_all_items') return null;
var sum = 0, maxSum = 0, n = 0;
allItems(q).forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined || typeof v !== 'number') return; // hors échelle : exclu des totaux
n++; sum += v; maxSum += 4;
});
return maxSum > 0 ? { n: n, pct: Math.round((sum / maxSum) * 100) } : null;
}
function optionLabel(q, section, v) {
if (v && typeof v === 'object' && typeof v.text === 'string') return v.text;
if (typeof v === 'string' && v.lastIndexOf(OPT, 0) === 0) {
var o = (DATA.opt_out_options || {})[v.slice(OPT.length)];
return o ? o.label : '';
}
var s = resolveScale(section, q);
if (!s) return '';
if (s.options) {
for (var i = 0; i < s.options.length; i++) if (s.options[i].value === v) return s.options[i].label;
}
return '';
}
function serializeAnswers(qid) {
var q = null;
DATA.questionnaires.forEach(function (x) { if (x.id === qid) q = x; });
if (!q) return null;
var st = store.get(qid);
var out = { qid: qid, title: q.title, respondent: session().respondent_label, answered: [] };
if (st) {
allItems(q).forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined) return;
var entry = { i: r.key, v: v };
var l = optionLabel(q, r.section, v);
if (l) entry.l = l;
out.answered.push(entry);
});
var sc = computeScore(q, st);
if (sc) out.pct = sc.pct;
}
return out;
}
function answersToBase64url(obj) {
var bytes = new TextEncoder().encode(JSON.stringify(obj));
var bin = '';
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function utf8Decode(bytes) {
var pct = [];
for (var i = 0; i < bytes.length; i++) pct.push('%' + ('00' + bytes[i].toString(16)).slice(-2));
return decodeURIComponent(pct.join(''));
}
function decodePayload(s) {
try {
var b64 = s.replace(/-/g, '+').replace(/_/g, '/');
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var obj = JSON.parse(utf8Decode(bytes));
if (!obj || typeof obj !== 'object' || typeof obj.qid !== 'string' || !Array.isArray(obj.answered)) return null;
return obj;
} catch (e) { return null; }
}
function allItems(q) {
// Clé de stockage = item.index (contrat). Deux instruments réutilisent
// la numérotation d'origine section par section (pensees_negatives,
// inhibiteurs_excitateurs) : là, un index nu écraserait une réponse
// d'une autre section : les répétitions sont préfixées `s<si>:`.
var out = [], seen = {};
(q.sections || []).forEach(function (s, si) {
(s.items || []).forEach(function (it) {
var key = seen[it.index] === undefined ? String(it.index) : 's' + si + ':' + it.index;
seen[it.index] = true;
out.push({ item: it, section: s, key: key });
});
});
return out;
}
function resolveScale(section, q) {
var key = (section && section.scale) || q.default_scale;
var s = typeof key === 'string' ? SCALES[key] : (key || null);
return s && s.type ? s : null;
}
function answeredCount(q, st) {
var n = 0;
allItems(q).forEach(function (r) { if (st.answers[r.key] !== undefined) n++; });
return n;
}
function firstUnanswered(q, st) {
var items = allItems(q);
for (var i = 0; i < items.length; i++) {
if (st.answers[items[i].key] === undefined) return i;
}
return items.length;
}
/* ---------- clavier ----------
keymap est posé par la vue active (question, récapitulatif) et remis
à null par les autres ; un seul écouteur pour toute la vie de la page. */
var keymap = null;
document.addEventListener('keydown', function (e) {
if (!keymap || e.ctrlKey || e.metaKey || e.altKey) return;
var tag = e.target && e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (/^[1-9]$/.test(e.key) && keymap.digit && +e.key <= keymap.digit) { e.preventDefault(); keymap.pick(+e.key); return; }
if (e.key === 'ArrowRight' && keymap.right) { e.preventDefault(); keymap.right(); return; }
if (e.key === 'ArrowLeft' && keymap.left) { e.preventDefault(); keymap.left(); return; }
if (e.key === 'Enter' && keymap.enter) { e.preventDefault(); keymap.enter(); }
});
/* ---------- progress header ---------- */
function showProgress(text, frac) {
prog.hidden = false;
prog.innerHTML = '';
var line = el('div', null, text);
var fill = el('div', 'fill');
var bar = el('i');
bar.style.width = Math.round(frac * 100) + '%';
fill.appendChild(bar);
prog.appendChild(line); prog.appendChild(fill);
}
function hideProgress() { prog.hidden = true; prog.innerHTML = ''; }
/* ---------- export / suppression (accueil) ---------- */
function exportAll() {
var payload = {
schema: 'couple:v1',
exported_at: new Date().toISOString(),
respondent: session().respondent_label,
questionnaires: DATA.questionnaires.map(function (q) { return serializeAnswers(q.id); })
};
try {
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = el('a');
a.href = url;
a.download = 'clarte-couple-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
} catch (e) {}
}
function renderFootLinks(container) {
container.innerHTML = '';
var exportBtn = el('button', 'linkbtn', 'Exporter toutes mes réponses');
exportBtn.addEventListener('click', exportAll);
var delBtn = el('button', 'linkbtn danger', 'Supprimer toutes mes réponses');
delBtn.addEventListener('click', function () { renderDeleteConfirm(container); });
container.appendChild(exportBtn);
container.appendChild(document.createTextNode(' · '));
container.appendChild(delBtn);
}
function renderDeleteConfirm(container) {
container.innerHTML = '';
container.appendChild(document.createTextNode('Effacer définitivement tout ce qui est enregistré sur cet appareil ?'));
container.appendChild(document.createTextNode(' '));
var yes = el('button', 'btn', 'Supprimer définitivement');
var no = el('button', 'linkbtn', 'Annuler');
yes.addEventListener('click', function () {
var keys = [];
for (var i = 0; i < LSB.length; i++) {
var k = LSB.key(i);
if (k && k.lastIndexOf(LS, 0) === 0) keys.push(k);
}
keys.forEach(function (k) { try { LSB.removeItem(k); } catch (e) {} });
home();
});
no.addEventListener('click', function () { renderFootLinks(container); });
container.appendChild(yes);
container.appendChild(document.createTextNode(' '));
container.appendChild(no);
}
/* ---------- views ---------- */
function home() {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, 'Espace couple'));
app.appendChild(el('p', 'muted',
'Des questionnaires pour mettre des mots, à deux, sur ce qui se passe entre vous. ' +
'Répondez librement, à votre rythme. Tout reste sur cet appareil, rien n’est envoyé nulle part.'));
var list = el('ul', 'cardlist');
DATA.questionnaires.forEach(function (q) {
var total = allItems(q).length;
var st = store.get(q.id);
var n = st ? answeredCount(q, st) : 0;
var li = el('li');
var b = el('button', 'cardbtn');
b.appendChild(el('span', null, q.title));
if (st && n >= total) {
b.appendChild(el('span', 'state', 'Terminé · voir mes réponses'));
} else if (n > 0) {
b.appendChild(el('span', 'state', 'En cours · ' + n + ' sur ' + total));
} else {
b.appendChild(el('span', 'meta',
q.type === 'worksheet' ? total + ' écrits à compléter' : total + ' questions'));
}
b.addEventListener('click', function () {
var s2 = store.get(q.id);
var n2 = s2 ? answeredCount(q, s2) : 0;
if (!s2 || n2 === 0) intro(q);
else if (n2 >= total) recap(q);
else quiz(q, firstUnanswered(q, s2));
});
li.appendChild(b); list.appendChild(li);
});
app.appendChild(list);
var who = session();
var w = el('div', 'who');
var lbl = el('label', 'muted', 'Je réponds en tant que');
lbl.setAttribute('for', 'respondent-label');
var inp = el('input');
inp.id = 'respondent-label';
inp.maxLength = 40;
inp.value = who.respondent_label;
inp.addEventListener('input', function () {
who.respondent_label = inp.value.trim() || 'moi';
saveSession(who);
});
w.appendChild(lbl); w.appendChild(inp);
app.appendChild(w);
// Visibilité partagée : à dire avant la première séance, ça change la façon de répondre.
app.appendChild(el('p', 'muted',
'Si vous partagez cet appareil, l’autre personne pourra lire vos réponses. C’est voulu : les questionnaires servent aussi à en parler à deux.'));
app.appendChild(el('p', 'muted',
'Ces questionnaires ne sont pas un jugement ni un diagnostic : ils servent de support à la discussion. ' +
'Un score bas n’est pas un échec, c’est une information.'));
var foot = el('footer', 'pagefoot');
foot.appendChild(el('p', null, 'Aucun compte, aucune connexion : vos réponses restent sur cet appareil.'));
var links = el('p', 'footlinks');
renderFootLinks(links);
foot.appendChild(links);
app.appendChild(foot);
}
function renderHeaderFields(q, st) {
var wrap = el('div', 'headfields');
var stored = (st && st.header) || {};
var target = st || { answers: {}, pos: 0, header: {} };
(q.header_fields || []).forEach(function (f) {
var lbl = el('label', 'muted', f.label);
var id = 'hf-' + f.id;
lbl.setAttribute('for', id);
var inp = el('input');
inp.type = f.type === 'date' ? 'date' : 'text';
inp.id = id;
inp.value = typeof stored[f.id] === 'string' ? stored[f.id] : '';
inp.addEventListener('input', function () {
if (!target.header) target.header = {};
target.header[f.id] = inp.value;
store.set(q.id, target);
});
wrap.appendChild(lbl); wrap.appendChild(inp);
});
wrap.appendChild(el('p', 'muted', 'Votre nom et la date restent sur cet appareil.'));
return wrap;
}
function intro(q) {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, q.title));
if (q.intro) app.appendChild(el('p', null, q.intro));
if (q.instructions) app.appendChild(el('p', null, q.instructions));
var total = allItems(q).length;
var st = store.get(q.id);
var n = st ? answeredCount(q, st) : 0;
app.appendChild(el('p', 'muted',
(q.type === 'worksheet' ? total + ' écrits' : total + ' questions') +
' · vous pouvez arrêter et reprendre quand vous voulez.'));
if (q.type === 'worksheet' && q.header_fields && q.header_fields.length) {
app.appendChild(renderHeaderFields(q, st));
}
var nav = el('div', 'nav');
var back = el('button', 'btn', 'Retour');
back.addEventListener('click', home);
var fu = st ? firstUnanswered(q, st) : 0;
var start = el('button', 'btn btn--primary',
st == null ? 'Commencer' : (n === total ? 'Voir mes réponses' : 'Reprendre à la question ' + (fu + 1)));
start.addEventListener('click', function () {
var s2 = store.get(q.id);
var n2 = s2 ? answeredCount(q, s2) : 0;
if (!s2 || n2 === 0) { quiz(q, 0); return; }
if (n2 >= total) { recap(q); return; }
quiz(q, firstUnanswered(q, s2));
});
nav.appendChild(back); nav.appendChild(start);
app.appendChild(nav);
}
/* ---------- champ libre (feuille de travail) ---------- */
function freeTextSizes(it) {
var si = it.suggested_input;
return {
rows: si === 'texte long' ? 9 : (si === '3 champs libres' ? 6 : 3),
max: si === 'texte long' ? 5000 : 600
};
}
function makeFreeText(q, st, r) {
var it = r.item;
var sizes = freeTextSizes(it);
var wrap = el('div', 'freeblock');
wrap.appendChild(el('p', 'muted',
'Ici, la réponse s’écrit : prenez le temps qui vous faut, quelques mots suffisent.'));
var ta = el('textarea', 'freetext');
ta.setAttribute('aria-label', it.text);
ta.rows = sizes.rows;
ta.maxLength = sizes.max;
var v = st.answers[r.key];
ta.value = v && typeof v === 'object' && typeof v.text === 'string' ? v.text : '';
var timer = null;
var save = function () {
timer = null;
if (ta.value.trim() === '') delete st.answers[r.key];
else st.answers[r.key] = { text: ta.value };
store.set(q.id, st);
};
ta.addEventListener('input', function () {
if (timer) clearTimeout(timer);
timer = setTimeout(save, 300);
});
ta.addEventListener('blur', function () {
if (timer) { clearTimeout(timer); save(); }
});
wrap.appendChild(ta);
return {
wrap: wrap,
ta: ta,
flush: function () { if (timer) { clearTimeout(timer); save(); } }
};
}
function quiz(q, pos) {
var items = allItems(q);
var total = items.length;
pos = Math.min(Math.max(0, Math.floor(+pos) || 0), total); // pos == total → récapitulatif
if (q.type === 'worksheet') { quizWorksheet(q, pos, items, total); return; }
var st = store.get(q.id) || { answers: {}, pos: 0 };
st.pos = pos; store.set(q.id, st);
if (pos >= total) { recap(q); return; }
var cur = items[pos];
var scale = resolveScale(cur.section, q);
showProgress('Question ' + (pos + 1) + ' sur ' + total, (pos + 1) / total);
app.innerHTML = '';
maybeBanner();
if (pos === 0 || items[pos - 1].section !== cur.section) {
if (cur.section.title) app.appendChild(el('p', 'muted section-label', cur.section.title));
if (cur.section.section_instructions) app.appendChild(el('p', 'muted', cur.section.section_instructions));
}
var t = el('p', 'item-text', cur.item.text);
t.setAttribute('tabindex', '-1');
app.appendChild(t);
var box = el('div', 'scale');
box.setAttribute('role', 'group');
var pick = function (value) {
return function () {
st.answers[cur.key] = value;
st.pos = pos + 1;
store.set(q.id, st);
quiz(q, pos + 1);
};
};
var freeFlush = null;
var type = scale ? scale.type : null;
if (type === 'free_text') {
var ft = makeFreeText(q, st, cur);
freeFlush = ft.flush;
app.appendChild(ft.wrap);
// pas de focus automatique : le clavier mobile couvrirait la question
} else if (type === 'numeric_range' && scale.min != null && scale.max != null) {
var rangeWrap = el('div', 'rangeblock');
var out = el('output', 'range-out');
var slider = el('input');
slider.type = 'range';
slider.id = 'range-input';
slider.min = scale.min;
slider.max = scale.max;
slider.step = scale.step || 1;
var sv = st.answers[cur.key];
slider.value = typeof sv === 'number' ? sv : scale.min;
out.textContent = String(slider.value);
out.setAttribute('for', 'range-input');
rangeWrap.appendChild(out);
rangeWrap.appendChild(slider);
var anchors = el('div', 'range-anchors');
anchors.appendChild(el('span', null, (scale.anchors || {})[String(scale.min)] || ''));
anchors.appendChild(el('span', null, (scale.anchors || {})[String(scale.max)] || ''));
rangeWrap.appendChild(anchors);
app.appendChild(rangeWrap);
var deb = null;
var commit = function () {
var nv = slider.valueAsNumber;
if (typeof nv === 'number' && !isNaN(nv)) st.answers[cur.key] = nv;
store.set(q.id, st);
out.textContent = slider.value;
};
slider.addEventListener('input', function () {
out.textContent = slider.value;
if (deb) clearTimeout(deb);
deb = setTimeout(commit, 250);
});
var viaKeyboard = false;
slider.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
if (deb) { clearTimeout(deb); commit(); }
goNext();
return;
}
viaKeyboard = true; // les flèches ajustent la valeur sans avancer
});
slider.addEventListener('change', function () {
if (deb) clearTimeout(deb);
commit();
if (viaKeyboard) viaKeyboard = false;
else goNext(); // relâchement du pointeur : on avance d'une question
});
} else if (scale && scale.options && scale.options.length) {
scale.options.forEach(function (o) {
var b = el('button', null, o.label);
b.setAttribute('aria-pressed', st.answers[cur.key] === o.value ? 'true' : 'false');
b.addEventListener('click', pick(o.value));
box.appendChild(b);
});
} else {
// format de réponse inconnu (inatteignable avec les données actuelles) : on n'invente rien.
app.appendChild(el('p', 'muted', 'Cette question n’a pas de format de réponse connu.'));
}
(cur.item.extra_options || []).forEach(function (k) {
var o = (DATA.opt_out_options || {})[k];
if (!o) return;
var b = el('button', 'optout', o.label);
b.setAttribute('aria-pressed', st.answers[cur.key] === optValue(k) ? 'true' : 'false');
b.addEventListener('click', pick(optValue(k)));
box.appendChild(b);
});
if (box.children.length) {
app.appendChild(box);
if (type === 'numeric_range' || (scale && scale.options && scale.options.length)) {
app.appendChild(el('p', 'keyhint', 'Au clavier : chiffres pour répondre, flèches pour naviguer.'));
}
}
var goNext = function () { if (freeFlush) freeFlush(); st.pos = pos + 1; store.set(q.id, st); quiz(q, pos + 1); };
var goPrev = function () { if (freeFlush) freeFlush(); quiz(q, pos - 1); };
var nav = el('div', 'nav');
var prev = el('button', 'btn', 'Précédente');
prev.addEventListener('click', goPrev);
var next = el('button', 'btn', pos + 1 < total ? 'Passer la question' : 'Voir le récapitulatif');
next.addEventListener('click', goNext);
nav.appendChild(prev); nav.appendChild(next);
app.appendChild(nav);
keymap = {
digit: type === 'numeric_range' ? scale.max : (scale && scale.options ? scale.options.length : 0),
pick: type === 'numeric_range'
? function (n) { slider.value = n; if (deb) clearTimeout(deb); commit(); }
: function (n) { var b = box.children[n - 1]; if (b) b.click(); },
right: goNext,
left: goPrev
};
if (type !== 'free_text') t.focus();
}
/* ---------- feuille de travail (folio) : une section par écran ---------- */
function quizWorksheet(q, pos, items, total) {
var st = store.get(q.id) || { answers: {}, pos: 0, header: {} };
if (pos >= total) { recap(q); return; }
var cur = items[pos];
var section = cur.section;
var start = pos;
while (start > 0 && items[start - 1].section === section) start--;
var end = pos;
while (end + 1 < total && items[end + 1].section === section) end++;
st.pos = start; store.set(q.id, st);
showProgress('Question ' + (start + 1) + ' sur ' + total, (start + 1) / total);
app.innerHTML = '';
maybeBanner();
if (section.title) app.appendChild(el('p', 'muted section-label', section.title));
if (section.section_instructions) app.appendChild(el('p', 'muted', section.section_instructions));
var secItems = items.slice(start, end + 1);
if (section.layout === 'two_columns') {
secItems = secItems.slice().sort(function (a, b) { return (a.item.column || 99) - (b.item.column || 99); });
}
var grid = el('div', section.layout === 'two_columns' ? 'cols cols--two' : 'cols');
var flushers = [];
var firstLabel = null;
secItems.forEach(function (r) {
var block = el('div', 'freeitem');
var lbl = el('p', 'item-text', r.item.text);
lbl.setAttribute('tabindex', '-1');
block.appendChild(lbl);
var ft = makeFreeText(q, st, r);
flushers.push(ft.flush);
block.appendChild(ft.wrap);
grid.appendChild(block);
if (!firstLabel) firstLabel = lbl;
});
app.appendChild(grid);
var flushAll = function () { flushers.forEach(function (f) { f(); }); };
var nav = el('div', 'nav');
if (start > 0) {
var prev = el('button', 'btn', 'Précédente');
prev.addEventListener('click', function () { flushAll(); quiz(q, start - 1); });
nav.appendChild(prev);
}
var next = el('button', 'btn', end + 1 < total ? 'Suivant' : 'Voir le récapitulatif');
next.addEventListener('click', function () {
flushAll();
st.pos = Math.min(end + 1, total); store.set(q.id, st);
quiz(q, end + 1);
});
nav.appendChild(next);
app.appendChild(nav);
keymap = null; // saisie libre : aucun raccourci clavier qui surprendrait
if (firstLabel) firstLabel.focus();
}
/* ---------- résultats : score partiel à chaque instant ---------- */
function recap(q) {
var items = allItems(q);
var total = items.length;
var st = store.get(q.id) || { answers: {}, pos: 0 };
var n = answeredCount(q, st);
showProgress(n + ' réponses sur ' + total, total ? n / total : 0);
keymap = null;
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, q.title));
app.appendChild(el('p', null,
'Merci d’avoir pris ce temps. Voici où vous en êtes. Ce n’est pas un diagnostic.'));
var optCount = 0;
items.forEach(function (r) {
var v = st.answers[r.key];
if (typeof v === 'string' && v.lastIndexOf(OPT, 0) === 0) optCount++;
});
var sc = computeScore(q, st);
var complete = !!(sc && n === total && optCount === 0);
if (n === 0) {
app.appendChild(el('p', null,
'Aucune réponse pour l’instant. Le questionnaire sera là quand vous serez prêt·e.'));
} else if (sc) {
var box = el('div', 'result-band');
if (complete) {
var pct = sc.pct;
box.appendChild(el('div', 'score', pct + ' %'));
box.appendChild(el('p', null, 'Score complet.'));
var band = null;
var bands = (q.scoring || {}).bands || [];
// première bande dont le minimum est atteint ; sinon la dernière
for (var i = 0; i < bands.length; i++) { if (pct >= bands[i].min) { band = bands[i]; break; } }
if (!band && bands.length) band = bands[bands.length - 1];
if (band) box.appendChild(el('p', null, 'Ce qu’en dit le test d’origine : ' + band.label));
} else {
box.appendChild(el('p', null,
'Score provisoire : ' + n + ' réponses sur ' + total + '. Il évolue à chaque nouvelle réponse.'));
}
if (optCount > 0) {
box.appendChild(el('p', null, optCount === 1
? '1 réponse marquée hors échelle, non comptée dans le score.'
: optCount + ' réponses marquées hors échelle, non comptées dans le score.'));
}
box.appendChild(el('p', 'muted',
'Ce chiffre décrit un moment, pas une personne ni un couple. S’il vous surprend ou vous pèse, apportez-le en séance plutôt que de le garder pour vous.'));
app.appendChild(box);
if (complete && q.scoring && q.scoring.source_instruction) {
app.appendChild(el('blockquote', 'orig', q.scoring.source_instruction));
}
} else {
app.appendChild(el('p', null,
'Vous avez répondu à ' + n + ' questions sur ' + total +
'. Ce questionnaire ne se calcule pas : vos réponses sont là pour nourrir votre réflexion ou une discussion à deux.'));
}
if (n > 0) {
var details = el('details');
details.open = n === total || total <= 25;
details.appendChild(el('summary', null, 'Revoir mes réponses (' + n + ' sur ' + total + ')'));
if (n < total) {
details.appendChild(el('p', 'muted', 'Il reste ' + (total - n) + ' questions sans réponse.'));
}
var table = el('table', 'recap');
if (st.header) {
(q.header_fields || []).forEach(function (f) {
var val = st.header[f.id];
if (typeof val !== 'string' || !val) return;
var tr = el('tr');
tr.appendChild(el('td', null, f.label));
tr.appendChild(el('td', null, val));
table.appendChild(tr);
});
}
items.forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined) return;
var tr = el('tr');
tr.appendChild(el('td', null, r.item.text));
tr.appendChild(el('td', v && typeof v === 'object' ? 'free' : null, optionLabel(q, r.section, v)));
table.appendChild(tr);
});
details.appendChild(table);
app.appendChild(details);
}
if (n > 0) {
var shareBtn = el('button', 'btn', 'Créer un lien de partage');
var shareSlot = el('div');
shareBtn.addEventListener('click', function () { buildSharePanel(q, shareBtn, shareSlot); });
app.appendChild(shareBtn);
app.appendChild(shareSlot);
}
var nav = el('div', 'nav');
var back = el('button', 'btn', 'Retour à l’accueil');
back.addEventListener('click', home);
var again = el('button', 'btn btn--primary',
n === 0 ? 'Commencer' : (n < total ? 'Reprendre' : 'Recommencer'));
again.addEventListener('click', function () {
if (n === 0) { quiz(q, 0); return; }
if (n < total) { quiz(q, firstUnanswered(q, st)); return; }
if (window.confirm('Recommencer ce questionnaire ? Vos réponses actuelles seront effacées.')) {
store.set(q.id, { answers: {}, pos: 0 });
quiz(q, 0);
}
});
nav.appendChild(back); nav.appendChild(again);
app.appendChild(nav);
// Entrée = continuer, mais seulement quand rien n'est à risque :
// questionnaire complet, Entrée ne déclenche jamais la remise à zéro.
keymap = (n > 0 && n < total) ? { enter: function () { again.click(); } } : null;
}
function buildSharePanel(q, trigger, slot) {
trigger.hidden = true;
var panel = el('div', 'share');
panel.appendChild(el('p', null,
'Ce lien contient vos réponses, encodées mais non chiffrées : quiconque possède le lien peut les lire. Envoyez-le seulement à qui de droit.'));
var url = location.href.split('#')[0] + '#p=' + answersToBase64url(serializeAnswers(q.id));
var row = el('div', 'share-row');
var inp = el('input');
inp.readOnly = true;
inp.value = url;
inp.setAttribute('aria-label', 'Lien de partage');
inp.addEventListener('focus', function () { inp.select(); });
var copy = el('button', 'btn', 'Copier le lien');
copy.addEventListener('click', function () {
var done = function () {
copy.textContent = 'Lien copié';
setTimeout(function () { copy.textContent = 'Copier le lien'; }, 2000);
};
var ok = false;
try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done, function () {}); ok = true; } } catch (e) {}
if (!ok) {
try { inp.select(); if (document.execCommand('copy')) done(); } catch (e) {}
}
});
row.appendChild(inp); row.appendChild(copy);
panel.appendChild(row);
panel.appendChild(el('p', 'muted',
'La personne qui ouvre ce lien voit vos réponses en lecture seule, rien n’est enregistré sur son appareil.'));
slot.appendChild(panel);
}
function sharedError() {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, 'Lien de partage'));
app.appendChild(el('p', null, 'Lien incomplet ou altéré. Demandez à la personne qui l’a envoyé d’en créer un nouveau.'));
var a = el('a', null, 'Retour à l’accueil');
a.href = location.href.split('#')[0];
app.appendChild(a);
}
function sharedView(p) {
var q = null;
DATA.questionnaires.forEach(function (x) { if (x.id === p.qid) q = x; });
if (!q) { sharedError(); return; }
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
var answers = {};
p.answered.forEach(function (a) { if (a && typeof a.i === 'string') answers[a.i] = a.v; });
var items = allItems(q);
var n = 0;
items.forEach(function (r) { if (answers[r.key] !== undefined) n++; });
app.appendChild(el('h1', null, 'Réponses partagées : ' + q.title));
if (p.respondent) app.appendChild(el('p', 'muted', 'Partagé par ' + p.respondent));
app.appendChild(el('p', 'banner',
'Vue en lecture seule. Ce lien ne peut pas être révoqué ; demandez un nouveau lien si les réponses changent.'));
var details = el('details');
details.open = true;
details.appendChild(el('summary', null, 'Revoir mes réponses (' + n + ' sur ' + items.length + ')'));
var table = el('table', 'recap');
items.forEach(function (r) {
var v = answers[r.key];
if (v === undefined) return;
var l = optionLabel(q, r.section, v);
if (!l) {
p.answered.forEach(function (a) { if (a && a.i === r.key && typeof a.l === 'string' && !l) l = a.l; });
}
var tr = el('tr');
tr.appendChild(el('td', null, r.item.text));
tr.appendChild(el('td', v && typeof v === 'object' ? 'free' : null, l));
table.appendChild(tr);
});
details.appendChild(table);
app.appendChild(details);
app.appendChild(el('footer', 'pagefoot',
'Ces réponses s’affichent depuis le lien ; rien n’est envoyé ni stocké ici.'));
}
if (location.hash.lastIndexOf('#p=', 0) === 0) {
var shared = decodePayload(location.hash.slice(3));
if (shared) sharedView(shared);
else sharedError();
} else {
home();
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
})();
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="robots" content="noindex, nofollow">
<title>Clarté · Espace couple</title>
<meta name="description" content="Questionnaires d’auto-évaluation de couple, en privé, sur votre appareil.">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#faf8f4">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header id="progress" class="progress" hidden></header>
<main id="app"></main>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
</html>
{
"name": "Clarté · Espace couple",
"short_name": "Clarté couple",
"lang": "fr",
"start_url": ".",
"display": "standalone",
"background_color": "#faf8f4",
"theme_color": "#faf8f4"
}
/* Clarté couple, esthétique papier, faible charge cognitive */
@font-face { font-family: 'Atkinson'; src: url('fonts/AtkinsonHyperlegible-Regular.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Atkinson'; src: url('fonts/AtkinsonHyperlegible-Bold.woff2') format('woff2'); font-weight: 700; font-display: swap; }
@font-face { font-family: 'Atkinson'; src: url('fonts/AtkinsonHyperlegible-Italic.woff2') format('woff2'); font-style: italic; font-display: swap; }
:root {
--paper: #faf8f4;
--ink: #3a3630;
--ink-faint: #6d675f;
--teal: #3e7d78;
--teal-soft: #e3efee;
--teal-text: #2d5f5b;
--line: #e5e0d8;
--line-strong: #968e80;
--measure-prose: 60ch;
--measure-question: 52ch;
--fs-caption: .8125rem;
--fs-small: .875rem;
--fs-body: 1rem;
--fs-question: 1.125rem;
--fs-h1: 1.375rem;
--fs-score: 1.75rem;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: 'Atkinson', Georgia, serif; font-size: var(--fs-body); line-height: 1.65;
}
main { max-width: 40rem; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }
header.progress {
position: sticky; top: 0; z-index: 2; background: var(--paper);
padding: .6rem 1.25rem; border-bottom: 1px solid var(--line);
font-size: var(--fs-caption); color: var(--ink-faint);
font-variant-numeric: tabular-nums;
max-width: 40rem; margin: 0 auto;
}
header.progress .fill {
height: 3px; background: var(--line); border-radius: 2px; margin-top: .35rem; overflow: hidden;
}
header.progress .fill i { display: block; height: 100%; background: var(--teal); transition: width .25s ease; }
h1 { font-size: var(--fs-h1); line-height: 1.3; margin: 1.2rem 0 .6rem; text-wrap: balance; }
p { margin: .6rem 0; max-width: var(--measure-prose); text-wrap: pretty; }
.muted { color: var(--ink-faint); font-size: var(--fs-small); }
.section-label { font-size: var(--fs-small); color: var(--ink-faint); margin: 2rem 0 .4rem; }
.keyhint { font-size: var(--fs-caption); color: var(--ink-faint); margin: .4rem 0 0; }
.banner {
border: 1px solid var(--line-strong); background: #fff; border-radius: 9px;
padding: .7rem .9rem; margin: .8rem 0 0; font-size: var(--fs-small); max-width: var(--measure-prose);
}
small.source { display: block; margin-top: .8rem; color: var(--ink-faint); font-size: var(--fs-caption); }
button { font: inherit; cursor: pointer; }
.btn {
border: 1px solid var(--line-strong); background: #fff; border-radius: 9px;
padding: .6rem 1.1rem; font: inherit; color: var(--ink); cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.btn:hover { border-color: var(--teal); }
.btn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.btn:active { background: var(--teal-soft); }
.btn:disabled { background: var(--paper); color: var(--ink-faint); border-color: var(--line); cursor: default; }
.btn--primary { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
.btn--primary:hover { background: #356d68; }
.btn--primary:focus-visible { outline-color: var(--ink); }
.btn--primary:disabled { background: var(--teal); color: #fff; opacity: 1; border-color: var(--line-strong); }
.cardlist { list-style: none; padding: 0; margin: 1.2rem 0; }
.cardlist li { margin: 0 0 .7rem; }
.cardbtn {
width: 100%; text-align: left; background: #fff; border: 1px solid var(--line-strong);
border-radius: 10px; padding: .85rem 1rem; display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
transition: border-color .15s ease;
}
.cardbtn:hover { border-color: var(--teal); }
.cardbtn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.cardbtn:active { background: var(--teal-soft); }
.cardbtn .meta { color: var(--ink-faint); font-size: var(--fs-small); white-space: nowrap; }
.cardbtn .state { color: var(--teal-text); font-size: var(--fs-small); white-space: nowrap; }
.item-text {
font-size: var(--fs-question); line-height: 1.55; margin: 1.4rem 0 1.2rem;
max-width: var(--measure-question); text-wrap: balance;
}
.scale { display: flex; flex-direction: column; gap: .5rem; margin: 1.2rem 0; max-width: var(--measure-question); }
.scale button {
text-align: left; padding: .75rem .9rem; border-radius: 9px;
border: 1px solid var(--line-strong); background: #fff; transition: border-color .12s ease, background .12s ease;
}
.scale button:hover { border-color: var(--teal); }
.scale button:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.scale button:active { background: var(--teal-soft); }
.scale button[aria-pressed="true"] { background: var(--teal-soft); border-color: var(--teal); }
.scale button.optout { border-style: dashed; color: var(--ink-faint); font-size: var(--fs-small); padding: .5rem .9rem; }
.freetext {
font: inherit; color: var(--ink); background: #fff; width: 100%;
max-width: var(--measure-question); min-height: 7rem; padding: .7rem .85rem;
border: 1px solid var(--line-strong); border-radius: 9px; resize: vertical;
}
.freetext:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.freeitem { margin: 0 0 1rem; }
.cols { display: grid; grid-template-columns: 1fr; gap: 1.2rem; margin: 1rem 0; max-width: var(--measure-question); }
.headfields { margin: 1.2rem 0 0; display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; align-items: baseline; }
.headfields input {
font-size: var(--fs-body); color: var(--ink); background: #fff;
border: 1px solid var(--line-strong); border-radius: 8px; padding: .45rem .6rem;
width: 16ch; min-height: 44px;
}
.headfields input:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
input[type="range"] { width: 100%; max-width: var(--measure-question); accent-color: var(--teal); }
.rangeblock { margin: 1.2rem 0; max-width: var(--measure-question); }
.range-out { font-size: var(--fs-question); font-weight: 700; font-variant-numeric: tabular-nums; color: var(--teal-text); }
.range-anchors { display: flex; justify-content: space-between; font-size: var(--fs-small); color: var(--ink-faint); margin-top: .3rem; }
.who { margin: 1.6rem 0 0; display: flex; align-items: baseline; gap: .45rem; }
.who input {
font-size: var(--fs-body); color: var(--ink); background: #fff;
border: 1px solid var(--line-strong); border-radius: 8px; padding: .45rem .6rem;
width: 16ch; min-height: 44px;
}
.who input:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.who input::placeholder { color: #6d675f; opacity: 1; }
.nav { display: flex; justify-content: space-between; gap: 1rem; margin-top: 1.6rem; }
.result-band {
background: var(--teal-soft); border-radius: 12px; padding: 1.1rem 1.2rem; margin: 1.2rem 0;
}
.result-band .score {
font-size: var(--fs-score); font-weight: 700; color: var(--teal); font-variant-numeric: tabular-nums;
}
blockquote.orig {
border-left: 3px solid var(--line); margin: 1rem 0 0; padding: .2rem 0 .2rem 1rem;
color: var(--ink-faint); font-size: var(--fs-small); font-style: italic;
}
.recap { width: 100%; border-collapse: collapse; font-size: var(--fs-small); margin-top: 1rem; }
.recap td, .recap th { border-bottom: 1px solid var(--line); padding: .45rem .5rem .45rem 0; text-align: left; vertical-align: top; }
.recap td:last-child { padding-right: 0; }
.recap td.free { white-space: pre-wrap; word-break: break-word; }
details { margin-top: 1.4rem; }
summary {
color: var(--ink); font-weight: 700; font-size: var(--fs-body); padding: .6rem 0; cursor: pointer;
}
summary:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.share { border: 1px solid var(--line); background: #fff; border-radius: 9px; padding: .8rem .9rem; margin-top: .8rem; max-width: var(--measure-prose); }
.share-row { display: flex; gap: .5rem; margin: .6rem 0; flex-wrap: wrap; }
.share-row input {
flex: 1 1 16rem; font-size: var(--fs-small); color: var(--ink); background: var(--paper);
border: 1px solid var(--line-strong); border-radius: 8px; padding: .45rem .6rem; min-height: 44px;
}
.share-row input:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.linkbtn {
border: none; background: none; color: var(--teal-text); padding: 0;
font: inherit; font-size: var(--fs-small); text-decoration: underline; cursor: pointer;
}
.linkbtn.danger { color: #8a4a3b; }
.linkbtn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
footer.pagefoot { margin-top: 3rem; font-size: var(--fs-small); color: var(--ink-faint); }
a { color: var(--teal-text); }
@media (min-width: 640px) {
.cols--two { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
header.progress .fill i { transition: none; }
}
@media (max-width: 480px) {
main { padding: 1.1rem .9rem 3rem; }
header.progress { padding: .5rem .9rem; }
.scale button { padding: .65rem .8rem; }
.scale button.optout { min-height: 44px; }
.nav { gap: .6rem; }
.btn { padding: .55rem .9rem; }
.cardbtn { flex-wrap: wrap; }
.cardbtn .meta, .cardbtn .state { white-space: normal; }
.recap tr { display: block; border-bottom: 1px solid var(--line); padding: .5rem 0; }
.recap td, .recap th { display: block; border-bottom: none; padding: .15rem 0; }
.recap td.free { white-space: pre-wrap; word-break: break-word; }
}
/* Cache-first: la page et ses données chargent hors réseau; les réponses
vivent dans localStorage et ne passent jamais par le réseau. */
/* Règle : tout commit qui touche un fichier de ASSETS doit incrémenter CACHE. */
var CACHE = 'couple-v4';
var ASSETS = ['./', './index.html', './style.css', './app.js', './data.js',
'./manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2',
'./fonts/AtkinsonHyperlegible-Bold.woff2',
'./fonts/AtkinsonHyperlegible-Italic.woff2'];
self.addEventListener('install', function (e) {
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); }));
});
self.addEventListener('activate', function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k !== CACHE; })
.map(function (k) { return caches.delete(k); }));
}));
});
self.addEventListener('fetch', function (e) {
if (e.request.method !== 'GET') return;
e.respondWith(caches.match(e.request, { ignoreSearch: true })
.then(function (hit) { return hit || fetch(e.request); }));
});
...@@ -151,13 +151,99 @@ const EF_OPTIONS = [ ...@@ -151,13 +151,99 @@ const EF_OPTIONS = [
]; ];
// ============================================ // ============================================
// ANSWER PROGRESS (counter, fill line, milestones)
// Derived from the question lists above — totals are never hardcoded.
// ============================================
const ASRS_IDS = [...ASRS_QUESTIONS.part_a, ...ASRS_QUESTIONS.part_b].map(q => q.id);
const DIVA_IDS = [
...DIVA_INATTENTION, ...DIVA_HYPERACTIVITY,
...DIVA_CHILDHOOD, ...DIVA_IMPAIRMENT
].map(q => q.id);
const EXECUTIVE_IDS = Object.values(EXEC_FUNCTIONS).flatMap(c => c.questions.map(q => q.id));
const ALL_QUESTION_IDS = [...ASRS_IDS, ...DIVA_IDS, ...EXECUTIVE_IDS];
const TOTAL_QUESTIONS = ALL_QUESTION_IDS.length; // 70 = 18 ASRS + 28 DSM-5 + 24 fonctions exécutives
// Milestone boundaries: end of the ASRS block, half of the total,
// end of the DSM-5 block, end of everything. Copy finals from
// copy-fr.md §12 (progress.answered, milestones). If this tree is ever
// translated, copy-en.md §12 gives: "{n} of {total} answered · {m} to go",
// "First part done, well underway.", "You are halfway through.",
// "Two parts done, one to go.", "That's everything. Thank you for getting clear."
const PROGRESS_COPY = {
countPlural: (n, m) => `${n} questions répondues · il en reste ${m}`,
countSingular: (n, m) => `${n} question répondue · il en reste ${m}`,
countDone: (n) => `${n} questions répondues`,
milestones: {
'asrs.done': 'Première partie terminée, bien avancé.',
'half': 'Vous êtes à la moitié.',
'dsm.done': 'Deux parties terminées, reste la dernière.',
'all.done': "C'est complet. Merci d'avoir été au clair."
}
};
const MILESTONES = [
{ key: 'asrs.done', at: ASRS_IDS.length },
{ key: 'half', at: Math.floor(TOTAL_QUESTIONS / 2) },
{ key: 'dsm.done', at: ASRS_IDS.length + DIVA_IDS.length },
{ key: 'all.done', at: TOTAL_QUESTIONS }
];
function formatCountLine(answered, total) {
const remaining = total - answered;
if (remaining === 0) return PROGRESS_COPY.countDone(answered);
if (answered <= 1) return PROGRESS_COPY.countSingular(answered, remaining);
return PROGRESS_COPY.countPlural(answered, remaining);
}
// Highest milestone whose boundary the answered count has crossed, or null.
function crossedMilestone(answered) {
let hit = null;
for (const m of MILESTONES) {
if (answered >= m.at) hit = m;
else break;
}
return hit;
}
// Refresh the sticky answer-progress band: count line, 3px fill, and a
// milestone in the count line's slot when a boundary is just crossed
// (shown for the current screen, then the count line returns). The last
// milestone shown is persisted so a reload does not re-fire it.
function updateAnswerProgress() {
const band = document.getElementById('answer-progress');
const countEl = document.getElementById('answer-count');
const fill = document.getElementById('answer-fill');
if (!band || !countEl || !fill) return;
const visible = state.currentSection === 'asrs'
|| state.currentSection === 'diva'
|| state.currentSection === 'executive';
band.hidden = !visible;
if (!visible) return;
const answered = ALL_QUESTION_IDS.filter(id => state.responses[id] !== undefined).length;
fill.style.width = (answered / TOTAL_QUESTIONS * 100) + '%';
const milestone = crossedMilestone(answered);
if (milestone && milestone.key !== state.lastMilestone) {
countEl.textContent = PROGRESS_COPY.milestones[milestone.key];
state.lastMilestone = milestone.key;
saveState();
} else {
countEl.textContent = formatCountLine(answered, TOTAL_QUESTIONS);
}
}
// ============================================
// APP STATE // APP STATE
// ============================================ // ============================================
const state = { const state = {
currentSection: 'home', currentSection: 'home',
responses: {}, responses: {},
results: null results: null,
lastMilestone: null
}; };
// ============================================ // ============================================
...@@ -172,7 +258,8 @@ function saveState() { ...@@ -172,7 +258,8 @@ function saveState() {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ localStorage.setItem(STORAGE_KEY, JSON.stringify({
responses: state.responses, responses: state.responses,
currentSection: state.currentSection currentSection: state.currentSection,
lastMilestone: state.lastMilestone
})); }));
} catch (e) { /* storage unavailable (private mode, quota): skip */ } } catch (e) { /* storage unavailable (private mode, quota): skip */ }
} }
...@@ -195,6 +282,9 @@ function restoreState() { ...@@ -195,6 +282,9 @@ function restoreState() {
if (saved.responses && typeof saved.responses === 'object') { if (saved.responses && typeof saved.responses === 'object') {
Object.assign(state.responses, saved.responses); Object.assign(state.responses, saved.responses);
} }
if (typeof saved.lastMilestone === 'string') {
state.lastMilestone = saved.lastMilestone;
}
if (sections.includes(saved.currentSection)) { if (sections.includes(saved.currentSection)) {
return saved.currentSection; return saved.currentSection;
} }
...@@ -219,6 +309,7 @@ function showSection(sectionId) { ...@@ -219,6 +309,7 @@ function showSection(sectionId) {
target.classList.add('active'); target.classList.add('active');
state.currentSection = sectionId; state.currentSection = sectionId;
updateProgress(); updateProgress();
updateAnswerProgress();
window.scrollTo(0, 0); window.scrollTo(0, 0);
saveState(); saveState();
} }
...@@ -381,6 +472,7 @@ function attachOptionHandlers(container) { ...@@ -381,6 +472,7 @@ function attachOptionHandlers(container) {
input.addEventListener('change', (e) => { input.addEventListener('change', (e) => {
state.responses[e.target.name] = parseInt(e.target.value); state.responses[e.target.name] = parseInt(e.target.value);
saveState(); saveState();
updateAnswerProgress();
const question = e.target.closest('.question'); const question = e.target.closest('.question');
if (question) question.classList.add('answered'); if (question) question.classList.add('answered');
}); });
...@@ -881,6 +973,7 @@ function init() { ...@@ -881,6 +973,7 @@ function init() {
document.getElementById('restart').addEventListener('click', () => { document.getElementById('restart').addEventListener('click', () => {
state.responses = {}; state.responses = {};
state.results = null; state.results = null;
state.lastMilestone = null;
clearSavedState(); clearSavedState();
renderASRS(); renderASRS();
renderDIVA(); renderDIVA();
......
...@@ -51,6 +51,12 @@ ...@@ -51,6 +51,12 @@
<div class="step-dot"></div> <div class="step-dot"></div>
</div> </div>
<p class="progress-label">Accueil</p> <p class="progress-label">Accueil</p>
<div class="answer-progress" id="answer-progress" hidden>
<p class="answer-count" id="answer-count"></p>
<div class="answer-fill-track">
<div class="answer-fill" id="answer-fill"></div>
</div>
</div>
</div> </div>
</nav> </nav>
......
...@@ -264,6 +264,44 @@ header .subtitle { ...@@ -264,6 +264,44 @@ header .subtitle {
color: var(--ink-light); color: var(--ink-light);
} }
/* Answer progress: count line + 3px fill, on questionnaire sections only.
Hidden on home/results via the `hidden` attribute (set in app.js). */
.answer-progress {
margin-top: var(--space-sm);
}
.answer-progress[hidden] {
display: none;
}
.answer-count {
text-align: center;
margin-bottom: var(--space-xs);
font-size: 0.85rem;
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.answer-fill-track {
height: 3px;
background: var(--paper-shadow);
border-radius: var(--radius-full);
overflow: hidden;
}
.answer-fill {
height: 100%;
width: 0;
background: var(--teal-dark);
transition: width 300ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.answer-fill {
transition: none;
}
}
/* ============================================ /* ============================================
MAIN CONTENT MAIN CONTENT
============================================ */ ============================================ */
......
/* Cache-first: la page et ses données chargent hors réseau; les réponses /* Cache-first: la page et ses données chargent hors réseau; les réponses
vivent dans localStorage et ne passent jamais par le réseau. */ vivent dans localStorage et ne passent jamais par le réseau. */
var CACHE = 'clarte-v1'; var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js', var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest', './manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2', './fonts/AtkinsonHyperlegible-Regular.woff2',
......
/* Clarté design tokens — global by nature; imported once from app/layout.tsx.
Source of truth: components/clarte/README.md. */
/* Clarté UI kit — design tokens.
Design authority: Diag_ADHD/redesign/couple-redesign-spec.md §1 + §8,
committed identity from public/couple/style.css.
Palette family is frozen: this is a contrast repair inside the palette, never a re-theme.
Custom properties are declared on `:root` (a :root block inside a CSS module is NOT
scoped — css-loader leaves it alone), so any component in the page can read them once
this module is imported. The same block also targets the exported `.clarte` wrapper
class, giving apps an opt-in scoped scope if they ever need token isolation.
Reduced-motion pattern (used by every component module in this kit):
@media (prefers-reduced-motion: reduce) { .x { transition: none; animation: none; } }
Never a global `* { transition: none }` — each component owns its own reset. */
@font-face {
font-family: 'Atkinson';
src: url('/fonts/AtkinsonHyperlegible-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Atkinson';
src: url('/fonts/AtkinsonHyperlegible-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Atkinson';
src: url('/fonts/AtkinsonHyperlegible-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
:root {
/* Paper palette (committed identity) */
--paper: #faf8f4;
--ink: #3a3630;
--teal: #3e7d78;
--teal-soft: #e3efee;
/* Contrast repairs (spec §1.3) */
--ink-faint: #6d675f; /* 5.27:1 on paper, 5.59:1 on #fff, 4.75:1 on teal-soft */
--line: #e5e0d8; /* decorative hairlines only */
--line-strong: #968e80; /* 3.06:1 paper / 3.24:1 white — all interactive borders */
--teal-text: #2d5f5b; /* 6.83:1 paper / 6.16:1 teal-soft — teal at text sizes */
--teal-hover: #356d68; /* white on #356d68 = 5.93:1 */
--danger: #8a4a3b; /* 6.35:1 on paper */
/* Typography — Atkinson ships 400/700 only; never a synthesized weight */
--font-clarte: 'Atkinson', Georgia, serif;
--fs-caption: 0.8125rem; /* 13px */
--fs-small: 0.875rem; /* 14px */
--fs-body: 1rem; /* 16px */
--fs-question: 1.125rem; /* 18px */
--fs-h1: 1.375rem; /* 22px */
--fs-score: 1.75rem; /* 28px */
/* Measures */
--measure-prose: 60ch;
--measure-question: 52ch;
/* Spacing rhythm — 4px base scale, one step per intent */
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 1rem;
--sp-4: 1.5rem;
--sp-5: 2rem;
--sp-6: 3rem;
/* Radii */
--radius: 9px;
--radius-lg: 12px;
/* Focus ring — every interactive element in the kit uses this verbatim */
--focus-ring: 2px solid var(--teal);
--focus-ring-offset: 2px;
--focus-ring-primary-color: var(--ink);
}
.clarte {
font-family: var(--font-clarte);
background: var(--paper);
color: var(--ink);
}
/**
* Clarté UI kit — unit tests (compile-proof for the unreferenced kit: next/jest
* transforms every component imported here, so a broken kit fails these).
*/
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
import React from 'react';
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import Button from '@/components/clarte/Button';
import ProgressHeader from '@/components/clarte/ProgressHeader';
import QuestionCard from '@/components/clarte/QuestionCard';
import OptionPills from '@/components/clarte/OptionPills';
import Slider from '@/components/clarte/Slider';
import TextArea from '@/components/clarte/TextArea';
import useDigitKeys from '@/components/clarte/useDigitKeys';
import { usePersistentState, migrateLegacy } from '@/components/clarte/usePersistentState';
// A harness so hooks are exercised inside a component tree, the way the apps use them.
function HookHarness({ useHook, assert }) {
assert(useHook());
return null;
}
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
});
afterEach(() => {
cleanup();
jest.useRealTimers();
});
describe('Button', () => {
it('renders with the .btn base class and forwards children', () => {
render(<Button>Continuer</Button>);
const btn = screen.getByRole('button', { name: 'Continuer' });
expect(btn).toBeInTheDocument();
expect(btn.className).toMatch(/btn/);
expect(btn.className).not.toMatch(/btnPrimary|btnSm/);
});
it('primary variant adds the primary class; sm adds the size class', () => {
const { rerender } = render(<Button variant="primary">Go</Button>);
expect(screen.getByRole('button').className).toMatch(/btnPrimary/);
rerender(<Button size="sm">Go</Button>);
expect(screen.getByRole('button').className).toMatch(/btnSm/);
});
it('respects the disabled prop', () => {
render(<Button disabled>Précédente</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('fires onClick when enabled', () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>Tap</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
describe('ProgressHeader', () => {
const labels = {
answeredLine: (n, m) => `${n} question(s) répondue(s) · il en reste ${m}`,
fullLine: (n) => `${n} questions répondues`,
};
it('renders answeredLine(n, m) with the remaining count', () => {
render(<ProgressHeader answered={3} total={70} labels={labels} />);
expect(screen.getByText('3 question(s) répondue(s) · il en reste 67')).toBeInTheDocument();
});
it('switches to fullLine when nothing remains', () => {
render(<ProgressHeader answered={70} total={70} labels={labels} />);
expect(screen.getByText('70 questions répondues')).toBeInTheDocument();
});
it('milestone replaces the count line (crossfade layers) and is announced', () => {
render(
<ProgressHeader answered={36} total={70} milestone="Vous êtes à la moitié." labels={labels} />
);
expect(screen.getByRole('status')).toHaveTextContent('Vous êtes à la moitié.');
// The count layer is kept in the DOM for the crossfade but hidden from a11y.
const countLayer = screen.getByText('36 question(s) répondue(s) · il en reste 34');
expect(countLayer).toHaveAttribute('aria-hidden', 'true');
});
it('fills the bar proportionally to answered/total', () => {
const { container } = render(<ProgressHeader answered={35} total={70} labels={labels} />);
const bar = container.querySelector('i');
expect(bar).toHaveStyle({ width: '50%' });
});
it('is hidden entirely when hidden is true', () => {
const { container } = render(
<ProgressHeader answered={1} total={7} labels={labels} hidden />
);
expect(container.querySelector('header')).toBeNull();
});
it('clamps the fill when answered exceeds total', () => {
const { container } = render(<ProgressHeader answered={9} total={7} labels={labels} />);
expect(container.querySelector('i')).toHaveStyle({ width: '100%' });
});
});
describe('QuestionCard', () => {
it('renders ribbon, question, answer children and footer slot', () => {
render(
<QuestionCard
ribbon="ASRS v1.1 · Partie A"
question="À quelle fréquence…?"
footer={<button>Suivant</button>}
>
<button>Réponse</button>
</QuestionCard>
);
expect(screen.getByText('ASRS v1.1 · Partie A')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'À quelle fréquence…?' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Réponse' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Suivant' })).toBeInTheDocument();
});
it('omits the ribbon when absent', () => {
render(<QuestionCard question="Seule question" />);
expect(screen.queryByText(/Partie/)).toBeNull();
});
});
describe('OptionPills', () => {
const options = [
{ value: 1, label: 'Jamais' },
{ value: 2, label: 'Parfois' },
{ value: 3, label: 'Souvent' },
];
it('renders real buttons with aria-pressed reflecting the controlled value', () => {
render(<OptionPills options={options} value={2} onChange={() => {}} />);
const pills = screen.getAllByRole('button');
expect(pills).toHaveLength(3);
expect(pills[0]).toHaveAttribute('aria-pressed', 'false');
expect(pills[1]).toHaveAttribute('aria-pressed', 'true');
expect(pills[2]).toHaveAttribute('aria-pressed', 'false');
});
it('reports the picked value through onChange', () => {
const onChange = jest.fn();
render(<OptionPills options={options} value={null} onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Souvent' }));
expect(onChange).toHaveBeenCalledWith(3);
});
it('disables disabled options without dropping them', () => {
const onChange = jest.fn();
const withDisabled = [...options.slice(0, 2), { value: 3, label: 'Souvent', disabled: true }];
render(<OptionPills options={withDisabled} value={null} onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Souvent' }));
expect(screen.getByRole('button', { name: 'Souvent' })).toBeDisabled();
expect(onChange).not.toHaveBeenCalled();
});
});
describe('useDigitKeys', () => {
function PillsWithDigits({ n, onPick }) {
useDigitKeys(n, onPick);
return <div>ready</div>;
}
it('maps digits 1..n to zero-based indices', () => {
const onPick = jest.fn();
render(<PillsWithDigits n={4} onPick={onPick} />);
fireEvent.keyDown(document, { key: '2' });
fireEvent.keyDown(document, { key: '1' });
fireEvent.keyDown(document, { key: '4' });
expect(onPick.mock.calls).toEqual([[1], [0], [3]]);
});
it('ignores digits beyond n and non-digits', () => {
const onPick = jest.fn();
render(<PillsWithDigits n={3} onPick={onPick} />);
fireEvent.keyDown(document, { key: '4' });
fireEvent.keyDown(document, { key: '0' });
fireEvent.keyDown(document, { key: 'a' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(onPick).not.toHaveBeenCalled();
});
it('ignores events from input/textarea/select targets', () => {
const onPick = jest.fn();
render(
<div>
<PillsWithDigits n={3} onPick={onPick} />
<input data-testid="field" />
<textarea data-testid="area" />
<select data-testid="sel" />
</div>
);
fireEvent.keyDown(screen.getByTestId('field'), { key: '1' });
fireEvent.keyDown(screen.getByTestId('area'), { key: '2' });
fireEvent.keyDown(screen.getByTestId('sel'), { key: '3' });
expect(onPick).not.toHaveBeenCalled();
});
it('ignores ctrl/meta/alt modifiers and keeps a single listener', () => {
const onPick = jest.fn();
render(<PillsWithDigits n={3} onPick={onPick} />);
fireEvent.keyDown(document, { key: '1', ctrlKey: true });
fireEvent.keyDown(document, { key: '1', metaKey: true });
fireEvent.keyDown(document, { key: '1', altKey: true });
expect(onPick).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: '1' });
expect(onPick).toHaveBeenCalledTimes(1);
});
});
describe('Slider', () => {
it('shows the value in <output> and reports valueAsNumber', () => {
const onChange = jest.fn();
render(
<Slider min={1} max={9} value={4} anchors={['Jamais', 'Très souvent']} onChange={onChange} />
);
expect(screen.getByText('Jamais')).toBeInTheDocument();
expect(screen.getByText('Très souvent')).toBeInTheDocument();
const output = screen.getByText('4');
expect(output.tagName).toBe('OUTPUT');
const input = screen.getByRole('slider');
expect(input).toHaveAttribute('min', '1');
expect(input).toHaveAttribute('max', '9');
expect(input).toHaveAttribute('step', '1');
fireEvent.change(input, { target: { value: '7' } });
expect(onChange).toHaveBeenCalledWith(7);
});
});
describe('TextArea', () => {
it('is controlled: onChange reports the new value immediately', () => {
function Hold({ onChange }) {
const [val, setVal] = React.useState('');
return (
<TextArea
value={val}
onChange={(v) => {
setVal(v);
onChange(v);
}}
rows={3}
maxLength={600}
ariaLabel="Votre réponse"
/>
);
}
const onChange = jest.fn();
render(<Hold onChange={onChange} />);
const area = screen.getByLabelText('Votre réponse');
fireEvent.change(area, { target: { value: 'Bonjour' } });
expect(area.value).toBe('Bonjour');
expect(onChange).toHaveBeenCalledWith('Bonjour');
});
it('debounces onDebouncedChange by 300ms and flushes on blur', () => {
jest.useFakeTimers();
const onDebounced = jest.fn();
render(
<TextArea value="" onChange={() => {}} onDebouncedChange={onDebounced} ariaLabel="Texte" />
);
const area = screen.getByLabelText('Texte');
fireEvent.change(area, { target: { value: 'a' } });
act(() => jest.advanceTimersByTime(200));
fireEvent.change(area, { target: { value: 'ab' } });
act(() => jest.advanceTimersByTime(200));
expect(onDebounced).not.toHaveBeenCalled(); // keystroke reset the 300ms timer
act(() => jest.advanceTimersByTime(150));
expect(onDebounced).toHaveBeenCalledWith('ab');
// blur flushes a pending debounce immediately
fireEvent.change(area, { target: { value: 'abc' } });
expect(onDebounced).toHaveBeenCalledTimes(1);
fireEvent.blur(area);
expect(onDebounced).toHaveBeenCalledWith('abc');
});
it('does not autofocus', () => {
render(<TextArea value="" onChange={() => {}} ariaLabel="Texte" />);
expect(screen.getByLabelText('Texte')).not.toHaveFocus();
});
});
describe('usePersistentState', () => {
it('returns the fallback when storage is empty', () => {
const seen = [];
render(
<HookHarness
useHook={() => usePersistentState('clarte:v1', { fallback: { responses: {} } })}
assert={(v) => seen.push(v[0])}
/>
);
expect(seen[0]).toEqual({ responses: {} });
});
it('persists writes as JSON and reads them back', () => {
let setter = null;
function Hold() {
const [value, set] = usePersistentState('clarte:v1', { fallback: { responses: {} } });
setter = set;
return <p data-testid="n">{Object.keys(value.responses).length}</p>;
}
render(<Hold />);
act(() => setter({ responses: { q1: 3 } }));
expect(JSON.parse(window.localStorage.getItem('clarte:v1'))).toEqual({ responses: { q1: 3 } });
cleanup();
render(<Hold />);
expect(screen.getByTestId('n').textContent).toBe('1');
});
it('falls back on a corrupt record instead of crashing', () => {
window.localStorage.setItem('clarte:v1', '{not json at all');
const seen = [];
render(
<HookHarness
useHook={() => usePersistentState('clarte:v1', { fallback: { responses: {} } })}
assert={(v) => seen.push(v[0])}
/>
);
expect(seen[0]).toEqual({ responses: {} });
});
it('falls back when the record fails the schema check', () => {
window.localStorage.setItem('clarte:v1', JSON.stringify({ oops: true }));
const seen = [];
render(
<HookHarness
useHook={() =>
usePersistentState('clarte:v1', {
fallback: { responses: {} },
validate: (v) => v && typeof v.responses === 'object',
})
}
assert={(v) => seen.push(v[0])}
/>
);
expect(seen[0]).toEqual({ responses: {} });
});
});
describe('migrateLegacy', () => {
it('parses couple:v1:* records and flags corrupt ones without throwing', () => {
window.localStorage.setItem(
'couple:v1:q:asrs',
JSON.stringify({ answers: { '0': 4, 's2:1': { text: 'écrit' } }, pos: 2 })
);
window.localStorage.setItem(
'couple:v1:session',
JSON.stringify({ created: '2026-09-01', respondent_label: 'A' })
);
window.localStorage.setItem('couple:v1:q:broken', 'x{');
window.localStorage.setItem('unrelated', 'keep out');
const records = migrateLegacy('couple:v1:');
const byKey = Object.fromEntries(records.map((r) => [r.key, r]));
expect(records).toHaveLength(3);
expect(byKey['couple:v1:q:asrs'].value).toEqual({
answers: { '0': 4, 's2:1': { text: 'écrit' } },
pos: 2,
});
expect(byKey['couple:v1:session'].value.respondent_label).toBe('A');
expect(byKey['couple:v1:q:broken'].corrupt).toBe(true);
expect(records.some((r) => r.key === 'unrelated')).toBe(false);
});
it('survives a storage access failure', () => {
const descriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
const throwing = {
getItem() {
throw new Error('denied');
},
};
Object.defineProperty(window, 'localStorage', { value: throwing, configurable: true });
try {
expect(migrateLegacy('couple:v1:')).toEqual([]);
} finally {
Object.defineProperty(window, 'localStorage', descriptor);
}
});
});
/**
* Clarté couple — intégrité de la donnée : la copie module de
* components/couple/data.js est identique octet pour octet (après aller-retour
* JSON) à la source verbatim public/couple/data.js ; structure : 8
* questionnaires, 190 items, chaque référence d'échelle se résout.
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { COUPLE_DATA } from '@/components/couple/data';
const ROOT = path.resolve(__dirname, '..', '..');
/* Migration note (2026-09-22): the byte/JSON identity between the vanilla
public/couple/data.js and this module was verified BEFORE the cutover
removed the vanilla copy (semantic identity: 8 questionnaires, 190 items,
canonical JSON equal). The module is now the single source of truth; the
original extraction lives in git history. What remains testable is the
structure contract below. */
describe('couple data — structure contract', () => {
it('has 8 questionnaires totalling 190 items', () => {
expect(COUPLE_DATA.questionnaires).toHaveLength(8);
const total = COUPLE_DATA.questionnaires.reduce(
(acc, q) => acc + q.sections.reduce((a, s) => a + (s.items || []).length, 0),
0
);
expect(total).toBe(190);
});
it('resolves every section scale and every default_scale in scale_library', () => {
COUPLE_DATA.questionnaires.forEach((q) => {
expect(COUPLE_DATA.scale_library[q.default_scale]).toBeDefined();
q.sections.forEach((s) => {
if (s.scale) expect(COUPLE_DATA.scale_library[s.scale]).toBeDefined();
(s.items || []).forEach((it) => {
expect(typeof it.index).toBe('number');
expect(typeof it.text).toBe('string');
});
});
});
});
it('every scale has a known type', () => {
const known = ['likert', 'single_choice', 'numeric_range', 'free_text'];
Object.values(COUPLE_DATA.scale_library).forEach((s) => {
expect(known).toContain(s.type);
});
});
it('exactly two sum_all_items instruments, with bands carrying min/label', () => {
const scored = COUPLE_DATA.questionnaires.filter(
(q) => q.scoring && q.scoring.method === 'sum_all_items'
);
expect(scored).toHaveLength(2);
scored.forEach((q) => {
expect(q.scoring.bands.length).toBeGreaterThan(0);
q.scoring.bands.forEach((b) => {
expect(typeof b.min).toBe('number');
expect(typeof b.label).toBe('string');
});
});
});
it('folio is the only worksheet, with texte_libre everywhere', () => {
const folio = COUPLE_DATA.questionnaires.find((q) => q.id === 'folio_vie_en_couple');
expect(folio.type).toBe('worksheet');
folio.sections.forEach((s) => expect(COUPLE_DATA.scale_library[s.scale].type).toBe('free_text'));
expect(
COUPLE_DATA.questionnaires.filter((q) => q.type === 'worksheet')
).toHaveLength(1);
});
it('communication is numeric_range 1..9 with two verbatim anchors', () => {
const s = COUPLE_DATA.scale_library.communication_1_9;
expect(s.type).toBe('numeric_range');
expect(s.min).toBe(1);
expect(s.max).toBe(9);
expect(Object.keys(s.anchors).sort()).toEqual(['1', '9']);
});
it('opt_out_options ids referenced by extra_options all resolve', () => {
COUPLE_DATA.questionnaires.forEach((q) =>
q.sections.forEach((s) =>
(s.items || []).forEach((it) =>
(it.extra_options || []).forEach((k) =>
expect(COUPLE_DATA.opt_out_options[k]).toBeDefined()
)
)
)
);
});
});
/**
* Clarté couple — moteur (logique pure) : clés de stockage et collisions,
* échelles, comptage, score et bandes, sérialisation et lien de partage,
* texte libre. Portage de public/couple/app.js, contrats identiques.
*/
// jsdom ne fournit pas TextEncoder (nécessaire à la sérialisation du lien).
if (typeof globalThis.TextEncoder === 'undefined') {
const util = require('util');
globalThis.TextEncoder = util.TextEncoder;
globalThis.TextDecoder = util.TextDecoder;
}
import {
OPT,
allItems,
answersToBase64url,
computeScore,
decodePayload,
findBand,
firstUnanswered,
freeTextSizes,
optionLabel,
optValue,
resolveScale,
sanitizePos,
serializeAnswers,
setAnswer,
} from '@/components/couple/engine';
import { COUPLE_DATA } from '@/components/couple/data';
const Q = (id) => COUPLE_DATA.questionnaires.find((q) => q.id === id);
const bonheur = Q('bonheur_conjugal');
const pensees = Q('pensees_negatives');
const folio = Q('folio_vie_en_couple');
const communication = Q('communication');
describe('allItems — storage keys and collisions', () => {
it('keys are String(index) for unique indexes', () => {
const items = allItems(bonheur);
expect(items).toHaveLength(25);
expect(items[0].key).toBe('1');
expect(items[24].key).toBe('25');
});
it('duplicated section indexes get the s<si>: prefix (pensees_negatives)', () => {
const items = allItems(pensees);
expect(items).toHaveLength(59);
// first sections reuse index 1..: section 0 item 1 → '1', section 1 item 1 → 's1:1'
const keys = items.map((r) => r.key);
expect(keys[0]).toBe('1');
const firstOfSection1 = keys.indexOf('s1:1');
expect(firstOfSection1).toBeGreaterThan(0);
// every key is unique
expect(new Set(keys).size).toBe(59);
});
it('inhibiteurs_excitateurs collides too', () => {
const keys = allItems(Q('inhibiteurs_excitateurs')).map((r) => r.key);
expect(keys.filter((k) => k.indexOf('s1:') === 0).length).toBeGreaterThan(0);
});
});
describe('resolveScale — per section, fallback default_scale', () => {
it('resolves the section scale when present', () => {
const r = allItems(communication)[0];
expect(resolveScale(r.section, communication).type).toBe('numeric_range');
});
it('falls back to default_scale when a section has no scale', () => {
const q = { default_scale: 'satisfaction_0_4', sections: [{ items: [{ index: 1 }] }] };
expect(resolveScale(q.sections[0], q).type).toBe('likert');
});
});
describe('answeredCount / firstUnanswered / sanitizePos', () => {
it('counts only defined answers', () => {
const st = { answers: { 1: 3, 2: 'optout:x' }, pos: 0 };
expect(answeredCountOf(bonheur, st)).toBe(2);
});
it('firstUnanswered returns the first gap, else items.length', () => {
expect(firstUnanswered(bonheur, { answers: {} })).toBe(0);
expect(firstUnanswered(bonheur, { answers: { 1: 2, 2: 3 } })).toBe(2);
const full = {};
for (let i = 1; i <= 25; i++) full[String(i)] = 1;
expect(firstUnanswered(bonheur, { answers: full })).toBe(25);
});
it('sanitizePos clamps and never yields NaN', () => {
expect(sanitizePos('abc', 25)).toBe(0);
expect(sanitizePos(-5, 25)).toBe(0);
expect(sanitizePos(99, 25)).toBe(25);
expect(sanitizePos(3.9, 25)).toBe(3);
});
function answeredCountOf(q, st) {
return allItems(q).filter((r) => st.answers[r.key] !== undefined).length;
}
});
describe('computeScore — sum_all_items only, non-numbers excluded', () => {
it('percent = answered sum / answered max (25×4 = 100)', () => {
const answers = {};
for (let i = 1; i <= 25; i++) answers[String(i)] = 3;
expect(computeScore(bonheur, { answers })).toEqual({ n: 25, pct: 75 });
});
it('excludes opt-out sentinels and free-text objects from sums', () => {
const answers = { 1: 4, 2: OPT + 'pas_activite_avec_partenaire', 3: { text: 'motif' } };
expect(computeScore(bonheur, { answers })).toEqual({ n: 1, pct: 100 });
});
it('returns null for scoring-null instruments', () => {
expect(computeScore(communication, { answers: { 1: 5 } })).toBeNull();
expect(computeScore(folio, { answers: { 1: { text: 'x' } } })).toBeNull();
});
});
describe('findBand — first min-match wins, last-band fallback', () => {
it('pct 75 picks the FIRST band whose min is reached (overlap case)', () => {
const band = findBand(bonheur, 75);
// bands overlap (min 51 and min 75): first-min-match must win
const bands = bonheur.scoring.bands;
const first = bands.find((b) => 75 >= b.min);
expect(band).toBe(first);
});
it('pct 0 falls back to the lowest band when no min matches', () => {
const bands = bonheur.scoring.bands;
if (bands.every((b) => b.min > 0)) {
expect(findBand(bonheur, 0)).toBe(bands[bands.length - 1]);
} else {
expect(findBand(bonheur, 0)).toBe(
bands.find((b) => 0 >= b.min)
);
}
});
it('returns null for scoring-null instruments', () => {
expect(findBand(communication, 50)).toBeNull();
});
});
describe('optionLabel — free text, sentinels, scale options', () => {
it('free text {text} echoes the text', () => {
expect(optionLabel(folio, folio.sections[0], { text: 'Son rire le matin' })).toBe(
'Son rire le matin'
);
});
it('a bare "optout:…" string with an unknown id resolves to nothing (never an opt-out label)', () => {
const section = folio.sections[0];
expect(optionLabel(folio, section, 'optout:x')).toBe('');
// but a free-text answer whose content IS "optout:x" echoes the text
expect(optionLabel(folio, section, { text: 'optout:x' })).toBe('optout:x');
});
it('sentinels resolve through opt_out_options', () => {
const r = allItems(Q('desir_relationnel_comportemental'))[0];
const v = optValue('pas_activite_avec_partenaire');
expect(v).toBe('optout:pas_activite_avec_partenaire');
expect(typeof optionLabel(Q('desir_relationnel_comportemental'), r.section, v)).toBe('string');
});
it('scale options resolve by value', () => {
const scale = resolveScale(allItems(bonheur)[0].section, bonheur);
const o = scale.options[2];
expect(optionLabel(bonheur, allItems(bonheur)[0].section, o.value)).toBe(o.label);
});
});
describe('serializeAnswers — payload shape', () => {
it('emits {i, v, l} with labels, never item texts', () => {
const answers = { 1: 3, 2: 4 };
const out = serializeAnswers('bonheur_conjugal', { answers }, 'camille');
expect(out.qid).toBe('bonheur_conjugal');
expect(out.title).toBe(bonheur.title);
expect(out.respondent).toBe('camille');
expect(out.answered).toHaveLength(2);
expect(out.answered[0].i).toBe('1');
expect(out.answered[0].v).toBe(3);
expect(typeof out.answered[0].l).toBe('string');
expect(out.answered[0].l.length).toBeGreaterThan(0);
const joined = JSON.stringify(out);
allItems(bonheur).forEach((r) => expect(joined).not.toContain(r.item.text));
});
it('carries pct only for scored instruments', () => {
const answers = {};
for (let i = 1; i <= 25; i++) answers[String(i)] = 3;
expect(serializeAnswers('bonheur_conjugal', { answers }, 'moi').pct).toBe(75);
expect(serializeAnswers('communication', { answers: { 1: 5 } }, 'moi').pct).toBeUndefined();
});
it('free text round-trips: v is the {text} object, l echoes the text', () => {
const out = serializeAnswers(
'folio_vie_en_couple',
{ answers: { 1: { text: 'du texte' }, 2: { text: 'optout:x' } } },
'moi'
);
expect(out.answered[0].v).toEqual({ text: 'du texte' });
expect(out.answered[0].l).toBe('du texte');
expect(out.answered[1].l).toBe('optout:x');
});
it('returns null for an unknown qid', () => {
expect(serializeAnswers('nope', { answers: {} }, 'moi')).toBeNull();
});
});
describe('secret link — base64url round trip and failure paths', () => {
it('round-trips losslessly, accents included', () => {
const payload = {
qid: 'folio_vie_en_couple',
title: 'La vie en couple',
respondent: 'camille',
answered: [{ i: '1', v: { text: 'Père et mère, où êtes-vous ? Écœurés…' }, l: 'Écœurés…' }],
};
const encoded = answersToBase64url(payload);
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
expect(encoded).not.toMatch(/[+/=]/);
expect(decodePayload(encoded)).toEqual(payload);
});
it('fragment URL is #p= only, never a query parameter', () => {
const encoded = answersToBase64url({ qid: 'x', answered: [] });
expect(encoded).not.toContain('?');
});
it('decodePayload returns null on garbage, wrong shapes, and non-object JSON', () => {
expect(decodePayload('!!!')).toBeNull();
expect(decodePayload('')).toBeNull();
const notObject = answersToBase64url([1, 2]);
expect(decodePayload(notObject)).toBeNull();
const noQid = answersToBase64url({ answered: [] });
expect(decodePayload(noQid)).toBeNull();
const answeredNotArray = answersToBase64url({ qid: 'x', answered: 'nope' });
expect(decodePayload(answeredNotArray)).toBeNull();
// valid base64url of invalid JSON bytes
const badJson = Buffer.from('{nope').toString('base64url');
expect(decodePayload(badJson)).toBeNull();
});
});
describe('free text sizes and setAnswer', () => {
it('sizes derive from suggested_input', () => {
const items = allItems(folio);
const long = items.find((r) => r.item.suggested_input === 'texte long');
const three = items.find((r) => r.item.suggested_input === '3 champs libres');
const plain = items.find((r) => !r.item.suggested_input);
expect(freeTextSizes(long.item)).toEqual({ rows: 9, max: 5000 });
expect(freeTextSizes(three.item)).toEqual({ rows: 6, max: 600 });
expect(freeTextSizes(plain.item)).toEqual({ rows: 3, max: 600 });
});
it('setAnswer deletes whitespace-only text, stores objects otherwise', () => {
const st = { answers: {} };
setAnswer(st, '1', { text: ' ' });
expect(st.answers['1']).toBeUndefined();
setAnswer(st, '1', { text: 'ok' });
expect(st.answers['1']).toEqual({ text: 'ok' });
});
});
/**
* Clarté couple — stockage : contrats couple:v1 exacts (README du kit),
* quarantaine des enregistrements corrompus, session, export, suppression
* totale. Les réponses existantes du moteur vanilla doivent survivre.
*/
import {
LS,
allItems,
buildExportPayload,
deleteAllCoupleKeys,
exportFilename,
readRecord,
readSession,
storageAvailable,
writeRecord,
writeSession,
} from '@/components/couple/engine';
import { COUPLE_DATA } from '@/components/couple/data';
const key = (qid) => LS + 'q:' + qid;
beforeEach(() => {
window.localStorage.clear();
});
describe('record round trips', () => {
it('writes and reads back a plain likert record', () => {
const st = { answers: { 1: 4, 2: 0 }, pos: 2 };
writeRecord('bonheur_conjugal', st);
expect(readRecord('bonheur_conjugal')).toEqual(st);
});
it('legacy vanilla record shapes survive untouched (incl. collision keys)', () => {
const legacy = { answers: { 1: 3, 's1:1': 4, 2: 2 }, pos: 11 };
window.localStorage.setItem(key('pensees_negatives'), JSON.stringify(legacy));
expect(readRecord('pensees_negatives')).toEqual(legacy);
});
it('sentinels stay strings, free text stays {text}', () => {
const st = {
answers: { 1: 'optout:pas_activite_avec_partenaire', 2: { text: 'Son rire le matin' } },
pos: 2,
};
writeRecord('folio_vie_en_couple', st);
const back = readRecord('folio_vie_en_couple');
expect(back.answers['1']).toBe('optout:pas_activite_avec_partenaire');
expect(back.answers['2']).toEqual({ text: 'Son rire le matin' });
});
it('missing record reads as null', () => {
expect(readRecord('communication')).toBeNull();
});
});
describe('corrupt-record quarantine', () => {
it('a corrupted record is archived, not destroyed, and reads as null', () => {
window.localStorage.setItem(key('bonheur_conjugal'), 'garbage{');
expect(readRecord('bonheur_conjugal')).toBeNull();
const quarantine = Object.keys(window.localStorage).filter(
(k) => k.lastIndexOf(LS + 'corrupt:', 0) === 0
);
expect(quarantine).toHaveLength(1);
expect(quarantine[0]).toContain(LS + 'corrupt:bonheur_conjugal:');
expect(window.localStorage.getItem(quarantine[0])).toBe('garbage{');
});
it('records without an answers object are rejected (not quarantined)', () => {
window.localStorage.setItem(key('bonheur_conjugal'), JSON.stringify({ pos: 3 }));
expect(readRecord('bonheur_conjugal')).toBeNull();
expect(
Object.keys(window.localStorage).filter((k) => k.indexOf('corrupt:') >= 0)
).toHaveLength(0);
});
});
describe('session', () => {
it('defaults to respondent_label "moi" with an ISO created stamp', () => {
const s = readSession();
expect(s.respondent_label).toBe('moi');
expect(typeof s.created).toBe('string');
expect(new Date(s.created).toISOString()).toBe(s.created);
});
it('round-trips a written session', () => {
writeSession({ created: '2026-09-21T00:00:00.000Z', respondent_label: 'camille' });
expect(readSession()).toEqual({
created: '2026-09-21T00:00:00.000Z',
respondent_label: 'camille',
});
});
it('repairs a broken session record', () => {
window.localStorage.setItem(LS + 'session', 'not json');
const s = readSession();
expect(s.respondent_label).toBe('moi');
expect(typeof s.created).toBe('string');
});
});
describe('export payload', () => {
it('envelope carries schema, exported_at, respondent; labels, never item texts', () => {
writeSession({ created: '2026-09-21T00:00:00.000Z', respondent_label: 'camille' });
writeRecord('bonheur_conjugal', { answers: { 1: 4 }, pos: 1 });
const payload = buildExportPayload();
expect(payload.schema).toBe('couple:v1');
expect(new Date(payload.exported_at).toISOString()).toBe(payload.exported_at);
expect(payload.respondent).toBe('camille');
expect(payload.questionnaires).toHaveLength(8);
payload.questionnaires.forEach((q) => {
expect(typeof q.qid).toBe('string');
expect(Array.isArray(q.answered)).toBe(true);
});
const bonheurEntry = payload.questionnaires.find((q) => q.qid === 'bonheur_conjugal');
expect(bonheurEntry.answered).toHaveLength(1);
expect(bonheurEntry.answered[0].v).toBe(4);
expect(typeof bonheurEntry.answered[0].l).toBe('string');
// no verbatim item text in the file (labels only)
const serialized = JSON.stringify(payload);
allItems(COUPLE_DATA.questionnaires.find((x) => x.id === 'bonheur_conjugal')).forEach((r) => {
expect(serialized).not.toContain(r.item.text);
});
});
it('filename is clarte-couple-YYYY-MM-DD.json', () => {
expect(exportFilename()).toMatch(/^clarte-couple-\d{4}-\d{2}-\d{2}\.json$/);
});
});
describe('full delete — exactly couple:v1:*', () => {
it('clears answers and session, leaves unrelated keys alone', () => {
window.localStorage.setItem(
key('bonheur_conjugal'),
JSON.stringify({ answers: { 1: 4 }, pos: 1 })
);
window.localStorage.setItem(LS + 'session', JSON.stringify({ created: 'x', respondent_label: 'z' }));
window.localStorage.setItem('unrelated:key', 'keep-me');
deleteAllCoupleKeys();
const remaining = Object.keys(window.localStorage).filter(
(k) => k.lastIndexOf(LS, 0) === 0
);
expect(remaining).toHaveLength(0);
expect(window.localStorage.getItem('unrelated:key')).toBe('keep-me');
});
});
describe('storage probe', () => {
it('reports available in jsdom', () => {
expect(storageAvailable()).toBe(true);
});
});
/**
* Clarté couple — vues : comportement des composants face aux contrats du
* moteur d'origine (public/couple/app.js + sa suite de fumée). Machine à
* états du récapitulatif, curseur (commit/avance), clavier, lien de partage,
* accueil (export, suppression en deux appuis, divulgation persistante).
*/
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
if (typeof globalThis.TextEncoder === 'undefined') {
const util = require('util');
globalThis.TextEncoder = util.TextEncoder;
globalThis.TextDecoder = util.TextDecoder;
}
import React from 'react';
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import HomeView from '@/components/couple/HomeView';
import IntroView from '@/components/couple/IntroView';
import QuizView from '@/components/couple/QuizView';
import WorksheetView from '@/components/couple/WorksheetView';
import RecapView from '@/components/couple/RecapView';
import ClarteCouple from '@/components/couple/ClarteCouple';
import { COUPLE_DATA } from '@/components/couple/data';
import { strings } from '@/components/couple/strings';
import {
LS,
allItems,
answersToBase64url,
readRecord,
serializeAnswers,
writeRecord,
writeSession,
} from '@/components/couple/engine';
const Q = (id) => COUPLE_DATA.questionnaires.find((q) => q.id === id);
const NBSP = String.fromCharCode(160);
// RTL's default matcher leaves U+00A0 uncollapsed on the DOM side but the query
// string keeps it too : match on a collapsed normalizer + space-folded needle.
const getByTextNB = (s) =>
screen.getByText(s.split(NBSP).join(' '), { normalizer: (x) => x.replace(/\s+/g, ' ') });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const seed = (qid, st) => window.localStorage.setItem(LS + 'q:' + qid, JSON.stringify(st));
const fullAnswers = (total, value) => {
const answers = {};
for (let i = 1; i <= total; i++) answers[String(i)] = value;
return answers;
};
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
cleanup();
jest.useRealTimers();
});
/* ---------- accueil ---------- */
describe('HomeView', () => {
const setup = () => {
const goIntro = jest.fn();
const goQuiz = jest.fn();
const goRecap = jest.fn();
render(<HomeView goIntro={goIntro} goQuiz={goQuiz} goRecap={goRecap} trackWrite={jest.fn()} />);
return { goIntro, goQuiz, goRecap };
};
it('renders the 8 questionnaire cards with fresh meta (folio: écrits à compléter)', async () => {
setup();
await act(async () => {});
const folioCard = screen.getByText('La vie en couple').closest('button');
expect(folioCard.textContent).toContain('7 écrits à compléter');
const bonheurCard = screen.getByText('Test du bonheur conjugal').closest('button');
expect(bonheurCard.textContent).toContain('25 questions');
});
it('shows the persistent visibility disclosure with NBSP colon', async () => {
setup();
await act(async () => {});
const expected = strings.home.disclosure;
expect(getByTextNB(expected)).toBeInTheDocument();
// persistent: still there with answers present
seed('bonheur_conjugal', { answers: { 1: 4 }, pos: 1 });
cleanup();
setup();
await act(async () => {});
expect(getByTextNB(strings.home.disclosure)).toBeInTheDocument();
});
it('routes fresh cards to intro, partial to firstUnanswered, complete to recap', async () => {
let mocks = setup();
await act(async () => {});
screen.getByText('Test du bonheur conjugal').closest('button').click();
expect(mocks.goIntro).toHaveBeenCalledWith('bonheur_conjugal');
cleanup();
seed('bonheur_conjugal', { answers: { 1: 4, 2: 2, 3: 0 }, pos: 0 });
mocks = setup();
await act(async () => {});
const partialCard = screen.getByText('Test du bonheur conjugal').closest('button');
expect(partialCard.textContent).toContain('En cours · 3 sur 25');
partialCard.click();
expect(mocks.goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 3);
cleanup();
seed('bonheur_conjugal', { answers: fullAnswers(25, 3), pos: 25 });
mocks = setup();
await act(async () => {});
const doneCard = screen.getByText('Test du bonheur conjugal').closest('button');
expect(doneCard.textContent).toContain('Terminé · voir mes réponses');
doneCard.click();
expect(mocks.goRecap).toHaveBeenCalledWith('bonheur_conjugal');
});
it('persists the respondent label into couple:v1:session', async () => {
setup();
await act(async () => {});
const input = screen.getByLabelText('Je réponds en tant que');
fireEvent.change(input, { target: { value: 'camille' } });
expect(JSON.parse(window.localStorage.getItem(LS + 'session')).respondent_label).toBe('camille');
});
it('full delete swaps the row inline on tap 1 and clears couple:v1:* on tap 2', async () => {
seed('bonheur_conjugal', { answers: { 1: 4 }, pos: 1 });
writeSession({ created: 'x', respondent_label: 'z' });
window.localStorage.setItem('unrelated:key', 'keep-me');
setup();
await act(async () => {});
fireEvent.click(screen.getByText('Supprimer toutes mes réponses'));
expect(
screen.getByText(
(c, el) =>
el !== null &&
el.tagName === 'P' &&
c.indexOf('Effacer définitivement tout ce qui est enregistré sur cet appareil') >= 0
)
).toBeInTheDocument();
fireEvent.click(screen.getByText('Annuler'));
expect(screen.getByText('Supprimer toutes mes réponses')).toBeInTheDocument();
fireEvent.click(screen.getByText('Supprimer toutes mes réponses'));
fireEvent.click(screen.getByText('Supprimer définitivement'));
const remaining = Object.keys(window.localStorage).filter((k) => k.lastIndexOf(LS, 0) === 0);
expect(remaining).toHaveLength(0);
expect(window.localStorage.getItem('unrelated:key')).toBe('keep-me');
});
});
/* ---------- intro ---------- */
describe('IntroView', () => {
it('renders verbatim title, intro, instructions and the resume CTA', () => {
seed('bonheur_conjugal', { answers: { 1: 4, 2: 2, 3: 0 }, pos: 2 });
const goHome = jest.fn();
const goQuiz = jest.fn();
const goRecap = jest.fn();
render(<IntroView q={Q('bonheur_conjugal')} goHome={goHome} goQuiz={goQuiz} goRecap={goRecap} />);
expect(screen.getByRole('heading')).toHaveTextContent('Test du bonheur conjugal');
expect(screen.getByText(/25 questions · vous pouvez arrêter et reprendre/)).toBeInTheDocument();
expect(screen.getByText('Reprendre à la question 4')).toBeInTheDocument();
fireEvent.click(screen.getByText('Reprendre à la question 4'));
expect(goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 3);
});
it('complete instrument routes the CTA to the recap', () => {
seed('bonheur_conjugal', { answers: fullAnswers(25, 1), pos: 25 });
render(<IntroView q={Q('bonheur_conjugal')} goHome={jest.fn()} goQuiz={jest.fn()} goRecap={jest.fn()} />);
expect(screen.getByText('Voir mes réponses')).toBeInTheDocument();
});
it('folio renders Nom and Date header fields, stored in the record', () => {
const goQuiz = jest.fn();
render(<IntroView q={Q('folio_vie_en_couple')} goHome={jest.fn()} goQuiz={goQuiz} goRecap={jest.fn()} />);
expect(screen.getByText(/7 écrits · vous pouvez arrêter et reprendre/)).toBeInTheDocument();
expect(screen.getByLabelText('Nom')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Nom'), { target: { value: 'Camille' } });
const st = readRecord('folio_vie_en_couple');
expect(st.header.nom).toBe('Camille');
fireEvent.click(screen.getByText('Commencer'));
expect(goQuiz).toHaveBeenCalledWith('folio_vie_en_couple', 0);
});
});
/* ---------- question : pilules, opt-out, clavier ---------- */
describe('QuizView — likert', () => {
const setupBonheur = (pos = 0) => {
const goQuiz = jest.fn();
render(
<QuizView q={Q('bonheur_conjugal')} pos={pos} goQuiz={goQuiz} trackWrite={jest.fn()} />
);
return goQuiz;
};
it('renders the question with position header « Question 1 sur 25 » and no second line', () => {
setupBonheur();
expect(screen.getByText('Question 1 sur 25')).toBeInTheDocument();
expect(screen.getAllByText('Question 1 sur 25')).toHaveLength(1);
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('picking an option stores the numeric value and advances', () => {
const goQuiz = setupBonheur();
fireEvent.click(screen.getByRole('button', { name: /Neutre/ }));
expect(JSON.parse(window.localStorage.getItem(LS + 'q:bonheur_conjugal')).answers['1']).toBe(2);
expect(goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 1);
});
it('digit keys pick options; out-of-range digits are ignored', () => {
const goQuiz = setupBonheur();
fireEvent.keyDown(document, { key: '3' });
expect(goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 1);
expect(readRecord('bonheur_conjugal').answers['1']).toBe(2);
fireEvent.keyDown(document, { key: '9' });
expect(goQuiz).toHaveBeenCalledTimes(1);
});
it('ArrowRight/ArrowLeft/Enter navigate', () => {
const goQuiz = setupBonheur(2);
fireEvent.keyDown(document, { key: 'ArrowRight' });
expect(goQuiz).toHaveBeenLastCalledWith('bonheur_conjugal', 3);
fireEvent.keyDown(document, { key: 'ArrowLeft' });
expect(goQuiz).toHaveBeenLastCalledWith('bonheur_conjugal', 1);
fireEvent.keyDown(document, { key: 'Enter' });
expect(goQuiz).toHaveBeenLastCalledWith('bonheur_conjugal', 3);
});
it('opt-out sentinels store optout:<id> and are excluded from sums', () => {
const goQuiz = jest.fn();
render(
<QuizView
q={Q('desir_relationnel_comportemental')}
pos={0}
goQuiz={goQuiz}
trackWrite={jest.fn()}
/>
);
const optBtn = screen.getByRole('button', { name: /ne me propose pas/i });
expect(optBtn).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(optBtn);
expect(readRecord('desir_relationnel_comportemental').answers['1']).toBe(
'optout:partenaire_ne_propose_pas'
);
expect(goQuiz).toHaveBeenCalledWith('desir_relationnel_comportemental', 1);
});
});
/* ---------- question : curseur numeric_range ---------- */
describe('QuizView — numeric_range slider', () => {
const setupCommunication = (pos = 0) => {
const goQuiz = jest.fn();
render(<QuizView q={Q('communication')} pos={pos} goQuiz={goQuiz} trackWrite={jest.fn()} />);
return goQuiz;
};
const slider = () => screen.getByRole('slider');
it('renders 1..9 with verbatim anchors and defaults to min', () => {
setupCommunication();
expect(slider().min).toBe('1');
expect(slider().max).toBe('9');
expect(slider().value).toBe('1');
expect(screen.getByText('Jamais')).toBeInTheDocument();
expect(screen.getByText('Très souvent')).toBeInTheDocument();
});
it('input schedules a 250ms debounced NUMBER commit without advancing', async () => {
const goQuiz = setupCommunication();
act(() => {
fireEvent.input(slider(), { target: { value: '5' } });
});
expect(goQuiz).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(300);
});
expect(readRecord('communication').answers['1']).toBe(5);
expect(goQuiz).not.toHaveBeenCalled();
});
it('native change (pointer release) commits and advances once', () => {
const goQuiz = setupCommunication();
fireEvent.input(slider(), { target: { value: '7' } });
fireEvent.change(slider());
expect(readRecord('communication').answers['1']).toBe(7);
expect(goQuiz).toHaveBeenCalledWith('communication', 1);
});
it('arrow-key adjustment commits without advancing', () => {
const goQuiz = setupCommunication();
fireEvent.input(slider(), { target: { value: '2' } });
fireEvent.keyDown(slider(), { key: 'ArrowRight' });
fireEvent.change(slider());
expect(readRecord('communication').answers['1']).toBe(2);
expect(goQuiz).not.toHaveBeenCalled();
});
it('Enter on the slider flushes and advances', () => {
const goQuiz = setupCommunication();
fireEvent.input(slider(), { target: { value: '6' } });
fireEvent.keyDown(slider(), { key: 'Enter' });
expect(readRecord('communication').answers['1']).toBe(6);
expect(goQuiz).toHaveBeenCalledWith('communication', 1);
});
it('digit keys set the value and commit immediately, without advancing', () => {
const goQuiz = setupCommunication();
fireEvent.keyDown(document, { key: '5' });
expect(slider().value).toBe('5');
expect(readRecord('communication').answers['1']).toBe(5);
expect(goQuiz).not.toHaveBeenCalled();
});
});
/* ---------- feuille de travail ---------- */
describe('WorksheetView — folio', () => {
it('renders one section per screen with sizing from suggested_input', () => {
render(<WorksheetView q={Q('folio_vie_en_couple')} pos={0} goQuiz={jest.fn()} trackWrite={jest.fn()} />);
expect(screen.getByText('Question 1 sur 7')).toBeInTheDocument();
const tas = screen.getAllByRole('textbox');
expect(tas).toHaveLength(2);
tas.forEach((ta) => {
expect(ta.rows).toBe(3);
expect(ta.maxLength).toBe(600);
});
expect(tas[0].getAttribute('aria-label')).toBe(
allItems(Q('folio_vie_en_couple'))[0].item.text
);
});
it('stores free text as {text}, empty after trim deletes the key', async () => {
render(<WorksheetView q={Q('folio_vie_en_couple')} pos={0} goQuiz={jest.fn()} trackWrite={jest.fn()} />);
const ta = screen.getAllByRole('textbox')[0];
fireEvent.change(ta, { target: { value: 'Son rire le matin' } });
act(() => {
jest.advanceTimersByTime(400);
});
expect(readRecord('folio_vie_en_couple').answers['1']).toEqual({ text: 'Son rire le matin' });
fireEvent.change(ta, { target: { value: ' ' } });
act(() => {
jest.advanceTimersByTime(400);
});
expect(readRecord('folio_vie_en_couple').answers['1']).toBeUndefined();
});
it('blur flushes before navigation (flush-on-nav parity)', () => {
const goQuiz = jest.fn();
render(<WorksheetView q={Q('folio_vie_en_couple')} pos={0} goQuiz={goQuiz} trackWrite={jest.fn()} />);
const ta = screen.getAllByRole('textbox')[1];
fireEvent.change(ta, { target: { value: 'Ses silences pesants' } });
fireEvent.blur(ta);
expect(readRecord('folio_vie_en_couple').answers['2']).toEqual({ text: 'Ses silences pesants' });
fireEvent.click(screen.getByText('Suivant'));
expect(goQuiz).toHaveBeenCalledWith('folio_vie_en_couple', 2);
});
it('long text sizing: texte long → 9 rows / 5000', () => {
render(<WorksheetView q={Q('folio_vie_en_couple')} pos={6} goQuiz={jest.fn()} trackWrite={jest.fn()} />);
const ta = screen.getAllByRole('textbox');
expect(ta).toHaveLength(3);
expect(ta[2].rows).toBe(9);
expect(ta[2].maxLength).toBe(5000);
expect(screen.getByText('Voir le récapitulatif')).toBeInTheDocument();
});
});
/* ---------- récapitulatif : machine à états ---------- */
describe('RecapView state machine', () => {
const setup = (qid, record) => {
if (record) seed(qid, record);
const goHome = jest.fn();
const goQuiz = jest.fn();
render(<RecapView q={Q(qid)} goHome={goHome} goQuiz={goQuiz} trackWrite={jest.fn()} />);
return { goHome, goQuiz };
};
it('n = 0 : no details, CTA « Commencer », Enter does nothing', () => {
const { goQuiz } = setup('bonheur_conjugal', { answers: {}, pos: 0 });
expect(screen.getByText('0 réponses sur 25')).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeNull();
expect(screen.getByText('Aucune réponse pour l’instant. Le questionnaire sera là quand vous serez prêt·e.')).toBeInTheDocument();
expect(screen.getByText('Commencer')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Enter' });
expect(goQuiz).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Commencer'));
expect(goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 0);
});
it('partial : provisional score line, NO percent, NO band, open details, Enter resumes', () => {
const { goQuiz } = setup('bonheur_conjugal', { answers: { 1: 4 }, pos: 1 });
expect(screen.getByText(/Avancement : 1 réponse sur 25/)).toBeInTheDocument();
expect(screen.queryByText(/^\d+ %$/)).toBeNull();
expect(screen.queryByText(/test d’origine/)).toBeNull();
expect(screen.getByText(/Revoir mes réponses \(1 sur 25\)/)).toBeInTheDocument();
const details = screen.getByText(/Revoir mes réponses/).closest('details');
expect(details.open).toBe(true);
expect(screen.getByText('Il reste 24 questions sans réponse.')).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(1);
fireEvent.keyDown(document, { key: 'Enter' });
expect(goQuiz).toHaveBeenCalledWith('bonheur_conjugal', 1);
expect(screen.getByText('Reprendre')).toBeInTheDocument();
});
it('complete : percent with NBSP, first-min-match band, « Recommencer », Enter never resets', () => {
setup('bonheur_conjugal', { answers: fullAnswers(25, 3), pos: 25 });
const NB = ' ';
expect(screen.getByText('75' + NB + '%')).toBeInTheDocument();
expect(screen.getByText('Score complet.')).toBeInTheDocument();
const bandLine = screen.getByText(/Lecture du support d’origine/).textContent;
const bands = Q('bonheur_conjugal').scoring.bands;
const first = bands.find((b) => 75 >= b.min);
expect(bandLine.endsWith(first.label + ' »')).toBe(true);
expect(screen.getByText('Recommencer')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Enter' });
expect(Object.keys(JSON.parse(window.localStorage.getItem(LS + 'q:bonheur_conjugal')).answers)).toHaveLength(25);
// confirm dialog guards the reset
const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
fireEvent.click(screen.getByText('Recommencer'));
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Recommencer ce questionnaire'));
expect(readRecord('bonheur_conjugal')).toEqual({ answers: {}, pos: 0 });
confirmSpy.mockRestore();
});
it('complete score at 50 falls into the first band whose min is reached', () => {
setup('bonheur_conjugal', { answers: fullAnswers(25, 2), pos: 25 });
const bands = Q('bonheur_conjugal').scoring.bands;
const expected = bands.find((b) => 50 >= b.min);
expect(screen.getByText(/Lecture du support d’origine/).textContent.endsWith(expected.label + ' »')).toBe(true);
});
it('scoring-null instrument : no score, count-bearing header, hors-échelle kept in the table', () => {
setup('desir_relationnel_comportemental', {
answers: { 1: 'optout:pas_activite_avec_partenaire', 2: 0 },
pos: 2,
});
expect(screen.getByText('2 réponses sur 33')).toBeInTheDocument();
expect(screen.queryByText(/Score provisoire/)).toBeNull();
expect(screen.queryByText(/^\d+ %$/)).toBeNull();
expect(screen.getByText(/Ce questionnaire ne se calcule pas/)).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(2);
});
it('share panel : #p= fragment URL, warning, copy feedback', async () => {
const clipboard = { writeText: jest.fn(() => Promise.resolve()) };
Object.defineProperty(window.navigator, 'clipboard', {
value: clipboard,
configurable: true,
});
setup('bonheur_conjugal', { answers: fullAnswers(25, 3), pos: 25 });
fireEvent.click(screen.getByText('Créer un lien de partage'));
expect(screen.getByText(/encodées mais non chiffrées/)).toBeInTheDocument();
const input = screen.getByLabelText('Lien de partage');
expect(input.readOnly).toBe(true);
const url = input.value;
expect(url).toContain('#p=');
expect(url).not.toContain('?');
const frag = url.split('#p=')[1];
const expected = serializeAnswers(
'bonheur_conjugal',
{ answers: fullAnswers(25, 3) },
'moi'
);
expect(frag).toBe(answersToBase64url(expected));
fireEvent.click(screen.getByText('Copier le lien'));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(clipboard.writeText).toHaveBeenCalledWith(url);
expect(screen.getByText('Lien copié')).toBeInTheDocument();
});
});
/* ---------- lien de partage : vue destinataire et erreurs ---------- */
describe('ClarteCouple — hash dispatch', () => {
beforeEach(() => {
jest.useRealTimers();
});
it('valid #p= payload renders the read-only shared view, writing nothing to storage', async () => {
const payload = serializeAnswers(
'bonheur_conjugal',
{ answers: fullAnswers(25, 3) },
'camille'
);
window.history.replaceState(null, '', '/#p=' + answersToBase64url(payload));
render(<ClarteCouple />);
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent(
'Réponses partagées : Test du bonheur conjugal'
);
expect(screen.getByText(/Partagé par camille/)).toBeInTheDocument();
expect(screen.getByText(/Vue en lecture seule/)).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(25);
expect(screen.getByText(/rien n’est envoyé ni stocké ici/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Commencer|Reprendre|Recommencer/ })).toBeNull();
const stored = Object.keys(window.localStorage).filter((k) => k.lastIndexOf(LS, 0) === 0);
expect(stored).toHaveLength(0);
window.history.replaceState(null, '', '/');
});
it('unknown qid in a well-formed payload falls back to the error view', async () => {
const payload = { qid: 'nope', answered: [] };
window.history.replaceState(null, '', '/#p=' + answersToBase64url(payload));
render(<ClarteCouple />);
expect(await screen.findByText('Lien incomplet ou altéré. Demandez à la personne qui l’a envoyé d’en créer un nouveau.')).toBeInTheDocument();
window.history.replaceState(null, '', '/');
});
it('garbled fragment falls back to the error view', async () => {
window.history.replaceState(null, '', '/#p=%%%not-base64%%%');
render(<ClarteCouple />);
expect(await screen.findByText(/Lien incomplet ou altéré/)).toBeInTheDocument();
window.history.replaceState(null, '', '/');
});
it('no hash boots to home with 8 cards', async () => {
render(<ClarteCouple />);
expect(await screen.findByText('Espace couple')).toBeInTheDocument();
expect(screen.getByText('Test du bonheur conjugal')).toBeInTheDocument();
});
});
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