Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Snapshot di riferimento del capitolo
Da questo capitolo il libro passa dalmain2.35.1 usato nei Capitoli 1–10 al candidato 2.35.2, branchfeat/ui-ux-2.35.2, commit4d32a8c2a6cd291e4fe389bdf10fdbaf1856a8c2.
Il manifest del branch mostra ancora2.35.1: è intenzionale, perché il bump di versione viene lasciato alla fase finale di release.
Nei capitoli precedenti abbiamo parlato soprattutto del motore di PlumePilot:
Ora arriviamo alla parte che l’utente vede direttamente.
PlumePilot possiede due interfacce principali:
popup della toolbar
+
menu fluttuante nella pagina LMS
Per molto tempo queste due superfici sono cresciute in parallelo.
È una dinamica comune nei progetti reali.
Una funzione nasce nel popup.
Poi viene richiesta anche nel menu fluttuante.
Poi una preferenza appare in entrambi.
Poi un pulsante cambia nome in uno ma non nell’altro.
Poi le dimensioni dei font divergono.
Poi le categorie non corrispondono più.
Alla fine l’utente non vede due viste dello stesso prodotto.
Vede due prodotti simili che deve imparare separatamente.
La 2.35.2 nasce esattamente da questo problema.
Il refactor non introduce React, Vue, Svelte o un’altra libreria UI.
Introduce invece qualcosa di più interessante dal punto di vista didattico:
una piccola architettura di presentazione costruita con DOM, CSS, storage e funzioni JavaScript pure.
Il risultato ci permette di studiare molti concetti normalmente nascosti dietro un framework:
E soprattutto possiamo rispondere a una domanda molto utile:
Cosa fa davvero un framework UI per noi, quando decidiamo di non usarne uno?
È facile pensare che il problema della vecchia UI fosse semplicemente:
abbiamo scritto due volte gli stessi pulsanti
Ma la duplicazione visiva è solo il sintomo.
Il problema architetturale è più profondo.
Popup e floating menu rappresentano lo stesso stato di dominio:
autoplay enabled?
stop ai test?
completamento automatico?
limite capitoli?
stop al 70%?
operazione lunga attiva?
menu layout personalizzato?
tema?
stile Gaming?
ma lo rappresentano dentro due runtime diversi.
Il popup è una normale pagina dell’estensione:
popup.html
popup.css
popup.js
nasce quando l’utente apre l’icona della toolbar e può essere distrutto appena il popup si chiude.
Il menu fluttuante vive invece dentro la pagina LMS:
floating-menu.js
↓
content script ISOLATED
↓
crea host DOM
↓
closed ShadowRoot
↓
menu dentro la pagina
È quindi più persistente e deve convivere con:
Non sono lo stesso contesto.
Per questo il refactor non tenta di avere un unico DOM condiviso.
Questa distinzione è fondamentale.
La soluzione adottata introduce:
menu-ux.js
menu-ux.css
menu-ux.js contiene funzioni condivise come:
PlumePilotMenuUx = {
autoplayPresentation,
bindCourseNavigation,
bindExpansionScroll,
ensureGamingFont,
applyActionLayout,
bindLayoutEditor,
};
Riferimento:
menu-ux.js:269–270
Ma il commento più importante è quasi all’inizio del file:
// Presentation only: both menus keep their existing storage and operation owners.
Riferimento:
menu-ux.js:24
Questa riga definisce l’architettura.
Il modulo condiviso non diventa proprietario dello stato applicativo.
Non gestisce:
chrome.storage.local
operazioni in background
messaggi runtime
commissione
progresso corso
autoplay engine
Gestisce invece come certi dati vengono presentati.
Possiamo rappresentarlo così:
graph TD
S[Stato applicativo] --> P[popup.js]
S --> F[floating-menu.js]
U[menu-ux.js] --> P
U --> F
C[menu-ux.css] --> PH[popup DOM]
C --> SH[Shadow DOM floating]
P --> PH
F --> SH
Questa è una forma leggera di presentation layer.
Quando usiamo React, potremmo scrivere qualcosa come:
state
↓
component(state)
↓
virtual DOM
↓
DOM
In PlumePilot il flusso è più esplicito:
storage / runtime / page state
↓
controller
↓
renderSomething()
↓
proprietà DOM
Per esempio nel floating menu:
function renderSettings() {
...
ui.autoplaySkipCompletedVideos.checked =
settings.autoplaySkipCompletedVideos === true;
...
}
Riferimento:
floating-menu.js:1079+
Mentre il popup esegue una lettura iniziale da storage:
chrome.storage.local.get(
{
enabled: true,
stopAtTests: false,
autoCompleteTests: false,
...
},
(result) => {
checkbox.checked = result.enabled;
...
},
);
Riferimento:
popup.js:1267–1334
Non esiste un reconciler automatico.
Siamo noi a decidere:
quale cambiamento richiede quale render
Un errore comune sarebbe memorizzare direttamente una stringa tipo:
"Solo video da completare · Test ignorati · Stop al 70%"
Ma quella stringa non è lo stato.
È una proiezione dello stato.
La sorgente reale può essere:
{
autoplaySkipCompletedVideos: true,
stopAtTests: true,
autoCompleteTests: false,
autoplayChapterLimitEnabled: true,
autoplayStopAt70Enabled: true,
}
Da questo otteniamo una rappresentazione leggibile.
La 2.35.2 formalizza questa idea con:
function autoplayPresentation({
skipCompleted = false,
testBehavior = "ignore",
limitEnabled = false,
limit = 1,
stopAt70 = false,
thresholdBypassed = false,
} = {}) {
...
}
Riferimento:
menu-ux.js:25–42
La funzione restituisce:
{
summary,
testOverride,
stopHint,
}
Questa struttura è un piccolo view model.
Consideriamo questa regola:
const effectiveTestBehavior =
limitEnabled && testBehavior === "stop"
? "ignore"
: testBehavior;
Riferimento:
menu-ux.js:27
L’utente può avere salvato:
Fermati ai test
ma contemporaneamente può essere attivo il limite capitoli.
Nel motore effettivo la policy diventa temporaneamente:
ignora il test
La UI deve quindi distinguere:
saved state
vs
effective state
Il modulo produce infatti anche:
testOverride:
limitEnabled && testBehavior === "stop"
? "Con il limite capitoli attivo, l’autoplay ignora i test ..."
: ""
La lezione generale è:
Una UI corretta non mostra soltanto ciò che è memorizzato. Deve mostrare ciò che il sistema farà davvero.
autoplayPresentation() è interessante perché non tocca il DOM.
Non legge storage.
Non manda messaggi.
Non modifica variabili globali.
Prende un input:
{
skipCompleted,
testBehavior,
limitEnabled,
limit,
stopAt70,
thresholdBypassed,
}
restituisce un output.
Questo la rende facilmente testabile.
Nel test:
assert.equal(
autoplayPresentation().summary,
"Tutti i video · Test ignorati",
);
Riferimento:
tests/menu-ux.mjs:7–18
È una piccola dimostrazione del valore delle pure functions nella UI.
Popup e floating menu possono essere aperti nello stesso momento.
Se l’utente modifica nel popup:
Tema → Scuro
il floating menu dovrebbe aggiornarsi.
Se modifica nel floating menu:
Salta videolezioni completate → ON
il popup dovrebbe rifletterlo.
La soluzione non è:
popup.sendMessage(floating)
floating.sendMessage(popup)
per ogni singola preferenza.
La sorgente condivisa è:
chrome.storage.local
I controller scrivono:
chrome.storage.local.set({ [key]: value });
poi reagiscono a:
chrome.storage.onChanged.addListener(...)
Il popup ascolta:
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
...
});
Riferimento:
popup.js:1370–1488
E aggiorna soltanto le parti interessate.
Per esempio:
if (changes.visualStyle)
renderVisualStyle(changes.visualStyle.newValue);
if (changes.menuSize)
renderMenuSize(changes.menuSize.newValue);
if (changes.floatingMenuLayout)
renderFloatingMenuLayout(changes.floatingMenuLayout.newValue);
Questo è molto vicino a un sistema reactive:
state mutation
↓
storage event
↓
selective render
La stessa idea appare nel menu fluttuante:
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
for (const key of Object.keys(defaults)) {
if (changes[key])
settings[key] = changes[key].newValue ?? defaults[key];
}
...
});
Riferimento:
floating-menu.js:4588–4664
Poi sceglie cosa aggiornare:
visualStyle → applyVisualStyle()
themePreference → applyTheme()
menuSize → applyMenuSize()
floating layout → applyFloatingMenuLayout()
autoplay state → renderSettings()
active operation → renderOperation()
commission data → renderCommissionExams()
È una forma manuale di dependency graph.
renderEverything()?Potremmo teoricamente fare:
chrome.storage.onChanged.addListener(() => {
renderEverything();
});
Sarebbe più semplice.
Ma avrebbe costi e rischi:
<details>;Il codice sceglie quindi un rendering incrementale manuale.
Questa è una delle cose che i framework cercano di rendere più ergonomiche.
La 2.35.2 organizza le destinazioni principali in:
Corso
Esami
Traguardi ← Gaming
Preferenze
Dentro Corso, però, le impostazioni autoplay non sono più una lunga regione sempre visibile.
Vengono aperte come una vista di dettaglio:
Corso
↓
Impostazioni autoplay
↓
← Corso
Questo è progressive disclosure.
Mostriamo inizialmente le azioni frequenti e spostiamo la configurazione più complessa in una seconda vista.
bindCourseNavigation()La logica condivisa è:
function bindCourseNavigation(root, scrollElement, onChange = () => {}) {
const home = root.querySelector("[data-ux-home]");
const detail = root.querySelector("[data-ux-detail]");
const opener = root.querySelector("[data-ux-open]");
const back = root.querySelector("[data-ux-back]");
...
}
Riferimento:
menu-ux.js:44–79
Quando si apre il dettaglio:
homeScroll = scrollElement?.scrollTop || 0;
home.hidden = true;
detail.hidden = false;
opener.setAttribute("aria-expanded", "true");
scrollElement.scrollTop = 0;
detail.querySelector("[data-ux-detail-title]").focus({ preventScroll: true });
Il codice non cambia soltanto hidden.
Gestisce anche:
scroll position
aria-expanded
keyboard focus
homeScroll?Immaginiamo:
Corso
↓ scroll
Esporta dispense
Completamento corso
Autoplay
↓ apri settings
Poi premiamo:
← Corso
Se la UI tornasse sempre in cima, il sistema tecnicamente funzionerebbe.
Ma l’utente perderebbe il proprio contesto spaziale.
Il codice salva:
let homeScroll = 0;
poi al ritorno:
scrollElement.scrollTop = homeScroll;
Questa è una forma di navigation state.
Alla chiusura della vista di dettaglio:
opener.focus({ preventScroll: true });
Perché?
Per un utente mouse-only potrebbe sembrare irrilevante.
Per una persona che usa la tastiera è fondamentale.
Senza focus restoration:
utente apre settings
↓
preme Escape
↓
DOM cambia
↓
focus scompare / finisce nel body
↓
la navigazione riparte da un punto imprevedibile
Con restoration:
Escape
↓
chiudi detail
↓
focus torna a “Impostazioni autoplay”
È una piccola state machine della navigazione.
La funzione condivisa ascolta:
if (event.key === "Escape" && close()) {
event.preventDefault();
event.stopPropagation();
}
Riferimento:
menu-ux.js:72–77
Possiamo leggere il comportamento come:
COURSE_HOME
↓ click settings
AUTOPLAY_DETAIL
↓ Escape / Back
COURSE_HOME
Il Capitolo 7 sulle state machine torna qui in forma molto più piccola.
Nel popup troviamo:
<nav class="popup-tabs" role="tablist">
<button role="tab" aria-selected="true" ...>Corso</button>
<button role="tab" aria-selected="false" ...>Esami</button>
...
</nav>
Riferimento:
popup.html:34–39
Il codice mantiene:
aria-selected
hidden sul panel
tabIndex 0 / -1
Questa tecnica viene chiamata spesso roving tabindex.
Solo il tab attivo entra nella sequenza normale di Tab.
Gli altri possono essere raggiunti con le frecce.
Il popup implementa:
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
return;
poi calcola il nuovo tab.
Riferimento:
popup.js:627–649
La logica corrisponde al pattern WAI-ARIA per una tablist orizzontale:
← tab precedente
→ tab successivo
Home primo tab
End ultimo tab
Qui l’accessibilità non è soltanto aggiungere:
role="tab"
È implementare il comportamento previsto da quel ruolo.
Questa è una regola generale importante:
ARIA descrive una UI; non implementa automaticamente la UI.
Scrivere:
role="tab"
non fa comparire:
arrow navigation
focus management
panel switching
aria-selected updates
Tutto questo resta responsabilità del codice.
Durante il redesign abbiamo incontrato un problema molto concreto:
apro un help / details
↓
il contenuto compare sotto
↓
parte del testo finisce fuori dall'area visibile
La soluzione ingenua sarebbe:
panel.scrollIntoView();
Ma popup e floating menu hanno geometrie differenti.
Nel popup:
viewport del documento
+
header sticky
Nel floating menu:
pannello scrollabile dentro Shadow DOM
+
pagina LMS sottostante che NON deve muoversi
Quindi serve una logica condivisa ma parametrica.
bindExpansionScroll()La funzione riceve:
(root, scrollElement, onLayout)
Riferimento:
menu-ux.js:81–135
Calcola il viewport effettivo:
const isDocument = scrollElement === document.scrollingElement;
const viewport = isDocument
? { top: 0, bottom: window.innerHeight }
: scrollElement.getBoundingClientRect();
Nel popup considera anche l’header sticky:
const header = isDocument
? root.querySelector(".popup-sticky-header")
: null;
Questo è un buon esempio di geometry-aware UI.
Il listener lavora in capture:
root.addEventListener("click", ..., true);
per osservare l’intenzione dell’utente prima del comportamento nativo di <details>.
Ma il codice distingue:
apertura manuale → sì, reveal
ripristino programmatico → no
chiusura → no
Il commento lo esplicita:
// Only user-triggered openings scroll; restoration and closing do not.
Riferimento:
menu-ux.js:106–107
Questo evita un anti-pattern molto fastidioso:
render stato salvato
↓
pagina scrolla da sola
Se il pannello ha un’animazione, misurare immediatamente la geometria può dare un risultato intermedio.
Il codice usa:
requestAnimationFrame(...)
poi:
Promise.allSettled(
animations.map(animation => animation.finished)
)
poi ancora:
requestAnimationFrame(...)
Riferimento:
menu-ux.js:124–132
Sequenza concettuale:
click
↓
DOM cambia
↓
next frame
↓
attendi animazioni finite
↓
next frame
↓
misura geometria finale
↓
scroll
È un’altra applicazione dei concetti del Capitolo 5.
prefers-reduced-motionLo scroll usa:
behavior:
window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth"
Riferimento:
menu-ux.js:103–104
Questa decisione è importante:
animazione estetica
≠
funzione necessaria
La funzione di reveal deve rimanere disponibile anche senza motion.
La personalizzazione dei pulsanti usa ancora la chiave storica:
const STORAGE_KEY = "floatingMenuLayout";
Riferimento:
floating-menu-layout.js:4
Il nome è ormai un piccolo reperto storico.
In 2.35.2 il valore governa entrambi i menu.
Non viene rinominato per evitare una migrazione inutile.
Questa è una decisione di compatibilità.
La struttura è:
{
version: 1,
items: [
{ id: "complete-tests", visible: true },
{ id: "complete-objectives", visible: true },
{ id: "test-collection", visible: true },
{ id: "study-materials", visible: true },
],
}
Riferimento:
floating-menu-layout.js:4–15
L’ordine nell’array rappresenta l’ordine UI.
Il booleano rappresenta la visibilità.
L’utente vede:
Completa tutti i test
ma lo schema salva:
complete-tests
Non:
"Completa tutti i test"
Perché la label può cambiare.
L’identità logica dovrebbe essere stabile.
È lo stesso principio visto nel DOM:
identity != presentation text
normalizeLayout() come schema migration leggeraLa funzione non si limita ad accettare il valore salvato.
Prima costruisce:
const savedById = new Map();
poi elimina:
Infine aggiunge eventuali nuove definizioni mancanti.
Riferimento:
floating-menu-layout.js:17–37
Questo significa che se in una versione futura aggiungessimo:
{ id: "new-action", ... }
un utente con un vecchio layout non perderebbe tutto.
La nuova entry verrebbe appesa.
Questa è una piccola forma di schema normalization.
La vecchia personalizzazione poteva essere interpretata come un ordine molto libero.
La nuova UI introduce gruppi fissi:
Materiali per lo studio
- Crea raccolta test
- Esporta dispense
Completamento del corso
- Completa tutti i test
- Completa tutti gli Obiettivi
Il codice di editing usa:
const groupFor = id =>
["complete-tests", "complete-objectives"].includes(id)
? "completion"
: "materials";
Riferimento:
menu-ux.js:162
Il drag è permesso soltanto dentro lo stesso gruppo.
Questa scelta riduce la libertà assoluta ma aumenta la coerenza semantica.
Un editor di layout non deve necessariamente permettere ogni permutazione possibile.
Possiamo pensarlo come:
user preference
∩
design invariants
PlumePilot permette:
ordine dentro il gruppo
visibilità
ma mantiene:
gruppi semantici
architettura Corso / Esami / Preferenze
Questa è una forma di constrained customization.
Supponiamo che l’utente abbia nascosto:
Esporta dispense
Poi una raccolta dispense risulti ancora attiva.
Se rispettassimo ciecamente la preferenza:
azione nascosta
↓
nessun pulsante per annullare
La UI diventerebbe incoerente con lo stato operativo.
La 2.35.2 introduce l’invariante:
un’azione che possiede un’operazione attiva deve essere visibile anche se l’utente l’aveva nascosta.
applyActionLayout()Il codice determina:
const activeItem = operationItems[operation?.kind];
poi:
node.hidden = !item.visible && item.id !== activeItem;
Riferimento:
menu-ux.js:137–155
Questa riga contiene una regola di dominio UI:
hidden preference
AND
not current operation
↓
hide
ma:
current operation
↓
force visible
In molti software si pensa alla UI di cancellazione come comfort.
Qui è più serio.
Le operazioni possono essere:
raccolta test
raccolta dispense
batch Obiettivi
Turbo Test
Nascondere l’unico controllo che permette di interromperle sarebbe una violazione della proprietà:
running operation → cancellation remains reachable
Questa è una UI invariant.
Se l’operazione è:
turbo
o:
objectives
il gruppo:
Completamento del corso
viene aperto:
if (
group.dataset.menuGroup === "completion" &&
["turbo", "objectives"].includes(operation?.kind)
)
section.open = true;
Riferimento:
menu-ux.js:151–154
Non basta rendere il bottone non-hidden.
Deve essere anche raggiungibile nella gerarchia visuale.
bindLayoutEditor() ricostruisce la lista con:
list.replaceChildren(...rows);
Riferimento:
menu-ux.js:208
Quando sostituiamo nodi DOM, il nodo focalizzato viene distrutto.
Se non facessimo altro:
click ↓
rerender
↓
focus perso
Il codice salva prima:
const focused = list.getRootNode().activeElement;
const focusedId = ...;
const focusedDirection = ...;
poi dopo il rendering cerca il nuovo controllo equivalente e fa:
focus({ preventScroll: true });
Riferimento:
menu-ux.js:163–214
Questa tecnica assomiglia molto ai recovery del DOM del Capitolo 3.
Non conserviamo il vecchio nodo.
Conserviamo una identità logica:
item id
+
control direction
Poi cerchiamo un nodo nuovo equivalente.
Pattern:
remember identity
↓
DOM replacement
↓
re-resolve identity
↓
restore focus
È lo stesso principio usato da PlumePilot per recuperare una lezione dopo un rerender.
Nel popup troviamo:
<section class="autoplay-section ux-group">
<h2 class="ux-heading">Autoplay</h2>
...
<button data-ux-open>Impostazioni autoplay</button>
</section>
Nel floating menu il markup è generato da una stringa template in floating-menu.js.
I due alberi non sono identici byte-per-byte.
Ma condividono attributi semantici:
data-ux-home
data-ux-detail
data-ux-open
data-ux-back
data-menu-group
data-menu-section
data-menu-item
Questi attributi funzionano come un piccolo DOM protocol.
Invece di legare il comportamento a:
.popup-course-autoplay-weird-wrapper > div:nth-child(2)
la funzione condivisa cerca:
root.querySelector("[data-ux-home]")
Questo rende l’intenzione esplicita.
Un data-* può quindi funzionare come:
behavioral contract
tra:
HTML
CSS
JavaScript
renderMenu()?Sarebbe possibile costruire una funzione che generi lo stesso markup per entrambi.
Ma avremmo nuovi problemi.
Il popup possiede:
pagina extension dedicata
header sticky
What's New dialog
specifici ID globali
Il floating menu possiede:
Shadow DOM
launcher
positioning
LMS visibility
commission integration in-page
gaming sprites dinamici
Un componente totalmente condiviso rischierebbe di diventare:
renderMenu({
popup: true,
floating: false,
hasLauncher: ...,
stickyHeader: ...,
shadowMode: ...,
...
})
cioè una funzione con troppe modalità.
Il refactor sceglie una via intermedia:
shared semantics
shared presentation helpers
shared CSS
shared layout schema
ma
surface-specific controllers
surface-specific markup where useful
Il menu viene inserito nella pagina LMS.
Se usassimo DOM normale:
CSS PlumePilot
↕
CSS Multiversity
potrebbero esserci collisioni.
Per esempio:
.button { ... }
oppure stili globali su:
button
input
label
h2
Il menu crea invece:
const shadow = host.attachShadow({ mode: "closed" });
Riferimento:
floating-menu.js:1922–1931
Con:
mode: "closed"
codice esterno non può fare normalmente:
host.shadowRoot.querySelector(...)
perché:
host.shadowRoot === null
La funzione che crea il root conserva però direttamente:
const shadow = host.attachShadow(...);
quindi il controller può continuare a usarlo.
È importante non attribuire al Shadow DOM proprietà che non ha.
Shadow DOM è soprattutto:
DOM/style encapsulation
Non è:
security sandbox
Un closed shadow root rende gli internals meno accessibili tramite la normale property shadowRoot, ma non deve essere usato come boundary di sicurezza per segreti.
Nel popup basta:
<link rel="stylesheet" href="menu-ux.css">
Riferimento:
popup.html:14–16
Nel floating menu invece quel CSS deve essere caricato dentro lo ShadowRoot.
Il template contiene:
<link rel="stylesheet" href="__PP_MENU_UX_CSS__">
poi il placeholder viene sostituito con:
chrome.runtime.getURL("menu-ux.css")
Riferimento:
floating-menu.js:3648
floating-menu.js:3886–3888
Per questo menu-ux.css compare anche in:
web_accessible_resources
Riferimento:
manifest.json:56–88
Qui un dettaglio CSS porta fino al manifest dell’estensione.
menu-ux.css definisce variabili come:
--ux-font
--ux-copy-size
--ux-action-size
--ux-surface
--ux-muted
--ux-accent
Riferimento:
menu-ux.css:1+
Queste proprietà sono design token.
Invece di scrivere in ogni selettore:
font-size: 12px;
si usa un ruolo:
copy size
action size
heading size
Questo rende più facile applicare coerentemente:
Small
Medium
Large
su entrambe le superfici.
Una delle regressioni che abbiamo corretto prima della 2.35.2 riguardava proprio font incoerenti.
Per esempio:
“Limite sessione autoplay”
“Capitoli”
nel floating menu non rispettavano la scala degli altri controlli.
Il refactor non risolve il problema assegnando manualmente un font-size a due label.
Definisce famiglie di ruoli:
:is(
.ux-setting-title,
.toggle-row > span,
.chapter-limit-row,
.test-choice-list label,
...
) {
font-size: var(--ux-copy-size);
}
Riferimento:
menu-ux.css:80–95
Questo è molto più robusto.
Un design system non richiede necessariamente React.
Può iniziare da:
color tokens
spacing rules
typography roles
interaction rules
focus states
La 2.35.2 introduce una piccola forma di design system condiviso senza costruire una libreria di componenti completa.
Lo stile Gaming usa:
Pixelify Sans
Nel popup il caricamento del font è relativamente diretto.
Nel menu Shadow DOM, Chromium può non registrare come ci aspettiamo una dichiarazione @font-face isolata nel root.
Il refactor aggiunge:
function ensureGamingFont(document, url) {
...
}
Riferimento:
menu-ux.js:4–22
Il codice fa:
fetch(url)
.then(response => response.arrayBuffer())
.then(bytes =>
new document.defaultView.FontFace(
"Pixelify Sans",
bytes,
{ style: "normal", weight: "400 700" },
).load()
)
.then(font => {
document.fonts.add(font);
});
Quindi il font viene registrato nel FontFaceSet del documento.
È un esempio interessante di fallback da:
CSS declarativo
a:
resource loading imperativo
quando il boundary del runtime lo richiede.
WeakMap?All’inizio troviamo:
const gamingFonts = new WeakMap();
La chiave è il document.
Il valore è la Promise di caricamento.
Questo evita:
stesso document
↓
font caricato molte volte
e non obbliga la cache a trattenere permanentemente un documento che non esiste più.
Modello:
Document A → Promise FontFace
Document B → Promise FontFace
Quando un Document non è più raggiungibile, una WeakMap non lo mantiene vivo soltanto a causa della chiave.
Questo è un uso molto più appropriato di WeakMap rispetto alla cache API del Capitolo 6.
Nel Capitolo 6 abbiamo visto:
pegasoActiveOperation
Ora vediamo come viene proiettato nella UI.
Nel floating menu:
const operation = settings.pegasoActiveOperation;
const busy = Boolean(operation);
Riferimento:
floating-menu.js:1233–1246
Poi ogni azione decide:
label
disabled/running state
cancellation wording
Per esempio Turbo Test:
nessuna operazione
→ Completa tutti i test
running
→ Interrompi i test automatici
stopping
→ Interruzione dei test…
Il pulsante non possiede l’operazione.
Il pulsante rappresenta l’operazione.
Questa distinzione è enorme.
Anti-pattern:
click button
↓
button remembers locally “I am running”
Pattern migliore:
operation owner changes durable/shared state
↓
UI reads operation state
↓
render button
È ancora la regola:
UI state should not become an accidental second source of truth.
Il popup possiede:
<button id="activeOperationBanner" ... hidden></button>
Riferimento:
popup.html:42
Quando viene cliccato:
selectPopupTab(operationTabId(activeOperation), true);
...
button.scrollIntoView({ block: "nearest" });
button.focus();
Riferimento:
popup.js:652–660
Il banner non è soltanto una notifica.
È una deep link interna verso il controllo rilevante.
Il feedback che ha portato alla 2.35.2 era semplice:
Corso contiene troppe cose
Una risposta superficiale sarebbe:
rimuoviamo funzioni
Ma il problema non era la quantità di capacità.
Era la gerarchia.
Il redesign organizza:
Corso
├── Progresso del corso
├── Trova prima attività incompleta
├── Autoplay
├── Materiali per lo studio
└── Completamento del corso
Esami
Traguardi
Preferenze
├── Interfaccia
└── Comportamento
Questa è information architecture.
Le azioni frequenti restano immediate:
Trova prima attività incompleta
Esporta dispense
Crea raccolta test
Le opzioni più complesse diventano progressive:
Impostazioni autoplay → detail view
Visualizzazione e avvisi → details
Completamento del corso → details
Preferenze → macro sections
Una possibile euristica è:
frequente + azione diretta → superficie
raro + configurazione → profondità
Progressive disclosure funziona solo se:
la destinazione è nominata chiaramente
+
lo stato attuale è riassunto
+
il ritorno è prevedibile
Per questo Autoplay mostra:
Tutti i video · Test ignorati · Stop al 70%
prima ancora di entrare nelle impostazioni.
La summary riduce il costo informativo della profondità.
Vediamo ancora molti pezzi duplicati fra:
popup.js
floating-menu.js
Per esempio entrambi possiedono:
render preferences
render operation
render progress
commission rendering
Potremmo dire:
violazione DRY
Ma sarebbe troppo semplice.
La duplicazione più pericolosa non è avere due funzioni con sintassi simile.
È avere due copie della stessa regola di dominio che possono divergere.
La 2.35.2 estrae soprattutto le regole che avevano bisogno di coincidere:
effective autoplay summary
navigation behavior
expansion scroll
layout normalization/applicazione
layout editor
shared typography
Questo riduce la duplication of knowledge.
Il popup e il floating menu hanno lifecycle diversi.
Avere due controller può quindi essere più chiaro che costruire un’astrazione gigantesca.
Regola utile:
condividi ciò che deve evolvere insieme
separa ciò che ha motivi diversi per cambiare
Questa è una formulazione pratica del principio di separation of concerns.
Il vantaggio didattico è enorme.
Dobbiamo gestire direttamente:
hidden;aria-selected;aria-expanded;tabIndex;focus();scrollTop;getBoundingClientRect();Un framework non elimina questi concetti.
Li incapsula.
Senza una singola funzione dichiarativa del tipo:
UI = render(state)
è più facile dimenticare un aggiornamento.
Esempio:
storage cambia
↓
aggiorno checkbox
↓
dimentico summary
oppure:
operation termina
↓
abilito pulsante
↓
dimentico di riapplicare layout
Per questo funzioni aggregate come:
renderSettings()
renderOperation()
renderFloatingMenuLayout()
sono importanti.
Anche senza JSX possiamo pensare:
renderOperation(state)
renderPreferences(state)
renderCourseProgress(state)
come componenti procedurali.
La differenza è che invece di restituire DOM:
return <OperationBanner ... />
mutano nodi già esistenti.
menu-ux.css non nasce in un progetto vuoto.
Popup e floating menu possiedono già molti stili storici.
Aggiungere una nuova regola come:
.ux-heading {
font-size: var(--ux-action-size);
}
non garantisce che vinca.
Potrebbero esistere selettori più specifici come:
html[data-menu-size="small"] .some-old-control { ... }
Il file contiene infatti il commento:
Root specificity keeps old size-specific rules from overriding the shared scale.
Riferimento:
menu-ux.css:65+
Questo ci ricorda che il CSS possiede anch’esso una forma di legacy compatibility.
Un design system nuovo deve entrare in una cascade già esistente.
Nel floating menu a quattro tab:
Corso | Esami | Traguardi | Preferenze
la parola Preferenze rischiava di avere troppo poco spazio.
La soluzione non è stata semplicemente:
allarga tutto il menu
perché il floating panel ha un vincolo di dimensione.
Il CSS usa:
:host .menu-tabs[data-count="4"] {
grid-template-columns: repeat(4, auto);
}
Riferimento:
menu-ux.css:58
Le colonne seguono quindi meglio la lunghezza delle label.
Questo è un esempio piccolo ma utile di constraint-based layout thinking.
Possiamo avere responsiveness rispetto a:
viewport
font size
numero tab
lunghezza label
visual style
user-selected menu size
La 2.35.2 usa attributi come:
data-menu-size
data-visual-style
come input del layout.
autoplayPresentation() viene testata direttamente in Node.
Questo verifica:
saved state
↓
effective summary
senza browser.
È veloce e deterministico.
tests/menu-ux.mjs legge anche:
popup.html
floating-menu.js
manifest.json
per controllare invarianti strutturali.
Per esempio verifica che:
Trova prima attività incompleta
sia prima della detail view autoplay.
Riferimento:
tests/menu-ux.mjs:22–46
È un test intermedio:
non unit puro
non browser E2E completo
La documentazione del redesign descrive anche:
scripts/check-menu-ux-browser.mjs
che esegue i controller reali in Chromium con storage simulato.
Vengono verificati fra gli altri:
Riferimento:
docs/features/menu-ux-2.35.2.md:48–53
Uno screenshot può dimostrare:
layout apparentemente corretto
ma non dimostra:
Escape restore focus
storage sync
operation cancellation reachability
keyboard navigation
reduced motion
layout persistence
La UI ha comportamento, non soltanto pixel.
Prendiamo:
Salta videolezioni completate
floating menu
chrome.storage.local.set(...)
chrome.storage.local
storage.onChangedriceve la mutazione negli altri contesti.
settings.autoplaySkipCompletedVideos
checkbox
summary autoplay
Il motore autoplay userà il nuovo comportamento.
Quindi la UI è soltanto un punto del data flow.
sequenceDiagram
participant U as Utente
participant P as Popup
participant S as chrome.storage.local
participant F as Floating menu
participant B as Bridge / runtime
participant A as Autoplay engine
U->>P: modifica preferenza
P->>S: storage.set()
S-->>P: onChanged
S-->>F: onChanged
P->>P: render summary
F->>F: render settings
S-->>B: stato condiviso
B-->>A: PEGASO_AUTONEXT_STATE
A->>A: applica comportamento
Il diagramma rende evidente una cosa:
Popup e floating menu non devono sincronizzarsi direttamente fra loro.
Si sincronizzano attraverso lo stato condiviso.
Supponiamo di leggere:
checkbox.checked
Quello descrive ciò che il popup sta mostrando adesso.
Non è necessariamente lo stato più autorevole del sistema.
Il popup potrebbe essere appena nato.
Il floating menu potrebbe avere appena scritto storage.
Un’altra operazione potrebbe aver modificato una policy.
La sorgente è altrove.
Questo ricollega il Capitolo 11 al Capitolo 3:
il DOM è una rappresentazione dello stato, non necessariamente lo stato stesso.
Esistono però stati che hanno senso soltanto nella UI:
tab selezionato
scroll corrente
help aperto
vista detail autoplay
focus corrente
Non serve persistere tutto in chrome.storage.local.
Qui torna la lezione del Capitolo 6:
lifetime dello storage
≈
lifetime del dato
Una posizione di focus non deve sopravvivere al browser restart.
Per chi viene dal backend, popup e floating menu possono essere pensati come due client:
Client A: popup
Client B: floating menu
entrambi consumano:
shared state / runtime services
È simile ad avere:
web dashboard
+
mobile app
che usano la stessa API.
Non pretenderemmo che abbiano lo stesso markup.
Pretenderemmo invece che concordino su:
business rules
identità
schema
stati effettivi
menu-ux.js come shared application/presentation serviceIl modulo condiviso svolge un ruolo simile a:
shared formatter
shared DTO projection
shared policy presenter
Per esempio:
Domain/preferences
↓
autoplayPresentation()
↓
UI ViewModel
Non possiede il database.
Non possiede il motore autoplay.
Produce una rappresentazione coerente.
No.
Avrebbero semplificato alcuni aspetti:
Ma avremmo comunque dovuto progettare:
Il framework non decide queste cose per noi.
Se l’interfaccia crescesse molto, potremmo iniziare a vedere segnali come:
molte render functions intrecciate
molti nodi creati manualmente
dipendenze DOM difficili da seguire
state drift frequente
componenti davvero riutilizzabili
test DOM sempre più complessi
A quel punto un framework potrebbe ridurre il costo di manutenzione.
Ma avrebbe anche costi:
Per un’estensione di questa dimensione, la scelta vanilla resta ragionevole.
Una evoluzione futura potrebbe essere:
function deriveMenuViewModel(state, courseContext) {
return {
autoplay: {
enabled: ...,
summary: ...,
canFindIncomplete: ...,
},
operations: ...,
progress: ...,
preferences: ...,
};
}
Poi:
popup renderer
floating renderer
consumerebbero lo stesso ViewModel.
Questo ridurrebbe ulteriormente la duplicazione di knowledge senza imporre lo stesso DOM.
Potremmo immaginare:
MenuViewModel
├── PopupAdapter
└── FloatingAdapter
Il modello direbbe:
cosa mostrare
Gli adapter deciderebbero:
come rappresentarlo nel loro DOM
È lo stesso schema visto nel Capitolo 3 con i DOM adapter multipiattaforma.
Problema:
controlli semanticamente equivalenti
ma accent color diverso
La correzione non dovrebbe essere:
aggiungi CSS a quel checkbox
ma:
definisci la regola per checkbox/radio condivisi
Il nuovo CSS usa selettori comuni basati su data-menu-size e tipi input.
Riferimento:
menu-ux.css:69+
Lezione:
Quando più bug hanno la stessa causa visiva, cercare il ruolo condiviso invece del singolo elemento.
Il feedback era semplicissimo:
testo troppo vicino al pulsante
La correzione è:
[data-action="clear-commission-data"] {
margin-top: 12px;
}
Riferimento:
menu-ux.css:99
Non ogni problema UI richiede un’astrazione enorme.
La capacità importante è distinguere:
inconsistenza sistemica
vs
regola locale legittima
Qui il problema era sistemico:
4 tab
+
label di lunghezze diverse
+
pannello con larghezza limitata
La soluzione riguarda il layout della tablist, non la singola parola.
Questo bug apparteneva al comportamento, non al CSS.
Un <details> poteva aprirsi correttamente e comunque offrire una UX sbagliata perché il nuovo contenuto risultava fuori viewport.
È un buon esempio di distinzione:
DOM correctness
≠
interaction correctness
Possiamo sintetizzare il redesign con alcune proprietà.
popup destinations ≈ floating destinations
autoplayPresentation(state)
floatingMenuLayout
running action → visible cancellation path
surface ≠ source of truth
open detail → focus title
close detail → focus opener
floating internal scroll ≠ page scroll
Possiamo semplificare così:
Popup controller ── regole UI A
Floating controller ── regole UI B
Con il tempo:
A ≈ B
ma non necessariamente:
A == B
┌─────────────────────┐
│ shared presentation │
│ menu-ux.js / .css │
└──────────┬──────────┘
│
┌─────────────┴─────────────┐
│ │
┌──────▼──────┐ ┌──────▼───────┐
│ popup.js │ │floating-menu │
│ popup.html │ │ .js │
└──────┬──────┘ └──────┬───────┘
│ │
└─────────────┬─────────────┘
│
┌──────────▼──────────┐
│ chrome.storage + │
│ runtime/domain state│
└─────────────────────┘
La parte condivisa non è diventata il centro del sistema.
È diventata un layer sottile.
Questo limita il coupling.
La documentazione della 2.35.2 dichiara esplicitamente che non introduce modifiche a:
playback algorithm
persistent schema version
permissions
Riferimento:
docs/features/menu-ux-2.35.2.md:38
Questa è una scelta di scope control.
Un refactor UI già ampio diventa più sicuro se non cambia contemporaneamente il motore sottostante.
La 2.35.2 tocca molte superfici:
popup
floating menu
CSS
layout persistence
focus
scroll
browser tests
ma cerca di non cambiare il dominio.
Possiamo chiamarlo un refactor orizzontale:
stesso comportamento
nuova presentazione coerente
Se dopo il refactor:
Autoplay salta una lezione
possiamo chiedere:
è cambiato il motore?
Se la risposta è no, restringiamo lo spazio di indagine a:
configurazione trasmessa
rendering preferenza
storage sync
Separare scope riduce l’incertezza diagnostica.
Guardando questo codice possiamo vedere che gran parte del lavoro non è:
creare un button
ma coordinare:
state
DOM
focus
storage
lifecycle
rendering
interaction
Un framework fornisce convenzioni per ridurre questo coordination cost.
Una UI vanilla può essere molto strutturata se possiede:
state ownership chiaro
pure projections
render boundaries
stable identities
shared tokens
behavior contracts
invariants
Il problema non è usare o non usare React.
Il problema è sapere dove vive ogni responsabilità.
Layer che trasforma lo stato applicativo in una forma adatta alla visualizzazione senza diventare proprietario del dominio.
Informazione calcolata da altro stato.
Esempio:
summary autoplay
non viene salvata: viene derivata.
Struttura preparata per la UI.
Esempio:
{
summary,
testOverride,
stopHint,
}
Strategia che mostra immediatamente le operazioni più importanti e sposta configurazioni o dettagli dietro interazioni esplicite.
Pattern in cui un solo elemento di un gruppo possiede tabIndex=0, mentre gli altri usano -1 e vengono navigati con tasti direzionali.
Ripristino intenzionale del focus sul controllo che ha aperto una vista, un dialog o un dettaglio dopo la chiusura.
Proprietà che deve restare vera indipendentemente dalle preferenze o dal percorso di interazione.
Esempio:
operazione attiva → controllo di cancellazione raggiungibile
Valore semantico condiviso per aspetti visuali come colore, dimensione, spaziatura o tipografia.
Convenzione di attributi e struttura usata da più componenti per collegare markup e comportamento.
In questo capitolo:
data-ux-*
data-menu-*
Pattern in cui un cambiamento a una sorgente condivisa genera notifiche che provocano aggiornamenti mirati nelle viste interessate.
Personalizzazione consentita entro invarianti definite dal prodotto.
Esempio:
riordino dentro un gruppo
ma non distruggo la gerarchia semantica
Immagina che il popup mantenga:
let enabled = checkbox.checked;
ma non ascolti chrome.storage.onChanged.
Il floating menu cambia enabled.
Domande:
Dato:
{
testBehavior: "stop",
limitEnabled: true,
stopAt70: true,
thresholdBypassed: false,
}
Calcola:
effective test behavior
summary
warning/override
prima di guardare autoplayPresentation().
Considera un layout editor che fa:
list.replaceChildren(...newRows);
senza ricordare il focus.
Descrivi cosa può succedere dopo aver premuto:
Sposta giù
usando soltanto la tastiera.
L’utente nasconde:
Completa tutti i test
ma Turbo Test è già attivo.
Progetta due possibili comportamenti e spiega quale preserva meglio la controllabilità dell’operazione.
Spiega perché:
menu-ux.css
può essere incluso direttamente dal popup ma deve essere reso accessibile al floating menu tramite una URL dell’estensione.
Riscrivi mentalmente:
renderSettings()
come componente React/Vue.
Poi elenca quali problemi del capitolo non verrebbero risolti automaticamente dal framework.
MDN:
https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow
https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode
W3C WAI-ARIA Authoring Practices:
https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
L’esempio manuale mostra in particolare:
ArrowLeft
ArrowRight
Home
End
come navigazione della tablist.
MDN:
https://developer.mozilla.org/en-US/docs/Web/API/FontFace
https://developer.mozilla.org/en-US/docs/Web/API/Document/fonts
Chrome Extensions:
https://developer.chrome.com/docs/extensions/reference/api/storage
In particolare:
chrome.storage.onChanged
che è alla base della sincronizzazione fra le due superfici.
La 2.35.2 ci mostra che un refactor UI non è soltanto:
nuovi colori
nuove card
spaziature migliori
È soprattutto riallineare:
information architecture
state representation
navigation
focus
persistence
invariants
style roles
cross-surface behavior
Il risultato può essere riassunto così:
shared state
↓
controllers separati
↓
shared presentation rules
↓
two coordinated surfaces
La scelta più importante non è stata “usare vanilla JS”.
È stata:
condividere la conoscenza che deve essere coerente, lasciando separate le responsabilità che appartengono davvero a runtime diversi.
Questo principio è molto più generale di PlumePilot.
Vale per:
web + mobile
admin + user UI
CLI + GUI
popup + embedded panel
server renderer + client renderer
Nel prossimo capitolo entreremo in un’altra area dove l’architettura diventa concreta:
Vedremo:
DOM scan
API pacing
cache
canvas
PDF.js
Blob
worker
batch operations
MutationObserver
memory pressure
non come una lista di micro-ottimizzazioni, ma come un problema di budget delle risorse.
← 10 — Rich-text editor · Indice · 12 — Performance e memoria →