Commit 4d906f13 by PLN (Algolia)

Redesign the couple subsite per the impeccable spec

parent eb7523c9
/* Clarté couple — moteur de questionnaires, 100% local.
/* Clarté couple : moteur de questionnaires, 100% local.
Les réponses sont sauvées à chaque sélection dans localStorage :
fermer, recharger ou couper le réseau ne perd jamais un effort.
Score partiel à chaque instant : on note ce qui est répondu. */
......@@ -15,15 +15,51 @@
if (text != null) n.textContent = text;
return n;
};
/* ---------- stockage : sonde de démarrage, mise en quarantaine ----------
Navigation privée ou stockage désactivé : on bascule sur une mémoire
volatile et on prévient à chaque écran, plutôt que de perdre en silence. */
var storageFailed = false;
var LSB = null;
try {
localStorage.setItem(LS + 'probe', '1');
localStorage.removeItem(LS + 'probe');
LSB = localStorage;
} catch (e) {
storageFailed = true;
var backing = {};
LSB = {
getItem: function (k) { return Object.prototype.hasOwnProperty.call(backing, k) ? backing[k] : null; },
setItem: function (k, v) { backing[k] = String(v); },
removeItem: function (k) { delete backing[k]; },
key: function (i) { return Object.keys(backing)[i] !== undefined ? Object.keys(backing)[i] : null; },
get length() { return Object.keys(backing).length; }
};
}
var store = {
get: function (qid) {
var raw = null;
try { raw = LSB.getItem(LS + 'q:' + qid); } catch (e) { return null; }
if (raw == null) return null;
var v = null;
try { v = JSON.parse(localStorage.getItem(LS + 'q:' + qid)); } catch (e) { return null; }
try { v = JSON.parse(raw); } catch (e) {
// un enregistrement corrompu est archivé, pas écrasé par la prochaine écriture
try { LSB.setItem(LS + 'corrupt:' + qid + ':' + Date.now(), raw); } catch (e2) {}
return null;
}
if (!v || typeof v !== 'object' || !v.answers || typeof v.answers !== 'object') return null;
return v;
},
set: function (qid, st) { try { localStorage.setItem(LS + 'q:' + qid, JSON.stringify(st)); } catch (e) {} }
set: function (qid, st) {
try { LSB.setItem(LS + 'q:' + qid, JSON.stringify(st)); }
catch (e) { storageFailed = true; }
}
};
function maybeBanner() {
if (!storageFailed) return;
app.appendChild(el('p', 'banner',
'Impossible d’enregistrer sur cet appareil (navigation privée ?). Vos réponses seront perdues à la fermeture de la page.'));
}
/* ---------- session : qui répond ----------
Base d'identité locale, posée pour le « à deux » et les futurs
......@@ -31,25 +67,28 @@
`index` reste la clé de stockage, `source_label` n'est que l'affichage. */
function session() {
var s = null;
try { s = JSON.parse(localStorage.getItem(LS + 'session')); } catch (e) { s = null; }
try { s = JSON.parse(LSB.getItem(LS + 'session')); } catch (e) { s = null; }
if (!s || typeof s !== 'object') s = { created: new Date().toISOString(), respondent_label: 'moi' };
if (typeof s.created !== 'string') s.created = new Date().toISOString();
if (typeof s.respondent_label !== 'string' || !s.respondent_label) s.respondent_label = 'moi';
return s;
}
function saveSession(s) { try { localStorage.setItem(LS + 'session', JSON.stringify(s)); } catch (e) {} }
function saveSession(s) {
try { LSB.setItem(LS + 'session', JSON.stringify(s)); }
catch (e) { storageFailed = true; }
}
/* Options hors échelle : valeur sentinelle distincte de « non répondu »,
jamais comptée dans les totaux (les scores n'additionnent que des nombres). */
var OPT = 'optout:';
function optValue(k) { return OPT + k; }
/* ---------- sérialisation (pour de futurs liens de partage) ----------
Modules purs, sans UI ni réseau : à quoi ressemblerait ce qu'on
raconterait de ses réponses. Rien n'est expédié aujourd'hui.
TODO(partage): ce contenu est sensible (sexualité, couple). Tout
lien de partage devra être CHIFFRÉ avant de quitter l'appareil —
l'encryption se branche ici, entre serializeAnswers et l'URL. */
/* ---------- sérialisation et lien de partage ----------
Lien encodé en base64url, non chiffré, avertissement affiché à la création
(décision enregistrée dans context.md §9, 2026-09-21). Le payload vit dans
le fragment de l'URL (#p=), jamais en paramètre de requête : il n'atteint
jamais les journaux du serveur. Si un jour on chiffre : WebCrypto AES-GCM
avec clé en #k=, branché ici. */
function computeScore(q, st) {
var sc = q.scoring || {};
if (sc.method !== 'sum_all_items') return null;
......@@ -62,13 +101,16 @@
return maxSum > 0 ? { n: n, pct: Math.round((sum / maxSum) * 100) } : null;
}
function optionLabel(q, section, v) {
if (v && typeof v === 'object' && typeof v.text === 'string') return v.text;
if (typeof v === 'string' && v.lastIndexOf(OPT, 0) === 0) {
var o = (DATA.opt_out_options || {})[v.slice(OPT.length)];
return o ? o.label : '';
}
var s = resolveScale(section, q);
if (!s) return '';
if (s.options) {
for (var i = 0; i < s.options.length; i++) if (s.options[i].value === v) return s.options[i].label;
}
return '';
}
function serializeAnswers(qid) {
......@@ -81,7 +123,10 @@
allItems(q).forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined) return;
out.answered.push({ i: r.key, v: v, l: optionLabel(q, r.section, v) });
var entry = { i: r.key, v: v };
var l = optionLabel(q, r.section, v);
if (l) entry.l = l;
out.answered.push(entry);
});
var sc = computeScore(q, st);
if (sc) out.pct = sc.pct;
......@@ -94,12 +139,28 @@
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function utf8Decode(bytes) {
var pct = [];
for (var i = 0; i < bytes.length; i++) pct.push('%' + ('00' + bytes[i].toString(16)).slice(-2));
return decodeURIComponent(pct.join(''));
}
function decodePayload(s) {
try {
var b64 = s.replace(/-/g, '+').replace(/_/g, '/');
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var obj = JSON.parse(utf8Decode(bytes));
if (!obj || typeof obj !== 'object' || typeof obj.qid !== 'string' || !Array.isArray(obj.answered)) return null;
return obj;
} catch (e) { return null; }
}
function allItems(q) {
// Clé de stockage = item.index (contrat). Deux instruments réutilisent
// la numérotation d'origine section par section (pensees_negatives,
// inhibiteurs_excitateurs) : là, un index nu écraserait une réponse
// d'une autre section — les répétitions sont préfixées `s<si>:`.
// d'une autre section : les répétitions sont préfixées `s<si>:`.
var out = [], seen = {};
(q.sections || []).forEach(function (s, si) {
(s.items || []).forEach(function (it) {
......@@ -113,13 +174,20 @@
function resolveScale(section, q) {
var key = (section && section.scale) || q.default_scale;
var s = typeof key === 'string' ? SCALES[key] : (key || null);
return s && s.options && s.options.length ? s : null;
return s && s.type ? s : null;
}
function answeredCount(q, st) {
var n = 0;
allItems(q).forEach(function (r) { if (st.answers[r.key] !== undefined) n++; });
return n;
}
function firstUnanswered(q, st) {
var items = allItems(q);
for (var i = 0; i < items.length; i++) {
if (st.answers[items[i].key] === undefined) return i;
}
return items.length;
}
/* ---------- clavier ----------
keymap est posé par la vue active (question, récapitulatif) et remis
......@@ -148,30 +216,90 @@
}
function hideProgress() { prog.hidden = true; prog.innerHTML = ''; }
/* ---------- export / suppression (accueil) ---------- */
function exportAll() {
var payload = {
schema: 'couple:v1',
exported_at: new Date().toISOString(),
respondent: session().respondent_label,
questionnaires: DATA.questionnaires.map(function (q) { return serializeAnswers(q.id); })
};
try {
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = el('a');
a.href = url;
a.download = 'clarte-couple-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
} catch (e) {}
}
function renderFootLinks(container) {
container.innerHTML = '';
var exportBtn = el('button', 'linkbtn', 'Exporter toutes mes réponses');
exportBtn.addEventListener('click', exportAll);
var delBtn = el('button', 'linkbtn danger', 'Supprimer toutes mes réponses');
delBtn.addEventListener('click', function () { renderDeleteConfirm(container); });
container.appendChild(exportBtn);
container.appendChild(document.createTextNode(' · '));
container.appendChild(delBtn);
}
function renderDeleteConfirm(container) {
container.innerHTML = '';
container.appendChild(document.createTextNode('Effacer définitivement tout ce qui est enregistré sur cet appareil ?'));
container.appendChild(document.createTextNode(' '));
var yes = el('button', 'btn', 'Supprimer définitivement');
var no = el('button', 'linkbtn', 'Annuler');
yes.addEventListener('click', function () {
var keys = [];
for (var i = 0; i < LSB.length; i++) {
var k = LSB.key(i);
if (k && k.lastIndexOf(LS, 0) === 0) keys.push(k);
}
keys.forEach(function (k) { try { LSB.removeItem(k); } catch (e) {} });
home();
});
no.addEventListener('click', function () { renderFootLinks(container); });
container.appendChild(yes);
container.appendChild(document.createTextNode(' '));
container.appendChild(no);
}
/* ---------- views ---------- */
function home() {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, 'Espace couple'));
app.appendChild(el('p', 'muted',
'Des questionnaires pour mettre des mots, à deux, sur ce qui se passe entre vous. ' +
'Répondez librement, à votre rythme — tout reste sur cet appareil, rien n\'est envoyé nulle part.'));
'Répondez librement, à votre rythme. Tout reste sur cet appareil, rien n’est envoyé nulle part.'));
var list = el('ul', 'cardlist');
DATA.questionnaires.forEach(function (q) {
var total = allItems(q).length;
var st = store.get(q.id);
var n = st ? answeredCount(q, st) : 0;
var li = el('li');
var b = el('button', 'cardbtn');
var left = el('span', null, q.title);
var right = el('span', 'meta', total + ' questions');
if (st) {
var n = answeredCount(q, st);
if (n >= total) { right = el('span', 'state', 'Terminé — voir mes réponses'); }
else if (n > 0) { right = el('span', 'state', 'En cours — ' + n + ' sur ' + total); }
b.appendChild(el('span', null, q.title));
if (st && n >= total) {
b.appendChild(el('span', 'state', 'Terminé · voir mes réponses'));
} else if (n > 0) {
b.appendChild(el('span', 'state', 'En cours · ' + n + ' sur ' + total));
} else {
b.appendChild(el('span', 'meta',
q.type === 'worksheet' ? total + ' écrits à compléter' : total + ' questions'));
}
b.appendChild(left); b.appendChild(right);
b.addEventListener('click', function () { intro(q); });
b.addEventListener('click', function () {
var s2 = store.get(q.id);
var n2 = s2 ? answeredCount(q, s2) : 0;
if (!s2 || n2 === 0) intro(q);
else if (n2 >= total) recap(q);
else quiz(q, firstUnanswered(q, s2));
});
li.appendChild(b); list.appendChild(li);
});
app.appendChild(list);
......@@ -191,48 +319,139 @@
w.appendChild(lbl); w.appendChild(inp);
app.appendChild(w);
// Visibilité partagée : à dire avant la première séance, ça change la façon de répondre.
app.appendChild(el('p', 'muted',
'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, et souvent le début de quelque chose de meilleur.'));
var foot = el('footer', 'pagefoot', 'Vos réponses ne quittent jamais cet appareil.');
'Si vous partagez cet appareil, l’autre personne pourra lire vos réponses. C’est voulu : les questionnaires servent aussi à en parler à deux.'));
app.appendChild(el('p', 'muted',
'Ces questionnaires ne sont pas un jugement ni un diagnostic : ils servent de support à la discussion. ' +
'Un score bas n’est pas un échec, c’est une information.'));
var foot = el('footer', 'pagefoot');
foot.appendChild(el('p', null, 'Aucun compte, aucune connexion : vos réponses restent sur cet appareil.'));
var links = el('p', 'footlinks');
renderFootLinks(links);
foot.appendChild(links);
app.appendChild(foot);
}
function renderHeaderFields(q, st) {
var wrap = el('div', 'headfields');
var stored = (st && st.header) || {};
var target = st || { answers: {}, pos: 0, header: {} };
(q.header_fields || []).forEach(function (f) {
var lbl = el('label', 'muted', f.label);
var id = 'hf-' + f.id;
lbl.setAttribute('for', id);
var inp = el('input');
inp.type = f.type === 'date' ? 'date' : 'text';
inp.id = id;
inp.value = typeof stored[f.id] === 'string' ? stored[f.id] : '';
inp.addEventListener('input', function () {
if (!target.header) target.header = {};
target.header[f.id] = inp.value;
store.set(q.id, target);
});
wrap.appendChild(lbl); wrap.appendChild(inp);
});
wrap.appendChild(el('p', 'muted', 'Votre nom et la date restent sur cet appareil.'));
return wrap;
}
function intro(q) {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, q.title));
if (q.intro) app.appendChild(el('p', null, q.intro));
if (q.instructions) app.appendChild(el('p', null, q.instructions));
var total = allItems(q).length;
app.appendChild(el('p', 'muted', total + ' questions · quelques minutes · vous pouvez arrêter et reprendre quand vous voulez.'));
var st = store.get(q.id);
var n = st ? answeredCount(q, st) : 0;
app.appendChild(el('p', 'muted',
(q.type === 'worksheet' ? total + ' écrits' : total + ' questions') +
' · vous pouvez arrêter et reprendre quand vous voulez.'));
if (q.type === 'worksheet' && q.header_fields && q.header_fields.length) {
app.appendChild(renderHeaderFields(q, st));
}
var nav = el('div', 'nav');
var back = el('button', null, 'Retour');
var back = el('button', 'btn', 'Retour');
back.addEventListener('click', home);
var st = store.get(q.id);
var start = el('button', 'cta', st ? 'Reprendre' : 'Commencer');
start.addEventListener('click', function () { quiz(q, st && st.pos ? st.pos : 0); });
var fu = st ? firstUnanswered(q, st) : 0;
var start = el('button', 'btn btn--primary',
st == null ? 'Commencer' : (n === total ? 'Voir mes réponses' : 'Reprendre à la question ' + (fu + 1)));
start.addEventListener('click', function () {
var s2 = store.get(q.id);
var n2 = s2 ? answeredCount(q, s2) : 0;
if (!s2 || n2 === 0) { quiz(q, 0); return; }
if (n2 >= total) { recap(q); return; }
quiz(q, firstUnanswered(q, s2));
});
nav.appendChild(back); nav.appendChild(start);
app.appendChild(nav);
}
/* ---------- champ libre (feuille de travail) ---------- */
function freeTextSizes(it) {
var si = it.suggested_input;
return {
rows: si === 'texte long' ? 9 : (si === '3 champs libres' ? 6 : 3),
max: si === 'texte long' ? 5000 : 600
};
}
function makeFreeText(q, st, r) {
var it = r.item;
var sizes = freeTextSizes(it);
var wrap = el('div', 'freeblock');
wrap.appendChild(el('p', 'muted',
'Ici, la réponse s’écrit : prenez le temps qui vous faut, quelques mots suffisent.'));
var ta = el('textarea', 'freetext');
ta.setAttribute('aria-label', it.text);
ta.rows = sizes.rows;
ta.maxLength = sizes.max;
var v = st.answers[r.key];
ta.value = v && typeof v === 'object' && typeof v.text === 'string' ? v.text : '';
var timer = null;
var save = function () {
timer = null;
if (ta.value.trim() === '') delete st.answers[r.key];
else st.answers[r.key] = { text: ta.value };
store.set(q.id, st);
};
ta.addEventListener('input', function () {
if (timer) clearTimeout(timer);
timer = setTimeout(save, 300);
});
ta.addEventListener('blur', function () {
if (timer) { clearTimeout(timer); save(); }
});
wrap.appendChild(ta);
return {
wrap: wrap,
ta: ta,
flush: function () { if (timer) { clearTimeout(timer); save(); } }
};
}
function quiz(q, pos) {
var items = allItems(q);
var total = items.length;
pos = Math.min(Math.max(0, Math.floor(+pos) || 0), total); // pos == total → récapitulatif
if (q.type === 'worksheet') { quizWorksheet(q, pos, items, total); return; }
var st = store.get(q.id) || { answers: {}, pos: 0 };
pos = Math.max(0, Math.min(pos, total)); // pos == total → récapitulatif
st.pos = pos; store.set(q.id, st);
if (pos >= total) { recap(q); return; }
var cur = items[pos];
var scale = resolveScale(cur.section, q);
var answered = answeredCount(q, st);
showProgress(answered + ' sur ' + total + ' répondues', answered / total);
showProgress('Question ' + (pos + 1) + ' sur ' + total, (pos + 1) / total);
app.innerHTML = '';
app.appendChild(el('p', 'muted', 'Question ' + (pos + 1) + ' sur ' + total));
maybeBanner();
if (pos === 0 || items[pos - 1].section !== cur.section) {
if (cur.section.title) app.appendChild(el('p', 'muted section-label', cur.section.title));
if (cur.section.section_instructions) app.appendChild(el('p', 'muted', cur.section.section_instructions));
}
var t = el('p', 'item-text', cur.item.text);
t.setAttribute('tabindex', '-1');
app.appendChild(t);
......@@ -247,7 +466,63 @@
quiz(q, pos + 1);
};
};
if (scale) {
var freeFlush = null;
var type = scale ? scale.type : null;
if (type === 'free_text') {
var ft = makeFreeText(q, st, cur);
freeFlush = ft.flush;
app.appendChild(ft.wrap);
// pas de focus automatique : le clavier mobile couvrirait la question
} else if (type === 'numeric_range' && scale.min != null && scale.max != null) {
var rangeWrap = el('div', 'rangeblock');
var out = el('output', 'range-out');
var slider = el('input');
slider.type = 'range';
slider.id = 'range-input';
slider.min = scale.min;
slider.max = scale.max;
slider.step = scale.step || 1;
var sv = st.answers[cur.key];
slider.value = typeof sv === 'number' ? sv : scale.min;
out.textContent = String(slider.value);
out.setAttribute('for', 'range-input');
rangeWrap.appendChild(out);
rangeWrap.appendChild(slider);
var anchors = el('div', 'range-anchors');
anchors.appendChild(el('span', null, (scale.anchors || {})[String(scale.min)] || ''));
anchors.appendChild(el('span', null, (scale.anchors || {})[String(scale.max)] || ''));
rangeWrap.appendChild(anchors);
app.appendChild(rangeWrap);
var deb = null;
var commit = function () {
var nv = slider.valueAsNumber;
if (typeof nv === 'number' && !isNaN(nv)) st.answers[cur.key] = nv;
store.set(q.id, st);
out.textContent = slider.value;
};
slider.addEventListener('input', function () {
out.textContent = slider.value;
if (deb) clearTimeout(deb);
deb = setTimeout(commit, 250);
});
var viaKeyboard = false;
slider.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
if (deb) { clearTimeout(deb); commit(); }
goNext();
return;
}
viaKeyboard = true; // les flèches ajustent la valeur sans avancer
});
slider.addEventListener('change', function () {
if (deb) clearTimeout(deb);
commit();
if (viaKeyboard) viaKeyboard = false;
else goNext(); // relâchement du pointeur : on avance d'une question
});
} else if (scale && scale.options && scale.options.length) {
scale.options.forEach(function (o) {
var b = el('button', null, o.label);
b.setAttribute('aria-pressed', st.answers[cur.key] === o.value ? 'true' : 'false');
......@@ -255,9 +530,8 @@
box.appendChild(b);
});
} else {
// échelle absente ou sans options (réponse libre) : on n'invente rien.
app.appendChild(el('p', 'muted',
'Cette partie ne se coche pas : elle est faite pour être parlée. Passez à la suite quand vous voulez.'));
// format de réponse inconnu (inatteignable avec les données actuelles) : on n'invente rien.
app.appendChild(el('p', 'muted', 'Cette question n’a pas de format de réponse connu.'));
}
(cur.item.extra_options || []).forEach(function (k) {
var o = (DATA.opt_out_options || {})[k];
......@@ -267,24 +541,90 @@
b.addEventListener('click', pick(optValue(k)));
box.appendChild(b);
});
if (box.children.length) {
app.appendChild(box);
if (type === 'numeric_range' || (scale && scale.options && scale.options.length)) {
app.appendChild(el('p', 'keyhint', 'Au clavier : chiffres pour répondre, flèches pour naviguer.'));
}
}
var goNext = function () { st.pos = pos + 1; store.set(q.id, st); quiz(q, pos + 1); };
var goNext = function () { if (freeFlush) freeFlush(); st.pos = pos + 1; store.set(q.id, st); quiz(q, pos + 1); };
var goPrev = function () { if (freeFlush) freeFlush(); quiz(q, pos - 1); };
var nav = el('div', 'nav');
var prev = el('button', null, 'Précédente');
prev.addEventListener('click', function () { quiz(q, pos - 1); });
var next = el('button', null, pos + 1 < total ? 'Passer' : 'Voir le récapitulatif');
var prev = el('button', 'btn', 'Précédente');
prev.addEventListener('click', goPrev);
var next = el('button', 'btn', pos + 1 < total ? 'Passer la question' : 'Voir le récapitulatif');
next.addEventListener('click', goNext);
nav.appendChild(prev); nav.appendChild(next);
app.appendChild(nav);
keymap = {
digit: scale ? scale.options.length : 0,
pick: function (n) { var b = box.children[n - 1]; if (b) b.click(); },
digit: type === 'numeric_range' ? scale.max : (scale && scale.options ? scale.options.length : 0),
pick: type === 'numeric_range'
? function (n) { slider.value = n; if (deb) clearTimeout(deb); commit(); }
: function (n) { var b = box.children[n - 1]; if (b) b.click(); },
right: goNext,
left: function () { quiz(q, pos - 1); }
left: goPrev
};
t.focus();
if (type !== 'free_text') t.focus();
}
/* ---------- feuille de travail (folio) : une section par écran ---------- */
function quizWorksheet(q, pos, items, total) {
var st = store.get(q.id) || { answers: {}, pos: 0, header: {} };
if (pos >= total) { recap(q); return; }
var cur = items[pos];
var section = cur.section;
var start = pos;
while (start > 0 && items[start - 1].section === section) start--;
var end = pos;
while (end + 1 < total && items[end + 1].section === section) end++;
st.pos = start; store.set(q.id, st);
showProgress('Question ' + (start + 1) + ' sur ' + total, (start + 1) / total);
app.innerHTML = '';
maybeBanner();
if (section.title) app.appendChild(el('p', 'muted section-label', section.title));
if (section.section_instructions) app.appendChild(el('p', 'muted', section.section_instructions));
var secItems = items.slice(start, end + 1);
if (section.layout === 'two_columns') {
secItems = secItems.slice().sort(function (a, b) { return (a.item.column || 99) - (b.item.column || 99); });
}
var grid = el('div', section.layout === 'two_columns' ? 'cols cols--two' : 'cols');
var flushers = [];
var firstLabel = null;
secItems.forEach(function (r) {
var block = el('div', 'freeitem');
var lbl = el('p', 'item-text', r.item.text);
lbl.setAttribute('tabindex', '-1');
block.appendChild(lbl);
var ft = makeFreeText(q, st, r);
flushers.push(ft.flush);
block.appendChild(ft.wrap);
grid.appendChild(block);
if (!firstLabel) firstLabel = lbl;
});
app.appendChild(grid);
var flushAll = function () { flushers.forEach(function (f) { f(); }); };
var nav = el('div', 'nav');
if (start > 0) {
var prev = el('button', 'btn', 'Précédente');
prev.addEventListener('click', function () { flushAll(); quiz(q, start - 1); });
nav.appendChild(prev);
}
var next = el('button', 'btn', end + 1 < total ? 'Suivant' : 'Voir le récapitulatif');
next.addEventListener('click', function () {
flushAll();
st.pos = Math.min(end + 1, total); store.set(q.id, st);
quiz(q, end + 1);
});
nav.appendChild(next);
app.appendChild(nav);
keymap = null; // saisie libre : aucun raccourci clavier qui surprendrait
if (firstLabel) firstLabel.focus();
}
/* ---------- résultats : score partiel à chaque instant ---------- */
......@@ -293,78 +633,106 @@
var total = items.length;
var st = store.get(q.id) || { answers: {}, pos: 0 };
var n = answeredCount(q, st);
showProgress(n + ' sur ' + total + ' répondues', n / total);
showProgress(n + ' réponses sur ' + total, total ? n / total : 0);
keymap = null;
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, q.title));
app.appendChild(el('p', null,
'Merci d\'avoir pris ce temps. Voici où vous en êtes — ce n\'est ni une note ni un verdict.'));
'Merci d’avoir pris ce temps. Voici où vous en êtes. Ce n’est pas un diagnostic.'));
var optCount = 0;
items.forEach(function (r) {
var v = st.answers[r.key];
if (typeof v === 'string' && v.lastIndexOf(OPT, 0) === 0) optCount++;
});
var sc = computeScore(q, st);
if (sc) {
var complete = !!(sc && n === total && optCount === 0);
if (n === 0) {
app.appendChild(el('p', null,
'Aucune réponse pour l’instant. Le questionnaire sera là quand vous serez prêt·e.'));
} else if (sc) {
var box = el('div', 'result-band');
if (complete) {
var pct = sc.pct;
box.appendChild(el('div', 'score', pct + ' %'));
box.appendChild(el('p', null, 'Score complet.'));
var band = null;
((q.scoring || {}).bands || []).forEach(function (b) { if (pct >= b.min && pct <= b.max) band = b; });
var box = el('div', 'result-band');
box.appendChild(el('div', 'score', pct + ' %'));
var sub = el('p', null,
n === total
? 'Score complet, sur les ' + total + ' questions.'
: 'Score provisoire : ' + n + ' questions répondues sur ' + total +
'. Il évolue à chaque réponse — rien n\'est figé.');
box.appendChild(sub);
if (band) {
box.appendChild(el('p', null, 'Lecture du support d\'origine : ' + band.label));
var bands = (q.scoring || {}).bands || [];
// première bande dont le minimum est atteint ; sinon la dernière
for (var i = 0; i < bands.length; i++) { if (pct >= bands[i].min) { band = bands[i]; break; } }
if (!band && bands.length) band = bands[bands.length - 1];
if (band) box.appendChild(el('p', null, 'Ce qu’en dit le test d’origine : ' + band.label));
} else {
box.appendChild(el('p', null,
'Score provisoire : ' + n + ' réponses sur ' + total + '. Il évolue à chaque nouvelle réponse.'));
}
if (optCount > 0) {
box.appendChild(el('p', null, optCount === 1
? '1 réponse marquée hors échelle, non comptée dans le score.'
: optCount + ' réponses marquées hors échelle, non comptées dans le score.'));
}
box.appendChild(el('p', 'muted',
'Ce chiffre décrit un moment, pas une personne ni un couple. S\'il vous surprend ou vous pèse, ' +
'c\'est exactement le genre de chose à apporter en séance — pas à porter en silence.'));
'Ce chiffre décrit un moment, pas une personne ni un couple. S’il vous surprend ou vous pèse, apportez-le en séance plutôt que de le garder pour vous.'));
app.appendChild(box);
if (q.scoring && q.scoring.source_instruction) {
var q2 = el('blockquote', 'orig', q.scoring.source_instruction);
app.appendChild(q2);
if (complete && q.scoring && q.scoring.source_instruction) {
app.appendChild(el('blockquote', 'orig', q.scoring.source_instruction));
}
} else if (n === 0) {
app.appendChild(el('p', null,
'Aucune réponse pour l\'instant — le questionnaire sera là quand vous serez prêt·e.'));
} else {
app.appendChild(el('p', null,
'Vous avez répondu à ' + n + ' questions sur ' + total +
'. Ce questionnaire ne se calcule pas : vos réponses valent par elles-mêmes, comme matière à réflexion ou à discussion.'));
'. Ce questionnaire ne se calcule pas : vos réponses sont là pour nourrir votre réflexion ou une discussion à deux.'));
}
if (n > 0) {
var details = el('details');
var summary = el('summary', null, 'Revoir mes réponses');
details.appendChild(summary);
details.open = n === total || total <= 25;
details.appendChild(el('summary', null, 'Revoir mes réponses (' + n + ' sur ' + total + ')'));
if (n < total) {
details.appendChild(el('p', 'muted', 'Il reste ' + (total - n) + ' questions sans réponse.'));
}
var table = el('table', 'recap');
if (st.header) {
(q.header_fields || []).forEach(function (f) {
var val = st.header[f.id];
if (typeof val !== 'string' || !val) return;
var tr = el('tr');
tr.appendChild(el('td', null, f.label));
tr.appendChild(el('td', null, val));
table.appendChild(tr);
});
}
items.forEach(function (r) {
var v = st.answers[r.key];
if (v === undefined) return;
var tr = el('tr');
tr.appendChild(el('td', null, r.item.text));
tr.appendChild(el('td', null, optionLabel(q, r.section, v)));
tr.appendChild(el('td', v && typeof v === 'object' ? 'free' : null, optionLabel(q, r.section, v)));
table.appendChild(tr);
});
details.appendChild(table);
app.appendChild(details);
}
if (n > 0) {
var shareBtn = el('button', 'btn', 'Créer un lien de partage');
var shareSlot = el('div');
shareBtn.addEventListener('click', function () { buildSharePanel(q, shareBtn, shareSlot); });
app.appendChild(shareBtn);
app.appendChild(shareSlot);
}
var nav = el('div', 'nav');
var back = el('button', null, 'Retour à l\'accueil');
var back = el('button', 'btn', 'Retour à l’accueil');
back.addEventListener('click', home);
var again = el('button', 'cta', n < total ? 'Continuer' : 'Recommencer');
var again = el('button', 'btn btn--primary',
n === 0 ? 'Commencer' : (n < total ? 'Reprendre' : 'Recommencer'));
again.addEventListener('click', function () {
if (n < total) {
var resume = st.pos || 0;
if (resume >= total) { // on avait déjà atteint le bout : reprendre à la 1re question sans réponse
resume = 0;
for (var i = 0; i < items.length; i++) {
if (st.answers[items[i].key] === undefined) { resume = i; break; }
}
}
quiz(q, resume);
return;
}
if (window.confirm('Recommencer ce questionnaire ? Vos réponses actuelles seront effacées.')) {
if (n === 0) { quiz(q, 0); return; }
if (n < total) { quiz(q, firstUnanswered(q, st)); return; }
if (window.confirm('Recommencer ce questionnaire ? Vos réponses actuelles seront effacées.')) {
store.set(q.id, { answers: {}, pos: 0 });
quiz(q, 0);
}
......@@ -374,10 +742,98 @@
// Entrée = continuer, mais seulement quand rien n'est à risque :
// questionnaire complet, Entrée ne déclenche jamais la remise à zéro.
keymap = n < total ? { enter: function () { again.click(); } } : null;
keymap = (n > 0 && n < total) ? { enter: function () { again.click(); } } : null;
}
function buildSharePanel(q, trigger, slot) {
trigger.hidden = true;
var panel = el('div', 'share');
panel.appendChild(el('p', null,
'Ce lien contient vos réponses, encodées mais non chiffrées : quiconque possède le lien peut les lire. Envoyez-le seulement à qui de droit.'));
var url = location.href.split('#')[0] + '#p=' + answersToBase64url(serializeAnswers(q.id));
var row = el('div', 'share-row');
var inp = el('input');
inp.readOnly = true;
inp.value = url;
inp.setAttribute('aria-label', 'Lien de partage');
inp.addEventListener('focus', function () { inp.select(); });
var copy = el('button', 'btn', 'Copier le lien');
copy.addEventListener('click', function () {
var done = function () {
copy.textContent = 'Lien copié';
setTimeout(function () { copy.textContent = 'Copier le lien'; }, 2000);
};
var ok = false;
try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done, function () {}); ok = true; } } catch (e) {}
if (!ok) {
try { inp.select(); if (document.execCommand('copy')) done(); } catch (e) {}
}
});
row.appendChild(inp); row.appendChild(copy);
panel.appendChild(row);
panel.appendChild(el('p', 'muted',
'La personne qui ouvre ce lien voit vos réponses en lecture seule, rien n’est enregistré sur son appareil.'));
slot.appendChild(panel);
}
function sharedError() {
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
app.appendChild(el('h1', null, 'Lien de partage'));
app.appendChild(el('p', null, 'Lien incomplet ou altéré. Demandez à la personne qui l’a envoyé d’en créer un nouveau.'));
var a = el('a', null, 'Retour à l’accueil');
a.href = location.href.split('#')[0];
app.appendChild(a);
}
function sharedView(p) {
var q = null;
DATA.questionnaires.forEach(function (x) { if (x.id === p.qid) q = x; });
if (!q) { sharedError(); return; }
keymap = null;
hideProgress();
app.innerHTML = '';
maybeBanner();
var answers = {};
p.answered.forEach(function (a) { if (a && typeof a.i === 'string') answers[a.i] = a.v; });
var items = allItems(q);
var n = 0;
items.forEach(function (r) { if (answers[r.key] !== undefined) n++; });
app.appendChild(el('h1', null, 'Réponses partagées : ' + q.title));
if (p.respondent) app.appendChild(el('p', 'muted', 'Partagé par ' + p.respondent));
app.appendChild(el('p', 'banner',
'Vue en lecture seule. Ce lien ne peut pas être révoqué ; demandez un nouveau lien si les réponses changent.'));
var details = el('details');
details.open = true;
details.appendChild(el('summary', null, 'Revoir mes réponses (' + n + ' sur ' + items.length + ')'));
var table = el('table', 'recap');
items.forEach(function (r) {
var v = answers[r.key];
if (v === undefined) return;
var l = optionLabel(q, r.section, v);
if (!l) {
p.answered.forEach(function (a) { if (a && a.i === r.key && typeof a.l === 'string' && !l) l = a.l; });
}
var tr = el('tr');
tr.appendChild(el('td', null, r.item.text));
tr.appendChild(el('td', v && typeof v === 'object' ? 'free' : null, l));
table.appendChild(tr);
});
details.appendChild(table);
app.appendChild(details);
app.appendChild(el('footer', 'pagefoot',
'Ces réponses s’affichent depuis le lien ; rien n’est envoyé ni stocké ici.'));
}
if (location.hash.lastIndexOf('#p=', 0) === 0) {
var shared = decodePayload(location.hash.slice(3));
if (shared) sharedView(shared);
else sharedError();
} else {
home();
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(function () {});
}
......
......@@ -2,17 +2,17 @@
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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.">
<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>
<div id="progress" hidden></div>
<main id="app" aria-live="polite"></main>
<header id="progress" class="progress" hidden></header>
<main id="app"></main>
<script src="data.js"></script>
<script src="app.js"></script>
</body>
......
{
"name": "Clarté — Espace couple",
"name": "Clarté · Espace couple",
"short_name": "Clarté couple",
"lang": "fr",
"start_url": ".",
......
/* Clarté couple — paper-like, low cognitive load */
/* 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; }
......@@ -6,24 +6,34 @@
:root {
--paper: #faf8f4;
--ink: #3a3630;
--ink-faint: #837d76;
--ink-faint: #6d675f;
--teal: #3e7d78;
--teal-soft: #e3efee;
--teal-text: #2d5f5b;
--line: #e5e0d8;
--measure: 46ch;
--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: 1.05rem; line-height: 1.65;
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: .9rem; color: var(--ink-faint);
font-size: var(--fs-caption); color: var(--ink-faint);
font-variant-numeric: tabular-nums;
max-width: 40rem; margin: 0 auto;
}
header.progress .fill {
......@@ -31,61 +41,149 @@ header.progress .fill {
}
header.progress .fill i { display: block; height: 100%; background: var(--teal); transition: width .25s ease; }
h1 { font-size: 1.5rem; line-height: 1.3; margin: 1.2rem 0 .6rem; }
h2 { font-size: 1.2rem; margin: 1.6rem 0 .4rem; }
p { margin: .6rem 0; max-width: var(--measure); }
.muted { color: var(--ink-faint); font-size: .92rem; }
small.source { display: block; margin-top: .8rem; color: var(--ink-faint); font-size: .8rem; }
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);
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 .meta { color: var(--ink-faint); font-size: .85rem; white-space: nowrap; }
.cardbtn .state { color: var(--teal); font-size: .85rem; white-space: nowrap; }
.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: 1.18rem; line-height: 1.55; margin: 1.4rem 0 1.2rem; max-width: var(--measure); }
.scale { display: flex; flex-direction: column; gap: .5rem; margin: 1.2rem 0; max-width: var(--measure); }
.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); background: #fff; transition: all .12s ease;
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[aria-pressed="true"] { background: var(--teal-soft); border-color: var(--teal); font-weight: 700; }
.scale button.optout { border-style: dashed; color: var(--ink-faint); font-size: .92rem; padding: .5rem .9rem; }
.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: inherit; color: var(--ink); background: transparent;
border: none; border-bottom: 1px solid var(--line);
width: 12ch; padding: .1rem .15rem;
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 { outline: none; border-bottom-color: var(--teal); }
.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; }
.nav button, .cta {
border: 1px solid var(--line); background: #fff; border-radius: 9px; padding: .6rem 1.1rem;
}
.cta { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
.cta:disabled { opacity: .45; cursor: default; }
.result-band {
background: var(--teal-soft); border-radius: 12px; padding: 1.1rem 1.2rem; margin: 1.2rem 0;
}
.result-band .score { font-size: 2rem; font-weight: 700; color: var(--teal); }
.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: .92rem; font-style: italic;
color: var(--ink-faint); font-size: var(--fs-small); font-style: italic;
}
.recap { width: 100%; border-collapse: collapse; font-size: .92rem; margin-top: 1rem; }
.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 { cursor: pointer; color: var(--ink-faint); }
footer.pagefoot { margin-top: 3rem; font-size: .85rem; color: var(--ink-faint); }
a { color: var(--teal); }
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. */
var CACHE = 'couple-v3';
/* 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',
......
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