Architettura - mattiaboero/lascuolaamica GitHub Wiki

Architettura

Principi di base

Il sito è un'applicazione statica: nessun server applicativo, nessun database, nessuna build framework. Il deploy produce file HTML, CSS, JS e JSON serviti direttamente dalla piattaforma di hosting statico.

Questa scelta non è per semplicità: è per controllabilità. Ogni file che arriva al browser è ispezionabile, ogni comportamento è deterministico, ogni dipendenza è esplicita.


Frontend

HTML statico per pagina. Ogni materia ha la propria pagina HTML. Nessun routing lato client, nessun SPA.

CSS condiviso con tema accessibile e font self-hosted. I font .woff2 sono in assets/fonts/ e referenziati da fonts.css — nessuna richiesta esterna a Google Fonts o CDN.

JavaScript vanilla organizzato per responsabilità. La logica è suddivisa in file condivisi e file pagina dedicati, così il comportamento resta leggibile e controllabile.


Moduli principali

File Ruolo
shared.js Footer, modali, log aggiornamenti, palette colori
subject-quiz-core.js Motore quiz runtime condiviso per tutte le 8 materie
js/<subject>-page.js Config materia dichiarativa (window.SA.subjectConfig)
questions-loader.js Caricamento e parsing dataset JSON
sw.js Service Worker: cache PWA, fallback offline, clean URLs
js/rewards.js Bacheca premi: badge/coppe/trofei, salvataggio locale

Coupling subject-quiz-core.js ↔ js/rewards.js

js/rewards.js è incluso con defer in tutte le pagine HTML (non solo premi.html), non solo dove serve. Nessun import/chiamata diretta tra i due file: sono disaccoppiati a compile-time e comunicano solo a runtime tramite window.SA + eventi DOM. Scelta deliberata, non un gap — un grafo di dipendenze statico (AST) non troverà mai un edge tra questi due file.

Flusso:

  1. rewards.js si registra su window.SA.rewards = { recordGame, ... } al caricamento.
  2. A fine partita, subject-quiz-core.js chiama con guard difensivo:
    if (!window.SA || !window.SA.rewards || typeof window.SA.rewards.recordGame !== 'function') return;
    window.SA.rewards.recordGame({ ... });
    Se rewards.js non è caricato per qualche motivo, la chiamata viene saltata senza crash.
  3. Quando recordGame() sblocca un badge, rewards.js dispatcha document.dispatchEvent(new CustomEvent('sa:rewards-updated', { detail: { unlocked, state } })).
  4. rewards.js stesso riascolta sa:rewards-updated per ridisegnare la bacheca (renderBoard()), utile se premi.html è aperta in un'altra tab o dopo un aggiornamento asincrono.

Perché non un import diretto: ogni pagina materia carica rewards.js indipendentemente da subject-quiz-core.js; il contratto è sul namespace globale window.SA, non sul grafo di file. Aggiungere un nuovo consumer di eventi rewards (es. una nuova bacheca) richiede solo ascoltare sa:rewards-updated, zero modifiche a subject-quiz-core.js.


Quiz engine

  • Un solo motore runtime: subject-quiz-core.js
  • Un file config per materia: js/<subject>-page.js
  • Dataset domande: json/<subject>.json + json/index.json
  • Materie supportate: matematica, geografia, italiano, scienze, storia, civica, problemi, inglese

Aggiungere una nuova materia

  1. Creare json/<subject>.json con shape compatibile con questions-loader.js
  2. Aggiornare json/index.json con path e cardinalita`
  3. Creare js/<subject>-page.js con window.SA.subjectConfig = { ... } Campi obbligatori:
    • subject
    • totalQ
    • lbKey
    • cursorKey
    • historyKey
    • metricsKey
    • classPrefKey
    • defaultArea
    • areas
    • questionsSource Campi opzionali:
    • classProfiles
    • levels
    • renderMode
    • answerMode
    • classMeta
    • mixedRepeatLimit
    • leaderboardAreaFallback
  4. Creare la pagina HTML seguendo lo scaffold di civica.html o inglese.html
  5. Aggiornare sitemap.xml e gli eventuali metadati pagina necessari
  6. Eseguire bash prepublish-check.sh

Extension contract

Algoritmo di selezione domande

Il sistema usa un planner stocastico a slot per ridurre i pattern ripetitivi tra sessioni. Per ogni partita:

  1. Le domande vengono raggruppate per area e difficoltà
  2. I candidati vengono selezionati con selezione softmax (non casuale pura)
  3. Un sistema di cooldown su ID e firma domanda riduce le ripetizioni multi-sessione
  4. Le metriche di qualità sessione (repeat rate, coverage, entropy, novelty) vengono salvate nella memoria locale del browser con media rolling

Tutte le 8 materie usano oggi la stessa pipeline di selezione nel core condiviso, con variazioni espresse solo tramite config field e metadata dei dataset.


Funzioni quiz (A1-A4, B1, C1)

Aggiunte in subject-quiz-core.js, condivise da tutte le 8 materie senza branch per subject:

Funzione Comportamento Chiave storage / opt-out
A1 — Spiegazione risposta Dopo ogni risposta appare #qExplanation con il campo explanation della domanda JSON, colorata in base a corretto/sbagliato nessuna
A2 — Difficoltà adattiva A fine partita calcola una media mobile esponenziale (EMA) per classe e la usa per pesare la selezione delle domande successive ${CURSOR_KEY}_adapt_v1; opt-out con cfg.adaptiveDifficulty: false
A3 — Ripassa i tuoi errori Le domande sbagliate (max 30) restano disponibili per una sessione dedicata dalla schermata iniziale ${CURSOR_KEY}_wrong_q_v1
A4 — Filtro sotto-ambito Dopo la selezione dell'area, una griglia di sotto-ambiti derivata dinamicamente dal dataset (nessuna config per materia necessaria). Conta sullo stesso pool della partita (getClassAwarePool, modo strict) e mostra solo i sotto-ambiti con almeno mezza partita di domande (MIN_SUBAREA_POOL) nessuna
B1 — Streak feedback Risposte corrette consecutive: alle soglie configurate (default 3/5/8) messaggio dedicato + mascotte celebrate invece di happy cfg.streakMilestones per personalizzare le soglie
C1 — Overlay progressi Pulsante "Progressi" nella schermata risultati apre statistiche per classe/area e le ultime partite legge loadStats()/loadLB() esistenti

Riferimento storico: CHANGELOG.md release 4.11.0 e 4.12.0.


Gioco arcade: Cervellino Spacca-Muri

/breakout (breakout.html + breakout.css + js/breakout.js) è un rompi-mattoni ispirato a Breakout/Arkanoid, motore Canvas 2D vanilla indipendente da subject-quiz-core.js: non fa parte del quiz engine unificato, non usa window.SA.subjectConfig, non consuma onBuildSession/onPickBonus/onScore.

Punti di contatto con il resto del sito:

  • Domande: usa questions-loader.js (SA.questionsLoader.getSubjectRows) per pescare dagli stessi json/<materia>.json delle 8 materie, filtrati per classe a inizio partita. Nessun dataset dedicato al gioco.
  • Premi: js/rewards.js espone recordBreakout() accanto a recordGame() — stesso STORAGE_KEY/stessa bacheca (premi.html), ma sotto-stato (state.breakout) e contatori separati da quelli quiz, così giocare non altera "partite totali" o "materie giocate". Registrazione progressiva durante la partita (muro abbattuto, salvataggio, nuovo bonus scoperto), non solo a fine partita, per non perdere il progresso se si esce con "Ricomincia"/"Cambia classe"/timer scaduto/cambio scheda — vedi flushBreakoutProgress() in js/breakout.js, invio a delta e idempotente sul conteggio partite.
  • Service Worker: breakout.css e js/breakout.js sono in OPTIONAL_PRECACHE_URLS di sw.js; la pagina /breakout va offline dopo la prima apertura, come le pagine materia.
  • Grafica: mattoni/pallina/barra disegnati su canvas con gradienti pre-renderizzati (sprite offscreen riusati via drawImage, non ridisegnati ogni frame). Palette mattoni più vivace dei token testo del sito (lecito: sono grafica di gioco, soglia WCAG 1.4.11 non-text 3:1, non 4.5:1), con token separati (--breakout-feedback-ok/-ko) per gli usi testuali (overlay risposta, punteggio volante, simbolo capsula) che restano a 4.5:1. Effetti decorativi (particelle, squash/stretch, wobble capsula) rispettano il toggle "riduci animazioni" e prefers-reduced-motion; la fisica di gioco non ne è mai influenzata. Modalità Okabe-Ito: stessi 4 colori mattone certificati, nessuna differenza.
  • Accessibilità: overlay domanda con focus trap dedicato (Tab/Shift+Tab intrappolati nel dialog, focus iniziale sulla prima opzione, ripristino del focus precedente alla chiusura) — aria-modal da solo non impedisce a Tab di uscire dal dialog nei browser.

Gioco di ortografia: Il Bosco delle Lettere

/bosco (bosco.html + bosco.css + js/bosco.js) è un gioco di ortografia per la 2ª e la 3ª, motore Canvas 2D vanilla indipendente da subject-quiz-core.js, come il gioco arcade.

Differenze rispetto a Spacca-Muri:

  • Dati propri. Non usa questions-loader.js: il banco di 129 parole vive dentro js/bosco.js (PAROLE), con skill, classe, difficoltà, indizio e distrattori per parola. È l'unico contenuto del sito che non sta in json/, perché non è una domanda a risposta multipla ma una parola con un buco.
  • Modello verificabile senza canvas. init() esce subito se non trova #boscoCanvas e il modello è esposto su SA.bosco: scripts/check_bosco.js lo carica con window/document finti e verifica banco parole, confini della radura e simulazione dell'acqua. È in npm run verify.
  • Distrattori confondibili per costruzione. CONFUSIONI elenca, per ciascuna delle 10 famiglie ortografiche, gli errori che i bambini fanno davvero (GNI per GN, CU per QU, la vocale nuda al posto di quella accentata). Il controllo verifica che i distrattori del banco restino dentro quelle famiglie invece di scivolare verso lettere a caso quando si aggiungono parole.
  • Due modalità sullo stesso banco. parola va dal suono al segno (parola bucata sul tabellone, cartelli nella radura), suono fa il contrario (gruppo sul tabellone, tre parole intere nella radura). La modalità scelta è in localStorage (lascuolaamica_bosco_modalita_v1), insieme a classe, audio e memoria delle abilità.
  • Memoria per abilità e recupero spaziato. lascuolaamica_bosco_abilita_v1 tiene precisione e ultimo incontro per famiglia; pescaPesata() ripropone più spesso le famiglie deboli, daRipassare() le riporta dopo un intervallo.

In comune con il resto del sito: palette letta dai token CSS via readPalette() (quindi la modalità accessibile Okabe-Ito ridipinge il bosco senza toccare il JS), premi nella bacheca condivisa (js/rewards.js, sei premi dedicati), precache opzionale nel Service Worker, storageGet/storageSet con fallback in memoria quando localStorage non è disponibile.

Timer di gioco facoltativo. Stesso controllo dei quiz e di Spacca-Muri. Il timer è spento di default e si accende dal pannello Info (scuolaAmica_play_timer_v1): acceso, SA.playWindow.ensureActive() fa partire da solo la sessione di 30 minuti alla prima partita e durante la pausa di 60 risponde false con un avviso; spento, risponde sempre true. Il bosco si apre in pausa sotto l'overlay «Entra nel bosco» (state.inGioco falso): il clic chiama entra() → SA.playWindow.ensureActive(), ed è anche il gesto utente che sblocca l'AudioContext. Finché non si è entrati, setPaused(false) passa dal cancello invece di riprendere, Esc non fa niente e scegliere classe o modalità ricompone la partita restando sotto l'overlay. Alla scadenza (evento SA.playWindow.eventName con detail.expired vero, solo a timer acceso) onPlayWindowExpired() chiude la partita, mostra SA.ui.alert «Tempo di gioco terminato» e torna al cancello con una partita nuova. Non si perde niente: SA.rewards.recordBosco registra ogni risposta e ogni parola mentre si gioca, non a fine partita. scripts/check_bosco.js verifica rifiuto, ingresso e scadenza con un SA.playWindow finto.

Strumento interattivo: Il Sistema Solare in 3D

/sistema-solare (sistema-solare.html + sistema-solare.css + js/sistema-solare*.js) è un modello 3D del Sistema Solare per la 4ª e la 5ª, indipendente da subject-quiz-core.js e senza punteggi né timer di gioco: è uno strumento da esplorare, non un gioco a quiz.

  • Motore WebGL 1 vanilla (js/sistema-solare-motore.js, nessuna libreria): posizioni dagli elementi orbitali JPL (validi 1800-2050), lune da JPL Horizons, poli IAU, ISS da TLE, sonde Voyager da traiettorie JPL Horizons. Due modi di tempo: «movimento rallentato» (di partenza, velocità compresse per guardarle) e «tempo vero». Le scale di distanze e raggi sono compresse e la pagina lo dichiara.
  • Interfaccia (js/sistema-solare.js): vista immersiva a tutto schermo, schede per bambini, missioni, passaporto, viaggio guidato con voce, film delle Voyager. Se WebGL manca la pagina resta nella vista normale.
  • Dati con fonte, generati. docs/sistema-solare/*.json (ogni valore con URL e note, rivisti da revisori indipendenti) → scripts/build_sistema_solare.py → js/sistema-solare-dati.json e assets/sistema-solare/voyager-traiettorie-v1.json. Lo stesso script scrive in sistema-solare.html, fra i marcatori SCHEDE e FAQ-JSONLD, le schede di tutti i corpi, le FAQ (testo e FAQPage identici, da faq.json) e i crediti delle immagini: la pagina si legge anche senza JavaScript e dai crawler. Non si modificano a mano.
  • Asset pesanti fuori dal precache. Mappe (assets/sistema-solare/*.webp 2048×1024 e basse/ 512×256), modelli (*.bin) e audio si scaricano quando servono; mappe e modelli finiscono nella cache stabile del Service Worker (bin è fra le estensioni stabili). /assets/* è immutabile: un file corretto cambia nome.
  • Controlli: npm run check:sistema-solare (build --check e scripts/check_sistema_solare.js: posizioni note, leggi di Keplero, coerenza fisica delle schede). È in verify e in prepublish-check.sh.
  • Revisione e SEO: docs/sistema-solare/REVISIONE.md (guida per fisico e bambini) e docs/sistema-solare/AUDIT-SEO.md.

Dati

json/
├── index.json          # Indice con cardinalità per materia e timestamp
├── matematica.json
├── italiano.json
├── inglese.json
├── problemi.json
├── civica.json
├── geografia.json
├── storia.json
└── scienze.json

Il riferimento legacy a questions.json (file aggregato) non è usato nel runtime pubblico. I controlli pre-pubblicazione bloccano riferimenti diretti non desiderati.

CSS critico delle pagine materia

Dalla 4.26.0 le 8 pagine materia non hanno CSS bloccanti. scripts/build_css_critico.js (npm run css:critico, serve Playwright) apre ogni pagina come la lascia il motore, in ogni classe, e scrive fra i marcatori CSS-CRITICO un <style> con le regole che toccano quegli elementi (@media e @font-face comprese). I fogli completi restano linkati con media="print" e data-css-completo; js/lazy-css.js li accende. Arrivano dopo il <style>, che ne è un sottoinsieme: la cascata finale è quella di prima. Dopo ogni modifica a tokens.css, subject-quiz-theme.css, inglese.css, utilities.css o fonts.css: npm run css:critico, poi npm run freshness (hash CSP). npm run verify lo ricorda (check:css-critico, impronta dei fogli nel marcatore); npm run test:e2e:css-critico confronta pixel per pixel la pagina col solo CSS critico e coi fogli completi.

Source of truth JSON-only

La source of truth runtime e il caricamento JSON tramite questions-loader.js.

  • subject-quiz-core.js legge i dataset materia da json/index.json
  • questions-loader.js costruisce bank e bonus questions a partire dai JSON materia
  • le righe bonus sono marcate con bonus: true e bucket bonusRaw
  • Domande per classe (dalla 4.26.0). json/<materia>.json resta la fonte, ma il sito non lo scarica più. scripts/build_json_classi.py (dentro npm run freshness, --check in npm run verify) scrive json/<materia>/c1.json … c5.json (domande attive di una classe, senza bonus, solo i campi letti dal sito) e json/<materia>/bonus.json (tutte le bonus). subject-quiz-core.js scarica solo le classi che una partita della classe scelta può usare (gradesForClass: distanza maxGradeDistance, o maxLevelDistance con i livelli, e la regola della 1ª: per la 3ª sono 2ª, 3ª e 4ª), le altre al cambio classe (data-domande="in-arrivo" sull'html finché non arrivano). /breakout scarica solo la classe scelta. Test: npm run test:e2e:json-classi

Stato utente

Tutto nella memoria locale del browser, tutto locale, niente server:

  • Punteggi e progressi per materia/classe
  • Preferenze UI (palette, riduzione animazioni)
  • Metriche qualità sessione quiz

Service Worker

sw.js gestisce:

  • Precache degli asset statici principali all'install
  • CSS e JS con la versione nell'URL — in export/ le pagine caricano CSS e JS locali con ?v=<APP_VERSION> (scripts/versiona_asset.js, chiamato dall'export; i sorgenti non cambiano), e precacheUrl mette in precache gli stessi URL. Dopo un rilascio il worker vecchio non trova gli URL nuovi e va in rete: una pagina non esegue mai CSS o JS di una versione diversa dal suo HTML. rewards.css e palette-okabe.css, caricati da JS, prendono la ?v= dello script che li carica. Anche i JSON scaricati con fetch: SA.urlVersione in shared.js (indice e changelog), la ?v= del proprio script in questions-loader.js e js/sistema-solare.js, che girano prima di app-version.js; in precache e in refetchOpenedJson con la stessa ?v=, manifest.json escluso. Senza rete, un CSS o un JS con un'altra ?v= ripiega sul file con lo stesso percorso (cacheFirst, ignoreSearch solo lì). Font e immagini restano senza versione. npm run test:e2e:versioni prova il passaggio da N a N+1
  • Precache lazy per i dataset materia — json/index.json è precached all'install (serve alla home per il conteggio totale); i file delle domande (json/<materia>/c<N>.json e bonus.json) NON sono precached: vengono salvati offline al primo fetch reale, quindi offline c'è ogni classe già aperta (con le vicine che la partita usa), non le altre
  • Materie già aperte al cambio di versione — refetchOpenedJson legge dalle cache vecchie quali file json/ c'erano (anche nelle cartelle per classe) e li riscarica nella cache nuova; chi aveva il file intero di prima (json/<materia>.json, fino alla 4.25.0) riceve tutte le classi e il bonus di quella materia, all'install e di nuovo all'activate (tetto di 5 s). Riscarica, non copia; se un file non arriva resta fuori dall'offline fino alla prossima apertura con la rete. Controllo: scripts/check_sw_opened_json.js
  • Solo le cache della versione corrente — cacheFirst e i ripieghi offline cercano con matchCurrent, cioè solo in KEEP_CACHE_NAMES. caches.match(request) senza nome guarda tutte le cache dalla più vecchia: se ne resta una di una versione precedente risponde lei. Non usarlo in sw.js
  • Pulizia anche fuori da activate — su Safari, se l'app viene chiusa con un aggiornamento in attesa, alla riapertura il worker nuovo è attivo ma la pulizia di activate non c'è stata. All'install rememberOlderCaches annota le cache che esistevano già; alla prima richiesta servita dropOlderCachesOnce riporta le materie aperte e cancella solo quelle. Non si cancella «tutto ciò che non è mio» fuori da activate: un worker attivo toglierebbe la cache di una versione più nuova ancora in attesa
  • Pagine HTML fuori dal precache — alla prima installazione l'unica pagina scaricata è /; ai cambi di versione nessuna, se una cache vecchia ha già / (altrimenti / e basta). ensureHome garantisce / nella cache nuova anche se la copia manca. Precachate erano 25 richieste di pagina a ogni install e a ogni versione, che Cloudflare contava come visualizzazioni (circa 13 per visita). Una pagina entra in cache quando viene aperta (networkFirstWithFallback); al cambio di versione carryVisitedHtml copia le pagine HTML dalle cache vecchie alla nuova con cache.match e cache.put, senza rete, in activate e in dropOlderCachesOnce. CSS e JS delle pagine restano in precache. check_sw_precache.js blocca una pagina della sitemap rimessa in precache
  • Fallback offline: una pagina già visitata si apre dalla cache; una mai visitata ripiega sulla home
  • Strategie differenziate per contenuti statici e dati quiz

Vincolo di deploy

La PWA e progettata per un deploy in root del dominio. Questo vincolo e intenzionale oggi:

  • shared.js registra il Service Worker su /sw.js
  • sw.js importa /app-version.js
  • il manifest usa start_url: "/" e scope: "/"
  • le clean URL dipendono da rewrite lato hosting compatibili con _redirects

Di conseguenza, un deploy in sottocartella o su GitHub Pages non e considerato supportato senza adattamenti infrastrutturali o refactor dedicato.

Scelta offline attuale

Per le navigazioni HTML la strategia e Network First con fallback alla home. Questo significa che offline, se una rotta non e disponibile in cache, il fallback finale torna a / invece di mostrare una pagina offline dedicata.

Header HTTP critici per i dataset

I file in /json/* (dataset domande per materia + index.json) richiedono un header specifico in _headers:

/json/*
  Cache-Control: public, max-age=0, must-revalidate
  X-Robots-Tag: noindex, nofollow

Senza max-age=0, must-revalidate il browser potrebbe servire una copia stale dei JSON anche dopo un upgrade della PWA. Sintomi tipici:

  • conteggio domande non aggiornato dopo release dati
  • bonus mancanti se un nuovo bucket e stato aggiunto al JSON
  • materie con cardinalita incoerente tra index.json e UI

Il flusso atteso ad ogni release dati:

  1. APP_VERSION viene bumpato in app-version.js
  2. sw.js ricostruisce la cache con nuovo CACHE_NAME
  3. al primo accesso post-upgrade il browser rivalida i JSON contro l'origine grazie a must-revalidate
  4. la nuova cache contiene i dataset aggiornati

Il check check_pwa_cache_headers in prepublish-check.sh verifica la presenza dell'header /sw.js e /app-version.js ma non gli header /json/*. La presenza degli header /json/* e invece controllata da prepublish-check.sh nella sezione _headers: split json noindex/cache rules.

Regola operativa: non rimuovere o ammorbidire la riga Cache-Control: public, max-age=0, must-revalidate su /json/* senza una revisione architetturale. Una cache piu aggressiva su questi file causa drift dati invisibile lato utente.

Riferimenti:


Sicurezza

  • Policy di sicurezza restrittive per ridurre l’esecuzione di contenuti non previsti
  • Header di protezione applicati a livello infrastrutturale
  • Dati tecnici esclusi dall’indicizzazione diretta
  • Dettagli completi: Sicurezza, privacy e minori
⚠️ **GitHub.com Fallback** ⚠️