Il contesto
Il pannello di controllo che ho costruito per gli agenti commerciali include anche la gestione dei terminali [BRAND_POS]. Il problema pratico: chi fornisce assistenza spesso non sa com'è fatta l'app del telefono che accompagna il dispositivo. Non l'ha mai installata, non l'ha mai aperta.
La soluzione ovvia sarebbe un PDF con gli screenshot. Ma un PDF è statico, noioso e nessuno lo legge. L'idea era diversa: creare qualcosa che sembri l'app stessa — si vede la schermata Home, si clicca sul tasto "Carte" in basso, e appare la schermata Carte. Esattamente come sul telefono reale, ma dentro il browser, dentro il pannello.
Stack: Firebase Storage per le immagini, JavaScript vanilla dentro un modal glassmorphism già presente nel pannello. Nessuna libreria esterna.
L'idea tecnica: hotspot in percentuale su una macchina a stati
La tecnica giusta non è ritagliare le immagini in più pezzi. Ogni schermata è un'immagine intera (lo screenshot del telefono). Sopra ci si posizionano dei rettangoli cliccabili invisibili — gli hotspot — definiti in percentuale rispetto all'immagine, non in pixel fissi. Questo garantisce che restino allineati correttamente su qualsiasi dimensione del modal.
L'intera navigazione è una macchina a stati minima:
ogni screen ha un ID, un'immagine e una lista di hotspot.
Ogni hotspot specifica solo dove porta (target) e le sue coordinate percentuali.
La funzione renderScreen(id) disegna l'immagine e sovrappone gli hotspot —
nient'altro.
const BRAND_SCREENS = { home: { img: 'Home.png', hotspots: [ { top:88, left:0, w:25, h:12, target:'carte', label:'Carte' }, { top:88, left:25, w:25, h:12, target:'altro', label:'Altro' }, { top:88, left:50, w:25, h:12, target:'disp', label:'Dispositivi' }, ] }, carte: { img: 'Carte.png', hotspots: [ { top:2, left:2, w:12, h:7, target:'home', label:'← Home' }, // ... altri hotspot ] }, // altri screen... };
Le coordinate top, left, w, h sono tutte in percentuale (0–100).
Un hotspot { top:88, left:0, w:25, h:12 } occupa il quarto inferiore sinistro dell'immagine —
indipendentemente da quanto è alta la finestra del modal.
Fase 1: l'editor di hotspot
Il problema pratico è misurare le coordinate. Farlo a occhio su un'immagine sarebbe lento e frustrante, soprattutto con 20 schermate e decine di zone cliccabili. Ho costruito prima un editor standalone — un singolo file HTML che non ha bisogno di server, si apre direttamente nel browser.
-
1
Carica uno screenshot
Si aggiunge uno screen con un ID (es.
home) e si carica l'immagine. L'editor la mostra a schermo intero nel canvas. -
2
Disegna gli hotspot col mouse
Si trascina sul canvas per disegnare un rettangolo. Le coordinate vengono calcolate automaticamente in percentuale rispetto all'immagine.
-
3
Collega la destinazione
Per ogni zona si sceglie dal menu a tendina quale screen aprire. Il menu si popola con tutti gli screen già creati. Finché non c'è una destinazione, la zona resta gialla con un "?".
-
4
Esporta il codice
Il pulsante "Genera codice" produce direttamente l'oggetto
BRAND_SCREENSpronto da incollare nell'app. C'è anche Esporta/Importa in JSON per non perdere il lavoro tra una sessione e l'altra.
Hotspot spostabili e ridimensionabili: ogni zona può essere trascinata per spostarla o ridimensionata dall'angolino in basso a destra. Si elimina con Canc. Molto più comodo che riscrivere le percentuali a mano ogni volta.
Fase 2: il modal interattivo nel pannello
Hotspot visibili, non trasparenti
Una scelta di design importante: gli hotspot nel modal finale non sono invisibili. Non avendo tutti gli screen disponibili, l'operatore deve sapere esattamente dove può cliccare e dove l'immagine è "muta". La soluzione: un alone blu pulsante sopra ogni zona cliccabile.
/* Animazione keyframe */ @keyframes mpgGlow { 0%, 100% { box-shadow: 0 0 6px 2px rgba(14,165,233,.35); } 50% { box-shadow: 0 0 14px 5px rgba(14,165,233,.6); } } /* L'hotspot cliccabile */ .mpg-hotspot { position: absolute; border: 1px solid rgba(14,165,233,.7); background: rgba(14,165,233,.12); border-radius: 6px; cursor: pointer; animation: mpgGlow 2s ease-in-out infinite; } /* Le zone senza destinazione non hanno classe → nessun glow */
Firebase Storage: getDownloadURL invece di img src
Il primo tentativo era semplice: costruire l'URL di Firebase Storage a mano e usarlo come
src dell'<img>. Non ha funzionato — le immagini restano bianche.
Il motivo: Firebase Storage può avere regole di sicurezza che richiedono autenticazione.
Un tag <img src="..."> non può mandare il token di autenticazione dell'utente corrente —
è una richiesta HTTP semplice, senza credenziali. La soluzione è usare direttamente l'SDK:
// URL costruito a mano → bloccato dalle regole Firebase img.src = `https://firebasestorage.googleapis.com/v0/b/ BUCKET/o/AppBRAND%2FHome.png?alt=media`;
async function _guideLoadImg(screenId) { const imgFile = BRAND_SCREENS[screenId].img; // getDownloadURL gestisce automaticamente il token di autenticazione const url = await firebase.storage() .ref(`AppBRAND/${imgFile}`) .getDownloadURL(); return url; }
Auto-scala per riempire il modal senza scrollbar
L'immagine di uno screenshot da telefono è verticale — alta e stretta.
Il modal potrebbe essere troppo basso, causando una scrollbar, oppure troppo largo,
lasciando spazio vuoto ai lati. La soluzione è misurare lo spazio disponibile e
applicare un transform: scale() all'intero stage (immagine + hotspot insieme),
così le proporzioni e l'allineamento degli hotspot restano perfetti.
function _guideFitStage() { const body = document.getElementById('mpgBody'); const stage = document.getElementById('mpgStage'); const img = stage.querySelector('img'); if (!img || !img.naturalHeight) return; const availH = body.clientHeight - 8; // 8px di margine const availW = body.clientWidth - 8; const scaleH = availH / img.naturalHeight; const scaleW = availW / img.naturalWidth; const scale = Math.min(scaleH, scaleW, 1); // mai ingrandire oltre 1:1 stage.style.transform = `scale(${scale})`; stage.style.transformOrigin = 'top center'; }
Un ResizeObserver sul body del modal chiama _guideFitStage() ogni volta che
la finestra cambia dimensione, così la scala si aggiorna in tempo reale.
I problemi che ho incontrato
1. flex:1 non si espande senza height esplicita sul parent
Il corpo del modal (l'area dove sta l'immagine) aveva flex: 1 per occupare
tutto lo spazio disponibile. Ma restava a altezza zero.
Il motivo è una regola CSS poco intuitiva: flex: 1 su un figlio funziona solo se il parent
ha un'altezza definita in modo esplicito — non basta max-height.
Con solo max-height: 88vh sul contenitore, il browser non sa quale sia l'altezza di riferimento
per distribuire lo spazio tra i figli flex.
#mpgBox {
display: flex;
flex-direction: column;
max-height: 88vh; /* ← non basta come riferimento per flex:1 */
}
#mpgBody { flex: 1; }
#mpgBox {
display: flex;
flex-direction: column;
height: min(94vh, 960px); /* height esplicita → flex:1 funziona */
}
#mpgBody {
flex: 1;
min-height: 0; /* necessario per evitare overflow in column layout */
overflow: hidden;
}
Regola da ricordare: in un flex container con flex-direction: column, aggiungere sempre height esplicita sul parent e min-height: 0 sui figli con flex: 1. Senza min-height: 0, il figlio può traboccare oltre il contenitore anche se la height sembra corretta.
2. Immagini bianche: l'errore silenzioso di Firebase Storage
Con l'URL di Firebase costruito a mano, il tag <img> non mostrava errori nel DOM —
restava semplicemente bianco. Non c'era nessun messaggio in console.
Solo controllando la tab Network si vedeva la risposta 401 Unauthorized.
La causa: Firebase Storage di default richiede autenticazione per leggere i file,
e una richiesta HTTP normale da <img src> non porta il token JWT dell'utente loggato.
getDownloadURL() dell'SDK risolve il problema perché costruisce internamente
un URL con token temporaneo già incluso.
3. Navigazione lenta: ~400ms per schermata
La prima versione funzionante faceva una chiamata getDownloadURL() ad ogni click di navigazione.
Ogni chiamata aggiunge ~300–500ms di latenza percepita — abbastanza da rendere la guida scomoda da usare.
La soluzione in due livelli:
- Cache in memoria — la prima volta che si naviga su uno screen, la URL viene salvata in
_guideUrlCache. La seconda volta (o dopo aver riaperto il modal) non si fa più alcuna chiamata a Firebase. - Preload in background — appena il modal si apre, parte
_guidePreloadAll(): lancia in parallelo legetDownloadURL()per tutti e 20 gli screen. Il modal si apre sulla Home immediatamente; quando l'utente naviga al secondo screen, la URL è già pronta in cache.
var _guideUrlCache = {}; async function _guideGetUrl(imgFile) { if (_guideUrlCache[imgFile]) return _guideUrlCache[imgFile]; // cache hit const url = await firebase.storage().ref(`AppBRAND/${imgFile}`).getDownloadURL(); _guideUrlCache[imgFile] = url; return url; } function _guidePreloadAll() { // Lancia tutte le chiamate in parallelo senza aspettare i risultati Object.values(BRAND_SCREENS).forEach(s => _guideGetUrl(s.img)); }
Il risultato finale
La guida è accessibile dalla pagina Utilità del pannello con un pulsante dedicato (icona 📱, gradiente teal/cyan). Al click si apre un modal glassmorphism con:
- 20 schermate dell'app [BRAND_POS], navigate esattamente come sul telefono
- 52 hotspot visibili con glow blu pulsante — l'operatore sa esattamente dove cliccare
- Pulsante ← Indietro nella header quando non si è sulla Home, con history stack completo
- Navigazione istantanea dopo il primo caricamento grazie alla cache URL + preload in background
- Auto-scala dell'immagine per riempire sempre il modal senza scrollbar, su qualsiasi schermo
Editor riutilizzabile: il file HTML dell'editor di hotspot è completamente separato dall'app. Può essere usato per qualsiasi guida basata su screenshot — basta sostituire le immagini e riesportare il codice. Non serve installare nulla.
Takeaway
- Hotspot in percentuale, non in pixel. Le coordinate percentuali restano allineate su qualsiasi dimensione del contenitore. I pixel fissi si rompono al primo resize.
- Firebase Storage richiede getDownloadURL. Non costruire l'URL a mano per passarlo a
<img src>— se lo Storage è protetto da auth, l'immagine non caricherà senza errori visibili. - flex: 1 in column layout vuole height esplicita sul parent.
max-heightnon basta. Aggiungere anchemin-height: 0sui figli per evitare overflow silenziosi. - Cache + preload eliminano la latenza percepita. Una singola chiamata per screen al primo accesso è accettabile; farla ad ogni navigazione non lo è. Bastano un dizionario in memoria e un preload parallelo all'apertura.
The context
The control panel I built for the sales team includes [BRAND_POS] terminal management. The practical problem: whoever handles support often doesn't know what the companion phone app looks like. They've never installed it, never opened it.
The obvious solution would be a PDF with screenshots. But a PDF is static, boring, and nobody reads it. The idea was different: create something that feels like the app itself — you see the Home screen, you click the "Cards" button at the bottom, and the Cards screen appears. Exactly like on the real phone, but inside the browser, inside the control panel.
Stack: Firebase Storage for images, vanilla JavaScript inside an existing glassmorphism modal in the panel. No external libraries.
The technical idea: percentage-based hotspots on a state machine
The right technique isn't to slice images into multiple pieces. Each screen is a full image (the phone screenshot). On top of it you position invisible clickable rectangles — the hotspots — defined in percentage relative to the image, not in fixed pixels. This ensures they stay correctly aligned at any modal size.
The whole navigation is a minimal state machine:
each screen has an ID, an image, and a list of hotspots.
Each hotspot only specifies where it leads (target) and its percentage coordinates.
The renderScreen(id) function draws the image and overlays the hotspots — nothing else.
const BRAND_SCREENS = { home: { img: 'Home.png', hotspots: [ { top:88, left:0, w:25, h:12, target:'carte', label:'Cards' }, { top:88, left:25, w:25, h:12, target:'altro', label:'More' }, { top:88, left:50, w:25, h:12, target:'disp', label:'Devices' }, ] }, carte: { img: 'Carte.png', hotspots: [ // ... ] }, // other screens... };
top, left, w, h are all in percentage (0–100).
A hotspot { top:88, left:0, w:25, h:12 } occupies the bottom-left quarter of the image —
regardless of how tall the modal window is.
Phase 1: the hotspot editor
The practical challenge is measuring coordinates. Doing it by eye on an image would be slow and frustrating, especially with 20 screens and dozens of clickable areas. I built a standalone editor first — a single HTML file that needs no server, just open it directly in the browser.
- 1Load a screenshot
Add a screen with an ID (e.g.
home) and load the image. The editor shows it full-screen on the canvas. - 2Draw hotspots with the mouse
Drag on the canvas to draw a rectangle. Coordinates are automatically calculated as percentages relative to the image.
- 3Link the destination
For each area, pick the target screen from a dropdown. The menu populates with all existing screens. Until a destination is set, the area stays yellow with a "?".
- 4Export the code
"Generate code" produces the
BRAND_SCREENSobject ready to paste. Export/Import JSON to save work between sessions.
Draggable and resizable hotspots: each area can be dragged to reposition or resized from the bottom-right corner. Delete with Del. Much more convenient than rewriting percentages by hand.
Phase 2: the interactive modal in the panel
Visible hotspots, not transparent ones
An important design choice: the hotspots in the final modal are not invisible. Since not all screens are available, the operator needs to know exactly where they can click and where the image is "silent". Solution: a pulsing blue glow overlay on each clickable area.
@keyframes mpgGlow { 0%, 100% { box-shadow: 0 0 6px 2px rgba(14,165,233,.35); } 50% { box-shadow: 0 0 14px 5px rgba(14,165,233,.6); } } .mpg-hotspot { position: absolute; border: 1px solid rgba(14,165,233,.7); background: rgba(14,165,233,.12); border-radius: 6px; cursor: pointer; animation: mpgGlow 2s ease-in-out infinite; }
Firebase Storage: getDownloadURL instead of img src
The first attempt was straightforward: build the Firebase Storage URL manually and use it as the src
of the <img> tag. It didn't work — the images stayed white.
The reason: Firebase Storage can have security rules that require authentication.
An <img src="..."> tag can't send the current user's auth token —
it's a plain HTTP request with no credentials. The fix is to use the SDK directly:
async function _guideLoadImg(screenId) { const imgFile = BRAND_SCREENS[screenId].img; // getDownloadURL handles the auth token automatically const url = await firebase.storage() .ref(`AppBRAND/${imgFile}`) .getDownloadURL(); return url; }
Auto-scale to fill the modal without a scrollbar
A phone screenshot is portrait — tall and narrow.
The modal might be too short, causing a scrollbar, or too wide, leaving blank space on the sides.
The solution is to measure the available space and apply a transform: scale() to the entire stage
(image + hotspots together), so proportions and hotspot alignment stay perfect.
function _guideFitStage() { const body = document.getElementById('mpgBody'); const stage = document.getElementById('mpgStage'); const img = stage.querySelector('img'); if (!img || !img.naturalHeight) return; const availH = body.clientHeight - 8; const availW = body.clientWidth - 8; const scaleH = availH / img.naturalHeight; const scaleW = availW / img.naturalWidth; const scale = Math.min(scaleH, scaleW, 1); // never scale beyond 1:1 stage.style.transform = `scale(${scale})`; stage.style.transformOrigin = 'top center'; }
Problems I ran into
1. flex:1 won't expand without an explicit height on the parent
The modal body (the area containing the image) had flex: 1 to fill the available space.
But it kept collapsing to zero height.
The reason is a somewhat counterintuitive CSS rule: flex: 1 on a child only works if the parent
has an explicit height — max-height alone isn't enough.
With just max-height: 88vh on the container, the browser has no reference height
to distribute space among flex children.
#mpgBox {
display: flex;
flex-direction: column;
height: min(94vh, 960px); /* explicit height → flex:1 works */
}
#mpgBody {
flex: 1;
min-height: 0; /* needed to prevent overflow in column layout */
overflow: hidden;
}
Rule to remember: in a flex container with flex-direction: column, always set an explicit height on the parent and min-height: 0 on children with flex: 1. Without min-height: 0, the child can overflow its container even when the height looks correct.
2. White images: Firebase Storage's silent error
With a manually constructed Firebase Storage URL, the <img> tag showed no errors in the DOM —
it just stayed white. No console message. Only checking the Network tab revealed a 401 Unauthorized.
getDownloadURL() from the SDK solves it because it internally builds a URL with a temporary token already included.
3. Slow navigation: ~400ms per screen
The first working version called getDownloadURL() on every navigation click.
Each call adds ~300–500ms of perceived latency — enough to make the guide feel sluggish.
Two-level solution:
- In-memory cache — the first time you navigate to a screen, the URL is saved in
_guideUrlCache. Next time, no Firebase call needed. - Background preload — as soon as the modal opens,
_guidePreloadAll()fires all 20getDownloadURL()calls in parallel. The modal opens on Home immediately; when the user navigates to the second screen, the URL is already cached.
var _guideUrlCache = {}; async function _guideGetUrl(imgFile) { if (_guideUrlCache[imgFile]) return _guideUrlCache[imgFile]; const url = await firebase.storage().ref(`AppBRAND/${imgFile}`).getDownloadURL(); _guideUrlCache[imgFile] = url; return url; } function _guidePreloadAll() { Object.values(BRAND_SCREENS).forEach(s => _guideGetUrl(s.img)); }
The final result
The guide is accessible from the panel's Utility page with a dedicated button (📱 icon, teal/cyan gradient). Clicking it opens a glassmorphism modal with:
- 20 screens of the [BRAND_POS] app, navigated just like on the real phone
- 52 hotspots with a pulsing blue glow — the operator always knows exactly where to click
- ← Back button in the header whenever you're not on Home, with a full history stack
- Instant navigation after the first load, thanks to URL cache + background preload
- Auto-scale to always fill the modal without a scrollbar, on any screen size
Takeaway
- Percentage hotspots, not pixels. Percentage coordinates stay aligned at any container size. Fixed pixels break on the first resize.
- Firebase Storage requires getDownloadURL. Don't build the URL manually for
<img src>— if Storage has auth rules, the image won't load with no visible error. - flex: 1 in column layout needs an explicit height on the parent.
max-heightisn't enough. Addmin-height: 0on children too, to prevent silent overflow. - Cache + preload eliminate perceived latency. One call per screen on first access is fine; doing it on every navigation click isn't. A simple in-memory dictionary and a parallel preload on modal open is all it takes.