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/clarte-tokens.css';
import '@/styles/globals.css';
import '@/styles/main.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 : 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",
"language": "fr",
"generated_at": "2026-09-21",
......@@ -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.",
"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 : 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 = [
];
// ============================================
// 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
// ============================================
const state = {
currentSection: 'home',
responses: {},
results: null
results: null,
lastMilestone: null
};
// ============================================
......@@ -172,7 +258,8 @@ function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
responses: state.responses,
currentSection: state.currentSection
currentSection: state.currentSection,
lastMilestone: state.lastMilestone
}));
} catch (e) { /* storage unavailable (private mode, quota): skip */ }
}
......@@ -195,6 +282,9 @@ function restoreState() {
if (saved.responses && typeof saved.responses === 'object') {
Object.assign(state.responses, saved.responses);
}
if (typeof saved.lastMilestone === 'string') {
state.lastMilestone = saved.lastMilestone;
}
if (sections.includes(saved.currentSection)) {
return saved.currentSection;
}
......@@ -219,6 +309,7 @@ function showSection(sectionId) {
target.classList.add('active');
state.currentSection = sectionId;
updateProgress();
updateAnswerProgress();
window.scrollTo(0, 0);
saveState();
}
......@@ -381,6 +472,7 @@ function attachOptionHandlers(container) {
input.addEventListener('change', (e) => {
state.responses[e.target.name] = parseInt(e.target.value);
saveState();
updateAnswerProgress();
const question = e.target.closest('.question');
if (question) question.classList.add('answered');
});
......@@ -881,6 +973,7 @@ function init() {
document.getElementById('restart').addEventListener('click', () => {
state.responses = {};
state.results = null;
state.lastMilestone = null;
clearSavedState();
renderASRS();
renderDIVA();
......
......@@ -51,6 +51,12 @@
<div class="step-dot"></div>
</div>
<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>
</nav>
......
......@@ -264,6 +264,44 @@ header .subtitle {
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
============================================ */
......
/* 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. */
var CACHE = 'clarte-v1';
var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest',
'./fonts/AtkinsonHyperlegible-Regular.woff2',
......
<!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 = [
];
// ============================================
// 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
// ============================================
const state = {
currentSection: 'home',
responses: {},
results: null
results: null,
lastMilestone: null
};
// ============================================
......@@ -172,7 +258,8 @@ function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
responses: state.responses,
currentSection: state.currentSection
currentSection: state.currentSection,
lastMilestone: state.lastMilestone
}));
} catch (e) { /* storage unavailable (private mode, quota): skip */ }
}
......@@ -195,6 +282,9 @@ function restoreState() {
if (saved.responses && typeof saved.responses === 'object') {
Object.assign(state.responses, saved.responses);
}
if (typeof saved.lastMilestone === 'string') {
state.lastMilestone = saved.lastMilestone;
}
if (sections.includes(saved.currentSection)) {
return saved.currentSection;
}
......@@ -219,6 +309,7 @@ function showSection(sectionId) {
target.classList.add('active');
state.currentSection = sectionId;
updateProgress();
updateAnswerProgress();
window.scrollTo(0, 0);
saveState();
}
......@@ -381,6 +472,7 @@ function attachOptionHandlers(container) {
input.addEventListener('change', (e) => {
state.responses[e.target.name] = parseInt(e.target.value);
saveState();
updateAnswerProgress();
const question = e.target.closest('.question');
if (question) question.classList.add('answered');
});
......@@ -881,6 +973,7 @@ function init() {
document.getElementById('restart').addEventListener('click', () => {
state.responses = {};
state.results = null;
state.lastMilestone = null;
clearSavedState();
renderASRS();
renderDIVA();
......
......@@ -51,6 +51,12 @@
<div class="step-dot"></div>
</div>
<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>
</nav>
......
......@@ -264,6 +264,44 @@ header .subtitle {
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
============================================ */
......
/* 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. */
var CACHE = 'clarte-v1';
var CACHE = 'clarte-v2';
var ASSETS = ['./', './index.html', './style.css', './app.js', './jspdf.min.js',
'./manifest.webmanifest',
'./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é 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 — 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);
});
});
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