Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
|| window.navigator.standalone === true;
var profile = false, skipped = false, hasImport = false;
try { profile = !!localStorage.getItem('multiplix-profile'); } catch (e) {}
// Deux clés : `multiplix-profiles` (index multi-profils) et
// `multiplix-profile` (ancien schéma mono-profil, migré au boot).
try { profile = !!(localStorage.getItem('multiplix-profiles') || localStorage.getItem('multiplix-profile')); } catch (e) {}
try { skipped = localStorage.getItem('multiplix-skip-install') === '1'; } catch (e) {}
// Migration cross-origin : on arrive de l'ancien domaine avec le profil
// dans le fragment (#import=…) → on boote l'app directement (pas la
Expand Down
30 changes: 25 additions & 5 deletions public/specs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ <h1>Tablito<em>.</em></h1>
<li><a href="#perimetre">Périmètre — ce que l'app ne fait PAS</a></li>
<li><a href="#metriques">Métriques de succès</a></li>
<li><a href="#division">Niveau 2 — Réviser par la division</a></li>
<li><a href="#profils">Multi-profils — plusieurs enfants par appareil</a></li>
<li><a href="#evolutions">Évolutions possibles (V2)</a></li>
<li><a href="#references">Références</a></li>
</ol>
Expand Down Expand Up @@ -831,7 +832,8 @@ <h3 id="ui-ecrans"><span class="num">7.1</span>Écrans</h3>
<table>
<thead><tr><th>Écran</th><th>Contenu</th></tr></thead>
<tbody>
<tr><td>Accueil</td><td>Mascotte (animations d'idle), prénom, streak, bouton « C'est parti ! »</td></tr>
<tr><td>Qui joue ?</td><td>Choix du joueur au lancement — visible uniquement dès 2 profils sur l'appareil (§12)</td></tr>
<tr><td>Accueil</td><td>Mascotte (animations d'idle), prénom, streak, bouton « C'est parti ! » (+ bouton « changer de joueur » en multi-profils)</td></tr>
<tr><td>Séance — Intro</td><td>Grille de points animée pour un nouveau fait</td></tr>
<tr><td>Séance — Question</td><td>Question en gros, pavé numérique, barre de progression</td></tr>
<tr><td>Séance — Feedback correct</td><td>Animation joyeuse, mascotte enthousiaste</td></tr>
Expand Down Expand Up @@ -981,16 +983,34 @@ <h3 id="division-reserves"><span class="num">11.7</span>Réserve méthodologique
<p>Des interventions de fluence ciblant <em>directement</em> la division existent et fonctionnent (cover-copy-compare, taped problems, detect-practice-repair). En revanche, les bénéfices de la <strong>répétition espacée</strong> sont démontrés sur la multiplication et la rétention en général, mais <strong>pas directement sur les faits de division</strong> : les méta-analyses traitent la division au sein des « faits de base » sans isoler son effet, et l'opération s'avère être un modérateur significatif. Appliquer Leitner à la division est donc une <strong>généralisation plausible mais non directement prouvée</strong> — un pari raisonné, assumé comme tel, à surveiller si l'usage révélait un comportement différent de la multiplication.</p>
</section>

<section class="chapter" id="profils">
<h2><span class="num">12</span>Multi-profils — plusieurs enfants par appareil</h2>
<p>Une tablette familiale sert souvent à plusieurs enfants. Chaque enfant dispose de son propre profil : faits, boîtes Leitner, badges, séries et images mystère totalement indépendants. Aucune notion de compte ni de mot de passe — tout reste local sur l'appareil (§9).</p>

<h3 id="profils-parcours"><span class="num">12.1</span>Parcours</h3>
<ul>
<li><strong>Mono-profil (cas majoritaire)</strong> : rien ne change. Pas d'écran intermédiaire, pas de bouton supplémentaire — zéro friction ajoutée à la boucle quotidienne.</li>
<li><strong>Ajout d'un enfant</strong> : bouton « Ajouter un enfant » dans l'espace parent (section Profils) ou depuis l'écran « Qui joue ? ». Rejoue l'onboarding complet : prénom, test de placement, intro des règles ×1/×10.</li>
<li><strong>Dès 2 profils</strong> : l'app s'ouvre sur l'écran « Qui joue ? » (liste des prénoms, pastille-initiale colorée stable par prénom). On ne devine jamais quel enfant tient l'appareil — choisir coûte un tap, se tromper de profil polluerait les boîtes Leitner des deux enfants.</li>
<li><strong>Changer de joueur</strong> : bouton dédié en haut de l'écran d'accueil (visible uniquement en multi-profils), qui ramène à « Qui joue ? ».</li>
<li><strong>Retour au premier plan</strong> : la PWA restant en mémoire des heures, le choix du boot ne suffit pas — quand l'app revient au premier plan après <strong>plus de 15 minutes</strong> en arrière-plan et qu'il y a plusieurs profils, « Qui joue ? » est reproposé. Uniquement depuis un écran sans état précieux (accueil, onboarding laissé en plan — même notion que pour les mises à jour du service worker) : une séance, un récap ou une navigation parent en cours ne sont jamais interrompus, et un aller-retour rapide (notification) ne change rien.</li>
<li><strong>Suppression</strong> : « Supprimer ce profil » dans l'espace parent, avec confirmation explicite. S'il reste un seul profil → retour direct à son accueil ; plusieurs → « Qui joue ? » ; aucun → onboarding.</li>
</ul>

<h3 id="profils-stockage"><span class="num">12.2</span>Stockage et migration</h3>
<p>Chaque profil vit sous sa propre clé localStorage (<code>multiplix-profile:&lt;id&gt;</code>) ; un index léger (<code>multiplix-profiles</code>) liste les profils (id, prénom) et le profil actif. L'ancien schéma mono-clé (<code>multiplix-profile</code>) est migré automatiquement et silencieusement au premier lancement : le profil existant devient le premier profil de l'index, rien n'est perdu.</p>
<p>L'export/import de sauvegarde reste par-profil : l'export produit le JSON du profil actif (format inchangé, compatible avec les sauvegardes antérieures) ; l'import depuis l'écran d'accueil crée un <em>nouveau</em> profil (jamais d'écrasement d'un autre enfant), tandis que l'import depuis l'espace parent restaure le profil actif.</p>
</section>

<section class="chapter" id="evolutions">
<h2><span class="num">12</span>Évolutions possibles (V2)</h2>
<h2><span class="num">13</span>Évolutions possibles (V2)</h2>
<ul>
<li>Mode défi : séance bonus optionnelle le week-end</li>
<li>Mode multi-enfant (plusieurs profils sur le même appareil)</li>
</ul>
</section>

<section class="chapter" id="references">
<h2><span class="num">13</span>Références</h2>
<h2><span class="num">14</span>Références</h2>
<ol class="references">
<li>Axtell, P. K., McCallum, R. S., Bell, S. M., &amp; Poncy, B. (2009). Developing math automaticity using a classwide fluency building procedure for middle school students: A preliminary study. <em>Psychology in the Schools, 46</em>(6), 526–538.</li>
<li>Brendefur, J., Strother, S., Thiede, K., &amp; Appleton, S. (2015). Developing multiplication fact fluency. <em>Advances in Social Sciences Research Journal, 2</em>(8). <a href="https://doi.org/10.14738/assrj.28.1396">doi:10.14738/assrj.28.1396</a></li>
Expand Down Expand Up @@ -1021,7 +1041,7 @@ <h2><span class="num">13</span>Références</h2>
</main>

<footer class="page-footer">
<p>Document de spécifications — v1.2 — Juin 2026</p>
<p>Document de spécifications — v1.3 — Juin 2026</p>
<p><a href="https://github.com/isc/tablito">Code source sur GitHub</a></p>
</footer>

Expand Down
88 changes: 86 additions & 2 deletions scripts/generate-user-guide.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,19 @@ async function seedProfile(page, profile) {
static parse(s) { return RealDate.parse(s); }
};

// Repart toujours d'un stockage de profils vierge : les addInitScript
// s'empilent au fil des captures et le DERNIER seed doit gagner — y
// compris sur l'index multi-profils écrit par seedMultiProfile.
localStorage.removeItem('multiplix-profiles');
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k && k.startsWith('multiplix-profile:')) localStorage.removeItem(k);
}
if (p === null) {
localStorage.removeItem('multiplix-profile');
} else {
// On seede via la clé legacy : l'app la migre vers le schéma
// multi-profils au boot, ce qui exerce aussi le chemin de migration.
localStorage.setItem('multiplix-profile', JSON.stringify(p));
}
// Bypass the install landing : on est en headless, l'install PWA n'a
Expand All @@ -267,10 +277,39 @@ async function seedProfile(page, profile) {
}, { p: profile, mockTodayIso: SEED_TODAY });
}

/**
* Seeds SEVERAL profiles directly in the multi-profile schema (per-profile
* key + index). Reuses seedProfile(null) for the seeded PRNG, frozen clock
* and landing skip; init scripts run in order so the writes below win.
*/
async function seedMultiProfile(page, entries) {
await seedProfile(page, null);
await page.addInitScript((list) => {
for (const { id, p } of list) {
localStorage.setItem('multiplix-profile:' + id, JSON.stringify(p));
}
localStorage.setItem(
'multiplix-profiles',
JSON.stringify({
activeId: list[0].id,
profiles: list.map(({ id, p }) => ({ id, name: p.name })),
}),
);
}, entries);
}

/** Returns the Leitner box of the currently displayed question's fact. */
async function readCurrentFactBox(page, q) {
return page.evaluate((qq) => {
const raw = localStorage.getItem('multiplix-profile');
// Post-boot, le profil vit sous le schéma multi-profils ; on garde le
// fallback legacy au cas où l'app n'a pas encore migré.
let raw = null;
const idx = localStorage.getItem('multiplix-profiles');
if (idx) {
const { activeId } = JSON.parse(idx);
if (activeId) raw = localStorage.getItem('multiplix-profile:' + activeId);
}
if (!raw) raw = localStorage.getItem('multiplix-profile');
if (!raw) return null;
const profile = JSON.parse(raw);
const a = Math.min(qq.a, qq.b);
Expand Down Expand Up @@ -711,6 +750,30 @@ async function captureDivisionScreens(page) {
await shot(page, '17-division-question');
}

async function captureMultiProfileScreens(page) {
// Deux enfants sur l'appareil : Léa (le profil du reste du guide) + Max
// (prénom choisi pour tomber sur une couleur d'avatar différente de Léa —
// la couleur est un hash stable du prénom).
const lea = buildSampleProfile();
const max = buildSampleProfile();
max.name = 'Max';
await seedMultiProfile(page, [
{ id: 'guide-lea', p: lea },
{ id: 'guide-max', p: max },
]);
await gotoHome(page);

// Dès 2 profils, le boot passe par « Qui joue ? ».
await page.waitForSelector('.profile-select-screen');
await shot(page, '19-profile-select');

// Accueil avec le bouton « changer de joueur » à côté de l'engrenage.
await page.click('.profile-select-btn:has-text("Léa")');
await page.waitForSelector('.home-screen');
await page.waitForSelector('.home-switch-btn');
await shot(page, '20-home-multi');
}

// --- HTML guide generator ---------------------------------------------------

const SECTIONS = [
Expand Down Expand Up @@ -925,11 +988,31 @@ const SECTIONS = [
histogramme des boîtes Leitner, l'évolution du taux de réussite,
les faits les plus difficiles,
les temps de réponse moyens par table, l'historique des 10 dernières
séances, et les actions export / import du profil (JSON).`,
séances, les actions export / import du profil (JSON), et la gestion
des profils — ajouter un enfant ou supprimer le profil affiché (voir
« Plusieurs enfants » ci-dessous).`,
shots: [
{ file: '13-parent-dashboard', caption: 'Tableau de bord parent complet.' },
],
},
{
id: 'profils',
title: 'Plusieurs enfants',
description: `Une tablette pour toute la fratrie : chaque enfant a son
propre profil — progression, badges, série et images mystère totalement
séparés. On ajoute un enfant depuis l'espace parent (« Ajouter un
enfant », section Profils) ou directement depuis l'écran de choix du
joueur. Dès deux profils, l'app demande « Qui joue ? » à l'ouverture,
et un bouton dédié en haut de l'accueil permet de changer de joueur à
tout moment. Avec un seul profil, rien ne change : pas d'écran ni de
bouton en plus. La suppression d'un profil se fait dans l'espace
parent, après confirmation — et l'export / import de sauvegarde reste
disponible profil par profil.`,
shots: [
{ file: '19-profile-select', caption: '« Qui joue ? » — l\'écran de choix affiché à l\'ouverture dès deux profils.' },
{ file: '20-home-multi', caption: 'Le bouton « changer de joueur » apparaît en haut de l\'accueil, à côté de l\'engrenage.' },
],
},
];

async function buildHtml({ generatedAt }) {
Expand Down Expand Up @@ -1318,6 +1401,7 @@ async function main() {
await captureSessionScreens(page);
await captureRecap(page);
await captureDivisionScreens(page);
await captureMultiProfileScreens(page);
// Voice capture runs LAST: it injects a SpeechRecognition stub and sets
// the input mode to 'voice' via addInitScript, both of which would
// pollute any subsequent capture (especially captureRecap which drives
Expand Down
Loading
Loading