+
+
diff --git a/docs/redesign-plan.md b/docs/redesign-plan.md
new file mode 100644
index 0000000..34fa764
--- /dev/null
+++ b/docs/redesign-plan.md
@@ -0,0 +1,463 @@
+# Piano: nuovo look per GUI e TUI, da zero
+
+Documento di lavoro. Numeri verificati sul branch `2.0.6` (commit `8c6b323`).
+
+**Obiettivo:** sostituire per intero il linguaggio visivo di Mornye — GUI
+(`mornye/frontend/`) e TUI (`mornye/tui/`) — con un unico design system nuovo,
+senza toccare motore di download, API e flussi.
+
+**Non è in scopo:** riscrittura di `app.js` in un framework, introduzione di
+un build step, cambi al contratto delle API di `webapp.py`.
+
+La §7 aggiunge, dopo una scansione della codebase, fix, miglioramenti e
+nuove funzioni: sono binari separati dal look, ognuno agganciato alla fase
+in cui costa meno farlo.
+
+---
+
+## 0. Stato e ripartenza (aggiornato 2026-11-12)
+
+**Chi riprende legga questa sezione, poi la §2 (direzione scelta) e la §4
+(fasi).** Tutto il lavoro è sul branch `2.0.6`, in commit locali **non
+pushati**, uno per voce, senza trailer di co-autore.
+
+### Fatto
+
+| Voce | Commit | Note |
+| --- | --- | --- |
+| F2 · task asyncio senza riferimento | `65e3683` | `loop_runner.spawn()`; usato in `core/base.py`, `core/progress.py`, `extensions/provider.py` |
+| F4 · errori mypy | `65ca900` | 0 errori; il passo mypy in CI ora è **bloccante** |
+| F5 · XML remoto | `962a344` | `core/safe_xml.py` rifiuta DTD/entità; nessuna dipendenza nuova |
+| F3 · I/O bloccante (parte) | `3e6f280` | `/api/browse-folder` e `/api/media` ora `def` (threadpool); unione dei segmenti in `to_thread` |
+| F8 · strumenti | `2c6759a` | CI su 3.11–3.14 + macOS; hook `black` a 26.10.1 |
+| `fetch()` sincrono per le estensioni | `c9d2d1d` | vedi §8 |
+| Ricerca su sorgenti solo-ricerca | `2842669` | vedi §8 |
+| Jellyfin: lacune piccole | `a2c6d4c` | vedi §9 |
+| Fase 0 · voce 3: contratto `id`/handler/classi | `6c9011f` | `tests/test_frontend_dom_contract.py` + inventario `tests/frontend_dom_contract.json`; unico orfano noto `hc-summary` |
+| Fase 0 · voce 2: snapshot TUI | `6ad44f5` | `tests/visual/capture_tui.py` → 48 SVG in `tests/visual/baseline/tui` (12 pannelli × 80×24/120×40 × scuro/chiaro), HOME temporanea, deterministico; `--compare` per il confronto |
+| J1 · rete locale per estensione | `f5abb18` | `extensions/local_network.py`, interruttore in Settings → Extensions, API admin `set_extension_local_network` |
+| J2 · testi dalle estensioni | `00b7ac3` | `core/extension_lyrics.py`, provider `ext:` in GUI/TUI/CLI |
+| J3 · home feed in Explore | `33c8b4a` | `core/extension_home_feed.py`; selettore "Feed" in Explore (Spotify + ogni estensione con `capabilities.homeFeed`); ogni item ha un `open_url` apribile dal campo URL |
+| J4 · link di sorgenti proprie + flag skip | `641c0b1` | i link che nessun client integrato legge (Jellyfin `…/web/#/details?id=`) vanno all'estensione che li rivendica; le tracce portano `extra_info.source_extension`; `download_one_async` rispetta `skipBuiltInFallback` e `skipMetadataEnrichment` |
+| Campi impostazioni estensione troppo stretti | `e26ecc5` | il Server URL di Jellyfin era largo 25px |
+| Testi Now Playing che escono a destra | `f69b231` | un pezzo temporizzato con più parole ora è diviso parola per parola (vedi sotto) |
+| Fase 0 · voce 1: screenshot GUI | `0ceeb2b` | `tests/visual/capture_gui.py` → 60 PNG in `tests/visual/baseline/gui` (10 viste × desktop/768/375 × scuro/chiaro); Playwright + Chrome installato, extra `visual`; `--compare` tollera il rumore di antialiasing |
+
+| Fase 1 · voce 4: `style=""` di `index.html` → classi | `105738d` | 164 dei 217 stili inline diventano utility `u-*` (una dichiarazione ciascuna, in fondo a `styles.css`, selettore raddoppiato `.u-x.u-x` per battere regole come `.s-row input`). Restano inline le proprietà che `app.js` azzera (`display`, `opacity`, …) e 11 dichiarazioni che una regola con id o attributo altrimenti sovrascriverebbe; `tests/test_frontend_inline_styles.py` impedisce di aggiungerne |
+| Fase 1 · voce 5: stili scritti da `app.js` | `e5fcef9` | gli `style=""` nel markup generato diventano le stesse utility; i colori con un significato diventano `data-tone` (`muted`/`ok`/`warn`/`error`) e il bordo di Save `data-dirty`. Restano scritti da JS solo valori calcolati o di comportamento: mostra/nascondi, larghezze delle barre, moto dei testi, copertine |
+| Fase 1 · voce 6: colori fuori token | `8447302` | `branding_tokens.FIXED` + `FIXED_CHANNELS`: inchiostro e tinte di Now Playing, codice a barre, glifi; trasparenze come `rgba(var(--media-ink-rgb), .62)`. Test: nessun colore crudo nelle dichiarazioni (maschere escluse) |
+
+**Fase 0 completa.** J1–J4 e il fix dei testi sono stati verificati nel
+browser (GUI web su una cartella dati di prova): interruttore J1 acceso e
+salvato; `ext:jellyfin` primo e spuntato dopo un ricaricamento; Explore con
+Apple Music mostra 15 sezioni e il clic apre l'album (17 tracce); con
+Jellyfin senza server compare un'unica scheda d'errore. **Mai provato con un
+server Jellyfin vero**: login, ricerca, apertura di un link, download
+`ORIGINAL`, testi e feed restano da verificare in quest'ordine.
+
+**Fix dei testi.** Una riga sincronizzata con un solo timestamp
+(`[00:51.00]<00:51.00>I don't know how … stay`) diventava un unico `span`:
+la copia bianca sovrapposta (`::after`, `white-space: pre`) restava su una
+riga e usciva dal bordo, quella grigia sotto andava a capo. Ora
+`npSplitTimedPhrases()` divide i pezzi in parole e ripartisce il tempo; un
+pezzo senza fine propria dura ~0,32 s a parola, mai oltre la riga dopo.
+Per il redesign: il componente "riga di testo" deve nascere con questa
+regola (una parola = un elemento).
+
+### Non fatto — da qui si riparte
+
+0. **Fase 2 · voce 7 — mock pronti, in attesa di approvazione.** In
+ `docs/mocks/`: `home.html` (Home con album aperto), `queue.html` (Coda con
+ pannello falliti), `tui-download.html` (pannello Download della TUI a
+ 120×40, generato da `make_tui_mock.py`), tutti su `console.css`, che
+ contiene la proposta di valori per i token v2 (grafite `#0d0d0d`…`#242424`,
+ segnale `#ffb000`, stato `#5ec46b`/`#ff5a4f`). Si aprono servendo la radice
+ del repo (`python -m http.server`, configurazione `mocks` in
+ `.claude/launch.json`). Regole emerse disegnando: la qualità per riga è una
+ lettura grigia, l'ambra resta a album, selezione, download in corso e
+ azione primaria (una sola per schermata); gli errori stanno in un solo
+ pannello "Failed". Jellyfin compare nel rail come *Library → Server*.
+1. **Fase 1 completa** (voci 4–6) — l'aspetto è identico per costruzione:
+ stili calcolati di ogni elemento di `index.html` senza JS (1472 elementi ×
+ desktop/telefono × scuro/chiaro) e della GUI in esecuzione (ogni vista e
+ scheda delle impostazioni, 52.449 stati) confrontati prima/dopo con 0
+ differenze; screenshot uguali alla baseline. Copertura a runtime: 31 dei
+ 107 tag generati da `app.js` sono comparsi nel giro; per gli altri un
+ controllo statico delle regole concorrenti ha trovato un solo caso reale
+ (input password delle impostazioni estensione), lasciato inline.
+ **Prossimo:** le voci di §7.4 previste "dentro le Fasi 0–1" e non ancora
+ fatte — F1 (CSP: prima `data-action` al posto dei 166 `onclick=`), M6
+ (stringhe in un dizionario), M2 (`create_app`), M4 (test JS con
+ `node:test`) — oppure direttamente la Fase 2 (mock, token v2,
+ styleguide).
+ Suite completa dopo la Fase 1: 2253 passati, 357 saltati.
+ **Lasciato apposta:** colori dentro `app.js` (tinta di default di Now
+ Playing in `npApplyColors`, `theme-color`), da portare ai token nella
+ voce 8.
+2. **§2 tutta decisa** (2026-11-12): direzione A, JetBrains Mono come unico
+ font, segnale ambra, logo invariato fino alla Fase 5, tema chiaro
+ derivato. Nessuna scelta aperta prima della Fase 2.
+3. **F1, F6, F7, F9, M1–M6, N1–N5** — non iniziati.
+4. **F3, resto**: restano ~30 avvisi `ASYNC240`, quasi tutti un singolo
+ `exists()`/`getsize()` su disco locale. Lasciati di proposito.
+5. **Jellyfin con un server vero** — vedi sopra.
+6. **Note emerse:** a 80×24 il pannello History della TUI taglia il testo a
+ destra (si vede negli snapshot). `mypy mornye` nella venv locale 3.14
+ si ferma su uno stub di `numpy` (`python_version = "3.11"`); con
+ `--python-version 3.12` resta 1 errore preesistente in
+ `core/hires_check.py:469`. `hc-summary` è aggiornato da `app.js` ma non
+ esiste più nell'HTML. Suite completa dopo J4: 2249 passati, 357 saltati
+ (l'unico fallimento era un test da aggiornare, fatto nello stesso commit).
+
+### Da sapere prima di toccare qualcosa
+
+- **Verifiche da rifare su CI.** Le modifiche a `ci.yml` (matrice 3.13/3.14,
+ job macOS, mypy bloccante) non sono mai girate: `mypy` e `black` sono stati
+ provati solo in locale (Python 3.14, black 26.5.1 contro il ≥26.10.1 che la
+ CI installa). Al primo push va guardato l'esito.
+- **28 file di test sono saltati di default** (`tests/conftest.py`,
+ `_OPTIONAL_UTILITIES_TESTS`): girano solo con
+ `MORNYE_TEST_REAL_EXT_DIR=1` e usano le estensioni installate in
+ `~/.mornye/extensions`. Con quel flag, **5 test di
+ `tests/test_search_mixin.py` falliscono già da prima** di questo lavoro
+ (verificato con `git stash`): da indagare a parte.
+- **Hook del progetto (GateGuard):** il primo `Bash` e ogni `Write` di un
+ file nuovo vengono rifiutati finché non si dichiarano i "fatti" richiesti;
+ basta riproporre la stessa operazione dopo averli scritti.
+
+---
+
+## 1. Da dove si parte
+
+| Pezzo | File | Righe | Nota |
+| --- | --- | --- | --- |
+| Stile GUI | `frontend/styles.css` | 4905 | 45 `@media`/`@container`, 27 `!important`, strati di rebrand successivi |
+| Markup GUI | `frontend/index.html` | 1871 | 10 viste, 215 `style=""` inline, 166 `onclick=` |
+| Logica GUI | `frontend/app.js` | 9761 | ~580 punti che toccano classi/stili/`innerHTML` |
+| Stile TUI | `tui/mornye.tcss` | 723 | grammatica MovieBox-Tui |
+| Vocabolario TUI | `tui/branding.py` | 390 | wordmark, titoli pannello, badge, hint |
+| Shell TUI | `tui/app.py` | 980 | 12 pannelli, 8+ temi |
+| Token condivisi | `branding_tokens.py` | 131 | solo colori; `tests/test_brand_tokens.py` li tiene allineati al CSS |
+
+Look attuale: "warm dark music app" — neri caldi, teal del logo, ambra,
+chrome traslucido, Fraunces + Inter + JetBrains Mono. TUI: wordmark a blocchi,
+card arrotondate, badge pieni.
+
+Cosa rende costoso un restyling oggi, e va quindi sistemato **prima** di
+disegnare:
+
+1. **Lo stile è in tre posti.** CSS, attributi `style=""` nell'HTML e
+ stringhe HTML generate da `app.js`. Un foglio nuovo non li raggiunge tutti.
+2. **I token coprono solo il colore.** Spaziature, raggi, scala tipografica,
+ ombre e durate sono numeri sparsi: cambiarli significa cercarli uno a uno.
+3. **Nessuna rete visiva.** I test verificano il cablaggio
+ (`test_frontend_new_ui_wiring.py`) e il vocabolario TUI (`test_tui_look.py`),
+ non l'aspetto. Una regressione di layout passa inosservata.
+
+---
+
+## 2. Direzione visiva — **scelta: A · Console da studio** (2026-11-12)
+
+Mornye come un apparecchio hi-fi da rack: grafite, un solo colore segnale,
+dati tecnici letti su un display. Densa, piatta, precisa. Scelta perché si
+traduce quasi 1:1 in un terminale: GUI e TUI diventano la stessa interfaccia
+in due materiali. (Scartate: "Copertina di disco", chiara e guidata
+dall'artwork; "Nativo morbido", troppo vicina all'attuale.)
+
+### Regole comuni
+
+| Aspetto | Regola |
+| --- | --- |
+| Neutri | Grafite senza dominante (né calda né blu), 4 livelli di superficie |
+| Colore | **Un solo segnale** per focus, selezione, azione primaria, livello attivo. Rosso/verde solo come stato, mai come decorazione |
+| Separazione | Filetti da 1px. Niente card in rilievo, ombre, blur, gradienti d'ambiente |
+| Forma | Raggio 2–3px. Griglia a 4px, righe da 32px |
+| Testo | **JetBrains Mono per tutto** — interfaccia, titoli, numeri e dati tecnici (deciso dall'utente, 2026-11-12). Inter e Fraunces escono, con i loro file in `assets/fonts/`. La gerarchia si fa con peso, dimensione e maiuscoletto, non cambiando famiglia |
+| Etichette | Sezioni in maiuscoletto spaziato, come le serigrafie di un pannello |
+| Movimento | ≤120ms, solo cambi di stato. Niente animazioni d'ingresso |
+
+Tre elementi firma, identici nei due mondi:
+
+1. **Il display di qualità** — `24 / 96 · FLAC` sul colore segnale,
+ al posto dei badge colorati (Hi-Res, Atmos, Lossless).
+2. **Il misuratore a segmenti** — l'avanzamento è una barra a tacche tipo
+ LED (`▮▮▮▮▯▯▯▯`), non un riempimento liscio.
+3. **L'etichetta di pannello** — il titolo sta *nel* filetto del bordo.
+
+### Solo GUI
+Rail di navigazione stretto con icone + etichetta, titlebar e dock opachi.
+L'artwork resta l'unica immagine a colori: quadrata, senza raggio, senza
+alone.
+
+### Solo TUI
+Via il wordmark a blocchi: una riga d'intestazione `MORNYE 2.0.6 ── stato`.
+Bordi a linea singola squadrati (non arrotondati), badge come letture tra
+parentesi nel colore segnale invece che a fondo pieno, hint in basso
+invariati nella forma `[key] action`.
+
+### Default — **confermati dall'utente** (2026-11-12)
+- **Segnale: ambra** (tipo display al fosforo, ~`#ffb000`), non il teal
+ attuale — altrimenti il cambio non si percepisce. Valori definitivi alla
+ voce 8, sotto test di contrasto.
+- **Logo:** resta com'è fino alla Fase 5; lì si valuta una versione mono.
+- **Tema chiaro:** derivato ("carta"), disegnato dopo lo scuro ma coperto
+ dagli stessi test.
+- **Accenti selezionabili (`ACCENTS`):** restano, ma cambiano solo il
+ segnale. **Temi TUI di terze parti:** restano, sono gratis in Textual.
+
+---
+
+## 3. Architettura del nuovo stile
+
+**Token v2 — `branding_tokens.py` resta l'unica fonte.** Si estende oltre il
+colore: `space-*`, `radius-*`, scala tipografica, `elevation-*`, `motion-*`.
+Due livelli: primitivi (`grey-900`) e semantici (`surface`, `text-muted`,
+`signal`). GUI e TUI consumano solo i semantici. Il test di parità
+Python ↔ CSS e quello di contrasto WCAG AA restano e si allargano ai token
+nuovi.
+
+**GUI — CSS a strati, sempre senza build.** `styles.css` viene sostituito da
+file piccoli caricati in ordine con `@layer`:
+
+```
+frontend/css/tokens.css ← specchio di branding_tokens.py
+frontend/css/base.css ← reset, tipografia, focus, scrollbar
+frontend/css/layout.css ← shell: rail, titlebar, dock, player bar
+frontend/css/components.css ← bottoni, input, chip, tabelle, modali, toast
+frontend/css/views/*.css ← una per vista
+```
+
+Vincolo: gli `id` e i nomi di funzione che `app.js` usa sono un **contratto**
+e non cambiano; cambiano classi, struttura della shell e tutto il CSS.
+
+**TUI — stessa regola.** `mornye.tcss` riscritto da zero sopra i token;
+`branding.py` ridisegna i quattro elementi d'identità (wordmark, titolo
+pannello, badge, hint). Restano intatti i comportamenti: fallback ASCII,
+`NO_COLOR`, layout compatto 80×24, log affiancato/impilato.
+
+---
+
+## 4. Fasi
+
+Ogni voce numerata è un commit a sé.
+
+### Fase 0 · Rete di sicurezza (nessun cambio visibile)
+1. Script che cattura le 10 viste GUI × scuro/chiaro × 3 larghezze
+ (finestra desktop, 768, 375) → baseline in `tests/visual/`.
+2. Snapshot SVG dei 12 pannelli TUI a 80×24 e 120×40 (`tui_harness.py` +
+ `App.export_screenshot`).
+3. Inventario del contratto: `id`/classi che `app.js` legge o scrive, salvato
+ come test (fallisce se l'HTML ne perde uno).
+
+### Fase 1 · Bonifica (look invariato, verificato con la Fase 0)
+4. I 215 `style=""` di `index.html` diventano classi.
+5. Gli stili scritti da `app.js` (`el.style.*`, HTML in stringa con colori)
+ diventano classi/attributi di stato (`data-state="failed"`).
+6. Colori fuori token in `styles.css` ricondotti ai token.
+
+### Fase 2 · Direzione e design system
+7. Mock statici di 3 schermate chiave (Home con album aperto, Coda, pannello
+ Download della TUI) nella direzione scelta → approvazione.
+8. Token v2 in `branding_tokens.py` + `tokens.css` + temi Textual + test.
+9. `frontend/styleguide.html` (solo sviluppo): tutti i componenti in tutti gli
+ stati, scuro e chiaro. È il banco di prova prima di toccare le viste.
+
+### Fase 3 · GUI
+10. Shell: rail di navigazione, titlebar, dock download, player bar.
+11. Home / ricerca / album / tabella tracce (la vista più usata).
+12. Coda + pannello falliti · 13. History · 14. Settings (670 righe di
+ markup: va riorganizzata, non solo ridipinta) · 15. Local · 16. Following
+ · 17. Explore · 18. Stats · 19. Player e Now Playing · 20. Logs.
+21. Toast, modali, stati vuoti, skeleton, `prefers-reduced-motion`.
+22. Rimozione di `styles.css` e di ogni residuo; aggiornamento `sw.js`,
+ `manifest.json`, `MANIFEST.in`/package-data.
+
+### Fase 4 · TUI
+23. `branding.py`: nuovo wordmark, titoli, badge, hint (+ fallback ASCII).
+24. `mornye.tcss` nuovo: frame, sidebar, form, tabelle, barre, modali.
+25. Passata sui 12 pannelli a 80×24, 120×40 e terminale "plain".
+26. Elenco temi rivisto; `test_tui_look.py` riscritto dove descrive il
+ vecchio vocabolario, lasciato dove descrive capacità.
+
+### Fase 5 · Contorno
+27. `desktop_branding.py`, icone, `theme_color`, eventuale nuovo logo.
+28. Migrazione accento/tema per chi aggiorna (già esiste `migrateAccent`).
+29. `docs/site.css`, screenshot di README e guide, `CHANGELOG.md`.
+
+### Fase 6 · Verifica
+30. Confronto visivo completo, contrasto AA, navigazione da tastiera e focus.
+31. pywebview su macOS (WKWebView), Windows (WebView2), Linux (WebKitGTK);
+ `--web` in Docker; PWA su telefono.
+
+---
+
+## 5. Rischi
+
+- **HTML generato da `app.js`.** È dove un foglio nuovo lascia buchi. Le
+ voci 3 e 5 esistono per questo; saltarle sposta il costo nella Fase 3.
+- **Tutto in monospazio.** Con JetBrains Mono ovunque il testo occupa più
+ larghezza di Inter: titoli lunghi, colonne della tabella tracce e la
+ Settings vanno provati presto (voce 9, styleguide) alle larghezze strette,
+ con troncamento e `font-variant-ligatures: none` nei dati.
+- **WebKitGTK datato.** `@layer` e le container query (già in uso) non ci
+ sono nelle versioni vecchie: va fissata la versione minima supportata su
+ Linux prima della Fase 3.
+- **Test che leggono il CSS.** `test_brand_tokens.py` analizza `styles.css`:
+ va aggiornato nello stesso commit che sposta i file, e vanno cercati altri
+ test che citano quel percorso.
+- **Transizione.** Le Fasi 0–1 possono uscire in una 2.0.x; dalla 2 in poi
+ conviene un branch dedicato e un rilascio unico: una GUI metà vecchia e
+ metà nuova è peggio di entrambe.
+
+## 6. Dove sta il lavoro
+
+Fasi 0–1: piccole ma noiose. Fase 2: breve, è dove si decide tutto. Fase 3:
+il grosso (Settings e Home da sole circa metà). Fase 4: circa un quarto
+della 3. Fasi 5–6: rifinitura.
+
+---
+
+## 7. Oltre il look: fix, miglioramenti, nuove funzioni
+
+Da una scansione statica del 2026-11-12: `ruff` con regole allargate
+(B, S, ASYNC, PERF, SIM, UP, BLE, PLW, RUF, C90), `mypy`, `black --check`,
+ricerche mirate su frontend e TUI. **Non** ho avviato l'app né eseguito la
+suite (2547 test raccolti): le voci sotto sono ciò che il codice mostra, non
+bug riprodotti. `ruff` e `black` con la configurazione attuale sono puliti;
+nessun `TODO`/`FIXME` nel codice.
+
+### 7.1 Fix
+
+| # | Cosa | Dove | Quando |
+| --- | --- | --- | --- |
+| F1 | **Nessuna Content-Security-Policy né header di sicurezza** su `--web` (nessuna occorrenza in `mornye/`). Oggi una CSP è impossibile: 166 `onclick=` inline, script inline, `document.write` per il cache-busting | `webapp.py`, `index.html` | Fase 1: delega eventi con `data-action`, versione degli asset dal backend; poi CSP + `nosniff` + `frame-ancestors` |
+| F2 | **3 task asyncio lanciati senza tenerne il riferimento**: possono essere raccolti dal GC a metà e le loro eccezioni spariscono | `core/base.py:75`, `core/progress.py:1115`, `extensions/provider.py:394` | Subito, indipendente |
+| F3 | **I/O su disco bloccante dentro funzioni async** (33 punti): su un NAS lento ferma l'event loop, e con lui il WebSocket dei progressi | `webapp.py:1094-1169` (9), `download_engine.py` (5), `core/animated_artwork.py` (5 + `open()` a :198), `extensions/provider.py:1360` | Subito, indipendente; `webapp.py` per primo |
+| F4 | **10 errori mypy rimasti** (il commento in `pyproject.toml` ne cita ~177). I due che contano: tipo di ritorno sbagliato a `extensions/provider.py:700`, `None` passato come `str` a `extensions/runtime.py:463` | 7 file | Subito; poi mypy diventa bloccante in CI |
+| F5 | XML remoto letto con `ElementTree` della stdlib (risposte WebDAV, TTML dei testi) | `core/webdav.py:155`, `core/apple_ttml.py:227` | **Fatto** — `core/safe_xml.py` rifiuta DTD ed entità, senza nuova dipendenza |
+| F6 | **530 `except Exception`, 70 dei quali con solo `pass`**. `app.py` ne ha 58: è l'API della GUI, quindi errori che dovrebbero diventare un toast spariscono | `app.py` per primo | Fase 3, vista per vista: almeno un log a debug, e un errore visibile dove l'utente aspetta un esito |
+| F7 | **Accessibilità GUI**: 19 `div`/`span` cliccabili non raggiungibili da tastiera, 8 `outline: none`, 6 `` senza `alt`, 11 `confirm()` nativi (in pywebview escono fuori stile) | `index.html`, `app.js`, `styles.css` | Fasi 2–3: nascono corretti nei componenti nuovi (bottone, modale di conferma) |
+| F8 | **Strumenti disallineati**: pre-commit fissa `black` 24.0 ma `dev` richiede ≥26.10.1; la CI prova solo 3.11/3.12 mentre i classifier dichiarano 3.13 e 3.14; nessun job macOS per un'app desktop | `.pre-commit-config.yaml`, `ci.yml` | Subito |
+| F9 | Il tema scelto con `t` nella TUI non risulta salvato da nessuna parte: da verificare a mano, e nel caso persisterlo | `tui/app.py:792` | Fase 4, voce 26 |
+
+Controllato e **non** un problema: i 10 `innerHTML` con interpolazione non
+passata da `escHtml` contengono solo numeri o testo fisso; i 5 avvisi B023
+in `network_destinations.py`/`webdav.py` sono lambda eseguite subito.
+
+### 7.2 Miglioramenti
+
+| # | Cosa | Quando |
+| --- | --- | --- |
+| M1 | **`app.js` in moduli ES per vista** (oggi 9761 righe, 396 funzioni, 123 variabili globali). `
-
+
+
+
+
Select Folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
—
+
+
+
+
+
+
+
+
+
+
+ 0:00
+
+ 0:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🎵
+
+
E
+
+
+
+
0:00-0:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\n"
- '\n'
- )
- # Before the first app script, whatever its cache-busting version:
- # matching one exact `?v=` string silently dropped the shim (and with
- # it every API call) the first time index.html bumped the version.
- html = re.sub(
- r'()',
- lambda m: inject + m.group(1),
- html,
- count=1,
+ '\n'
)
+ # Before the first app script. index.html marks the spot with a
+ # comment, because matching the script tag itself silently dropped the
+ # shim (and with it every API call) whenever that tag changed shape —
+ # a bumped `?v=` once, the timestamped loader another time.
+ marker = "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ download 120x40
+
+
+
+
+
+
+
+
+
+
+MORNYE
+ v0.0.0
+╭─ * Modes ──────────────╮╭─ Configuration ──────────────────────────────────────────────────────────────────────────╮
+││││
+│█││▼ Source│
+│█ v Download│││
+│█││+URL│
+│││╭──────────────────────────────────────────────────────────────────────╮│
+│ ? Search│││https://open.spotify.com/album/example││
+│││╰──────────────────────────────────────────────────────────────────────╯│
+││││
+│ # Tracks││+CSV track listwins over the URL│
+│││╭──────────────────────────────────────────────────────────────────────╮│
+││││a .csv or .tsv on this machine││
+│ Q Queue││╰──────────────────────────────────────────────────────────────────────╯▃│
+││││
+│││╭──────────────────────────╮│
+│ = Live│││ Browse for a track list… ││
+│││╰──────────────────────────╯│
+││││
+│ h History│││
+│││▼ Destination│
+││││
+│ @ Session││+Folder│
+│││╭──────────────────────────────────────────────────────────────────────╮│
+││││/Music/Mornye││
+│ - Library││╰──────────────────────────────────────────────────────────────────────╯│
+││││
+│││Exact file path│
+│ + Extensions││╭──────────────────────────────────────────────────────────────────────╮│
+│▂│││leave blank to name files normally││
+│││╰──────────────────────────────────────────────────────────────────────╯│
+│ ! Health│││
+││││
+││││
+││││
+╰────────────────────────╯╰───────────────────────────────────────────────────────────────────── [ Ctrl+R to run ] ─╯
+▌i Pick a URL and press Ctrl+R.
+[Ctrl+R] Run [Ctrl+C] Stop [/] Search [Ctrl+Y] Copy CLI [Ctrl+L] Log [Ctrl+O] Copy log [?] Help
+
+
+
diff --git a/tests/visual/baseline/tui/00-download_120x40_mornye-light.svg b/tests/visual/baseline/tui/00-download_120x40_mornye-light.svg
new file mode 100644
index 0000000..9b6bf8f
--- /dev/null
+++ b/tests/visual/baseline/tui/00-download_120x40_mornye-light.svg
@@ -0,0 +1,226 @@
+
diff --git a/tests/visual/baseline/tui/00-download_80x24_mornye-dark.svg b/tests/visual/baseline/tui/00-download_80x24_mornye-dark.svg
new file mode 100644
index 0000000..4c0f40e
--- /dev/null
+++ b/tests/visual/baseline/tui/00-download_80x24_mornye-dark.svg
@@ -0,0 +1,161 @@
+
diff --git a/tests/visual/baseline/tui/00-download_80x24_mornye-light.svg b/tests/visual/baseline/tui/00-download_80x24_mornye-light.svg
new file mode 100644
index 0000000..790f807
--- /dev/null
+++ b/tests/visual/baseline/tui/00-download_80x24_mornye-light.svg
@@ -0,0 +1,161 @@
+
diff --git a/tests/visual/baseline/tui/01-search_120x40_mornye-dark.svg b/tests/visual/baseline/tui/01-search_120x40_mornye-dark.svg
new file mode 100644
index 0000000..fd579ed
--- /dev/null
+++ b/tests/visual/baseline/tui/01-search_120x40_mornye-dark.svg
@@ -0,0 +1,223 @@
+
diff --git a/tests/visual/baseline/tui/01-search_120x40_mornye-light.svg b/tests/visual/baseline/tui/01-search_120x40_mornye-light.svg
new file mode 100644
index 0000000..73f526d
--- /dev/null
+++ b/tests/visual/baseline/tui/01-search_120x40_mornye-light.svg
@@ -0,0 +1,223 @@
+
diff --git a/tests/visual/baseline/tui/01-search_80x24_mornye-dark.svg b/tests/visual/baseline/tui/01-search_80x24_mornye-dark.svg
new file mode 100644
index 0000000..e16a86e
--- /dev/null
+++ b/tests/visual/baseline/tui/01-search_80x24_mornye-dark.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/01-search_80x24_mornye-light.svg b/tests/visual/baseline/tui/01-search_80x24_mornye-light.svg
new file mode 100644
index 0000000..cc38f92
--- /dev/null
+++ b/tests/visual/baseline/tui/01-search_80x24_mornye-light.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/02-tracks_120x40_mornye-dark.svg b/tests/visual/baseline/tui/02-tracks_120x40_mornye-dark.svg
new file mode 100644
index 0000000..077f21f
--- /dev/null
+++ b/tests/visual/baseline/tui/02-tracks_120x40_mornye-dark.svg
@@ -0,0 +1,224 @@
+
diff --git a/tests/visual/baseline/tui/02-tracks_120x40_mornye-light.svg b/tests/visual/baseline/tui/02-tracks_120x40_mornye-light.svg
new file mode 100644
index 0000000..694eca3
--- /dev/null
+++ b/tests/visual/baseline/tui/02-tracks_120x40_mornye-light.svg
@@ -0,0 +1,224 @@
+
diff --git a/tests/visual/baseline/tui/02-tracks_80x24_mornye-dark.svg b/tests/visual/baseline/tui/02-tracks_80x24_mornye-dark.svg
new file mode 100644
index 0000000..97e22b4
--- /dev/null
+++ b/tests/visual/baseline/tui/02-tracks_80x24_mornye-dark.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/02-tracks_80x24_mornye-light.svg b/tests/visual/baseline/tui/02-tracks_80x24_mornye-light.svg
new file mode 100644
index 0000000..51f1112
--- /dev/null
+++ b/tests/visual/baseline/tui/02-tracks_80x24_mornye-light.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/03-staged_120x40_mornye-dark.svg b/tests/visual/baseline/tui/03-staged_120x40_mornye-dark.svg
new file mode 100644
index 0000000..e6506f3
--- /dev/null
+++ b/tests/visual/baseline/tui/03-staged_120x40_mornye-dark.svg
@@ -0,0 +1,227 @@
+
diff --git a/tests/visual/baseline/tui/03-staged_120x40_mornye-light.svg b/tests/visual/baseline/tui/03-staged_120x40_mornye-light.svg
new file mode 100644
index 0000000..9a6dbd4
--- /dev/null
+++ b/tests/visual/baseline/tui/03-staged_120x40_mornye-light.svg
@@ -0,0 +1,227 @@
+
diff --git a/tests/visual/baseline/tui/03-staged_80x24_mornye-dark.svg b/tests/visual/baseline/tui/03-staged_80x24_mornye-dark.svg
new file mode 100644
index 0000000..14d9b74
--- /dev/null
+++ b/tests/visual/baseline/tui/03-staged_80x24_mornye-dark.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/03-staged_80x24_mornye-light.svg b/tests/visual/baseline/tui/03-staged_80x24_mornye-light.svg
new file mode 100644
index 0000000..8386819
--- /dev/null
+++ b/tests/visual/baseline/tui/03-staged_80x24_mornye-light.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/04-queue_120x40_mornye-dark.svg b/tests/visual/baseline/tui/04-queue_120x40_mornye-dark.svg
new file mode 100644
index 0000000..ce1b855
--- /dev/null
+++ b/tests/visual/baseline/tui/04-queue_120x40_mornye-dark.svg
@@ -0,0 +1,222 @@
+
diff --git a/tests/visual/baseline/tui/04-queue_120x40_mornye-light.svg b/tests/visual/baseline/tui/04-queue_120x40_mornye-light.svg
new file mode 100644
index 0000000..a788baa
--- /dev/null
+++ b/tests/visual/baseline/tui/04-queue_120x40_mornye-light.svg
@@ -0,0 +1,222 @@
+
diff --git a/tests/visual/baseline/tui/04-queue_80x24_mornye-dark.svg b/tests/visual/baseline/tui/04-queue_80x24_mornye-dark.svg
new file mode 100644
index 0000000..56dbd32
--- /dev/null
+++ b/tests/visual/baseline/tui/04-queue_80x24_mornye-dark.svg
@@ -0,0 +1,155 @@
+
diff --git a/tests/visual/baseline/tui/04-queue_80x24_mornye-light.svg b/tests/visual/baseline/tui/04-queue_80x24_mornye-light.svg
new file mode 100644
index 0000000..f85a4e3
--- /dev/null
+++ b/tests/visual/baseline/tui/04-queue_80x24_mornye-light.svg
@@ -0,0 +1,155 @@
+
diff --git a/tests/visual/baseline/tui/05-history_120x40_mornye-dark.svg b/tests/visual/baseline/tui/05-history_120x40_mornye-dark.svg
new file mode 100644
index 0000000..f436abb
--- /dev/null
+++ b/tests/visual/baseline/tui/05-history_120x40_mornye-dark.svg
@@ -0,0 +1,226 @@
+
diff --git a/tests/visual/baseline/tui/05-history_120x40_mornye-light.svg b/tests/visual/baseline/tui/05-history_120x40_mornye-light.svg
new file mode 100644
index 0000000..0289d87
--- /dev/null
+++ b/tests/visual/baseline/tui/05-history_120x40_mornye-light.svg
@@ -0,0 +1,226 @@
+
diff --git a/tests/visual/baseline/tui/05-history_80x24_mornye-dark.svg b/tests/visual/baseline/tui/05-history_80x24_mornye-dark.svg
new file mode 100644
index 0000000..c0a2f8c
--- /dev/null
+++ b/tests/visual/baseline/tui/05-history_80x24_mornye-dark.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/05-history_80x24_mornye-light.svg b/tests/visual/baseline/tui/05-history_80x24_mornye-light.svg
new file mode 100644
index 0000000..52803ca
--- /dev/null
+++ b/tests/visual/baseline/tui/05-history_80x24_mornye-light.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/06-session_120x40_mornye-dark.svg b/tests/visual/baseline/tui/06-session_120x40_mornye-dark.svg
new file mode 100644
index 0000000..c9753c6
--- /dev/null
+++ b/tests/visual/baseline/tui/06-session_120x40_mornye-dark.svg
@@ -0,0 +1,225 @@
+
diff --git a/tests/visual/baseline/tui/06-session_120x40_mornye-light.svg b/tests/visual/baseline/tui/06-session_120x40_mornye-light.svg
new file mode 100644
index 0000000..11021f0
--- /dev/null
+++ b/tests/visual/baseline/tui/06-session_120x40_mornye-light.svg
@@ -0,0 +1,225 @@
+
diff --git a/tests/visual/baseline/tui/06-session_80x24_mornye-dark.svg b/tests/visual/baseline/tui/06-session_80x24_mornye-dark.svg
new file mode 100644
index 0000000..d477bf7
--- /dev/null
+++ b/tests/visual/baseline/tui/06-session_80x24_mornye-dark.svg
@@ -0,0 +1,160 @@
+
diff --git a/tests/visual/baseline/tui/06-session_80x24_mornye-light.svg b/tests/visual/baseline/tui/06-session_80x24_mornye-light.svg
new file mode 100644
index 0000000..9fb76ec
--- /dev/null
+++ b/tests/visual/baseline/tui/06-session_80x24_mornye-light.svg
@@ -0,0 +1,160 @@
+
diff --git a/tests/visual/baseline/tui/07-library_120x40_mornye-dark.svg b/tests/visual/baseline/tui/07-library_120x40_mornye-dark.svg
new file mode 100644
index 0000000..60c6f3d
--- /dev/null
+++ b/tests/visual/baseline/tui/07-library_120x40_mornye-dark.svg
@@ -0,0 +1,225 @@
+
diff --git a/tests/visual/baseline/tui/07-library_120x40_mornye-light.svg b/tests/visual/baseline/tui/07-library_120x40_mornye-light.svg
new file mode 100644
index 0000000..2a3d259
--- /dev/null
+++ b/tests/visual/baseline/tui/07-library_120x40_mornye-light.svg
@@ -0,0 +1,225 @@
+
diff --git a/tests/visual/baseline/tui/07-library_80x24_mornye-dark.svg b/tests/visual/baseline/tui/07-library_80x24_mornye-dark.svg
new file mode 100644
index 0000000..ee1c655
--- /dev/null
+++ b/tests/visual/baseline/tui/07-library_80x24_mornye-dark.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/07-library_80x24_mornye-light.svg b/tests/visual/baseline/tui/07-library_80x24_mornye-light.svg
new file mode 100644
index 0000000..7f485fa
--- /dev/null
+++ b/tests/visual/baseline/tui/07-library_80x24_mornye-light.svg
@@ -0,0 +1,159 @@
+
diff --git a/tests/visual/baseline/tui/08-extensions_120x40_mornye-dark.svg b/tests/visual/baseline/tui/08-extensions_120x40_mornye-dark.svg
new file mode 100644
index 0000000..c96b329
--- /dev/null
+++ b/tests/visual/baseline/tui/08-extensions_120x40_mornye-dark.svg
@@ -0,0 +1,226 @@
+
diff --git a/tests/visual/baseline/tui/08-extensions_120x40_mornye-light.svg b/tests/visual/baseline/tui/08-extensions_120x40_mornye-light.svg
new file mode 100644
index 0000000..bc419b4
--- /dev/null
+++ b/tests/visual/baseline/tui/08-extensions_120x40_mornye-light.svg
@@ -0,0 +1,226 @@
+
diff --git a/tests/visual/baseline/tui/08-extensions_80x24_mornye-dark.svg b/tests/visual/baseline/tui/08-extensions_80x24_mornye-dark.svg
new file mode 100644
index 0000000..6096657
--- /dev/null
+++ b/tests/visual/baseline/tui/08-extensions_80x24_mornye-dark.svg
@@ -0,0 +1,160 @@
+
diff --git a/tests/visual/baseline/tui/08-extensions_80x24_mornye-light.svg b/tests/visual/baseline/tui/08-extensions_80x24_mornye-light.svg
new file mode 100644
index 0000000..f53475e
--- /dev/null
+++ b/tests/visual/baseline/tui/08-extensions_80x24_mornye-light.svg
@@ -0,0 +1,160 @@
+
diff --git a/tests/visual/baseline/tui/09-health_120x40_mornye-dark.svg b/tests/visual/baseline/tui/09-health_120x40_mornye-dark.svg
new file mode 100644
index 0000000..ef126eb
--- /dev/null
+++ b/tests/visual/baseline/tui/09-health_120x40_mornye-dark.svg
@@ -0,0 +1,223 @@
+
diff --git a/tests/visual/baseline/tui/09-health_120x40_mornye-light.svg b/tests/visual/baseline/tui/09-health_120x40_mornye-light.svg
new file mode 100644
index 0000000..a89d0e6
--- /dev/null
+++ b/tests/visual/baseline/tui/09-health_120x40_mornye-light.svg
@@ -0,0 +1,223 @@
+
diff --git a/tests/visual/baseline/tui/09-health_80x24_mornye-dark.svg b/tests/visual/baseline/tui/09-health_80x24_mornye-dark.svg
new file mode 100644
index 0000000..b47a744
--- /dev/null
+++ b/tests/visual/baseline/tui/09-health_80x24_mornye-dark.svg
@@ -0,0 +1,156 @@
+
diff --git a/tests/visual/baseline/tui/09-health_80x24_mornye-light.svg b/tests/visual/baseline/tui/09-health_80x24_mornye-light.svg
new file mode 100644
index 0000000..19545f1
--- /dev/null
+++ b/tests/visual/baseline/tui/09-health_80x24_mornye-light.svg
@@ -0,0 +1,156 @@
+
diff --git a/tests/visual/baseline/tui/10-signed_120x40_mornye-dark.svg b/tests/visual/baseline/tui/10-signed_120x40_mornye-dark.svg
new file mode 100644
index 0000000..b9f3dd0
--- /dev/null
+++ b/tests/visual/baseline/tui/10-signed_120x40_mornye-dark.svg
@@ -0,0 +1,224 @@
+
diff --git a/tests/visual/baseline/tui/10-signed_120x40_mornye-light.svg b/tests/visual/baseline/tui/10-signed_120x40_mornye-light.svg
new file mode 100644
index 0000000..a73da74
--- /dev/null
+++ b/tests/visual/baseline/tui/10-signed_120x40_mornye-light.svg
@@ -0,0 +1,224 @@
+
diff --git a/tests/visual/baseline/tui/10-signed_80x24_mornye-dark.svg b/tests/visual/baseline/tui/10-signed_80x24_mornye-dark.svg
new file mode 100644
index 0000000..c33572a
--- /dev/null
+++ b/tests/visual/baseline/tui/10-signed_80x24_mornye-dark.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/10-signed_80x24_mornye-light.svg b/tests/visual/baseline/tui/10-signed_80x24_mornye-light.svg
new file mode 100644
index 0000000..16ff7e0
--- /dev/null
+++ b/tests/visual/baseline/tui/10-signed_80x24_mornye-light.svg
@@ -0,0 +1,157 @@
+
diff --git a/tests/visual/baseline/tui/11-command_120x40_mornye-dark.svg b/tests/visual/baseline/tui/11-command_120x40_mornye-dark.svg
new file mode 100644
index 0000000..8ff0002
--- /dev/null
+++ b/tests/visual/baseline/tui/11-command_120x40_mornye-dark.svg
@@ -0,0 +1,220 @@
+
diff --git a/tests/visual/baseline/tui/11-command_120x40_mornye-light.svg b/tests/visual/baseline/tui/11-command_120x40_mornye-light.svg
new file mode 100644
index 0000000..c2fbd05
--- /dev/null
+++ b/tests/visual/baseline/tui/11-command_120x40_mornye-light.svg
@@ -0,0 +1,220 @@
+
diff --git a/tests/visual/baseline/tui/11-command_80x24_mornye-dark.svg b/tests/visual/baseline/tui/11-command_80x24_mornye-dark.svg
new file mode 100644
index 0000000..05d90e5
--- /dev/null
+++ b/tests/visual/baseline/tui/11-command_80x24_mornye-dark.svg
@@ -0,0 +1,154 @@
+
diff --git a/tests/visual/baseline/tui/11-command_80x24_mornye-light.svg b/tests/visual/baseline/tui/11-command_80x24_mornye-light.svg
new file mode 100644
index 0000000..f9c8e16
--- /dev/null
+++ b/tests/visual/baseline/tui/11-command_80x24_mornye-light.svg
@@ -0,0 +1,154 @@
+
diff --git a/tests/visual/capture_gui.py b/tests/visual/capture_gui.py
new file mode 100644
index 0000000..4a39e74
--- /dev/null
+++ b/tests/visual/capture_gui.py
@@ -0,0 +1,210 @@
+"""Screenshot every GUI view: the visual baseline for the redesign.
+
+Fase 0, voce 1 of ``docs/redesign-plan.md``. Starts ``mornye --web`` against
+an empty, throw-away data folder (nothing from this machine reaches a
+picture), opens it in Chrome through Playwright, and saves the ten views in
+the dark and the light theme at three widths: a desktop window, 768 and 375.
+
+Needs ``pip install playwright`` and Google Chrome (``channel="chrome"``, so
+Playwright downloads no browser of its own). Not part of the test suite:
+the pictures are compared by eye, or by name with ``--compare``, which only
+reports which files differ — pixels move with fonts and anti-aliasing.
+
+ python tests/visual/capture_gui.py # → tests/visual/out/gui
+ python tests/visual/capture_gui.py --views home settings --widths 375
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import hashlib
+import os
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[1]
+
+VIEWS: tuple[str, ...] = (
+ "home",
+ "explore",
+ "player",
+ "queue",
+ "history",
+ "local",
+ "following",
+ "stats",
+ "logs",
+ "settings",
+)
+#: name → (width, height). "desktop" is the default pywebview window.
+WIDTHS: dict[str, tuple[int, int]] = {
+ "desktop": (1280, 800),
+ "768": (768, 1024),
+ "375": (375, 812),
+}
+THEMES: tuple[str, ...] = ("dark", "light")
+
+
+def _free_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _wait_for(port: int, proc: subprocess.Popen, timeout: float = 60.0) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if proc.poll() is not None:
+ raise SystemExit(f"mornye --web exited early ({proc.returncode})")
+ with (
+ contextlib.suppress(OSError),
+ socket.create_connection(("127.0.0.1", port), timeout=1),
+ ):
+ return
+ time.sleep(0.3)
+ raise SystemExit("mornye --web did not start in time")
+
+
+@contextlib.contextmanager
+def _server(home: Path):
+ port = _free_port()
+ env = {
+ **os.environ,
+ "HOME": str(home),
+ "USERPROFILE": str(home),
+ "MORNYE_DATA_DIR": str(home / ".mornye"),
+ "MORNYE_CACHE_DIR": str(home / ".mornye" / ".cache"),
+ "MORNYE_DOWNLOAD_DIR": "/Music/Mornye",
+ "MORNYE_EXT_AUTO_INSTALL": "0",
+ }
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "mornye", "--web", "--port", str(port)],
+ cwd=ROOT,
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ try:
+ _wait_for(port, proc)
+ yield f"http://127.0.0.1:{port}/"
+ finally:
+ proc.terminate()
+ with contextlib.suppress(subprocess.TimeoutExpired):
+ proc.wait(timeout=10)
+
+
+def _capture(url: str, out: Path, views, widths, themes) -> list[Path]:
+ from playwright.sync_api import sync_playwright
+
+ written: list[Path] = []
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(channel="chrome", headless=True)
+ try:
+ for width_name in widths:
+ width, height = WIDTHS[width_name]
+ for theme in themes:
+ page = browser.new_page(
+ viewport={"width": width, "height": height},
+ color_scheme=theme, # type: ignore[arg-type]
+ reduced_motion="reduce",
+ )
+ page.add_init_script(
+ f"try {{ localStorage.setItem('mornye-theme-mode', '{theme}'); }}"
+ " catch (e) {}"
+ )
+ page.goto(url, wait_until="networkidle")
+ page.wait_for_function("typeof switchView === 'function'")
+ # The URL box types its example links out one character
+ # at a time; freeze it, or every Home picture differs.
+ page.evaluate(
+ "() => { clearTimeout(phTimeout); runTypewriter = () => {};"
+ " $('urlInput').placeholder = 'open.spotify.com/track/…'; }"
+ )
+ for view in views:
+ page.evaluate(
+ "([v, t]) => { switchView(v); applyTheme(t); }",
+ [view, theme],
+ )
+ page.wait_for_timeout(600)
+ path = out / f"{view}_{width_name}_{theme}.png"
+ page.screenshot(path=str(path), full_page=False)
+ written.append(path)
+ page.close()
+ finally:
+ browser.close()
+ return written
+
+
+#: Per-channel difference below which a pixel counts as unchanged. Two runs
+#: of the same page differ by a pixel or two of anti-aliasing (measured: at
+#: most 16 of 255); a real change of colour or layout is far above it.
+_NOISE = 16
+#: How many pixels may exceed it before a picture counts as changed.
+_NOISY_PIXELS = 20
+
+
+def _differs(a: Path, b: Path) -> bool:
+ if a.read_bytes() == b.read_bytes():
+ return False
+ try:
+ from PIL import Image, ImageChops
+ except ImportError:
+ return (
+ hashlib.sha256(a.read_bytes()).digest()
+ != hashlib.sha256(b.read_bytes()).digest()
+ )
+ with Image.open(a) as left, Image.open(b) as right:
+ if left.size != right.size:
+ return True
+ diff = ImageChops.difference(left.convert("RGB"), right.convert("RGB"))
+ red, green, blue = diff.split()
+ # Per pixel, the largest of its three channel differences.
+ worst = ImageChops.lighter(ImageChops.lighter(red, green), blue)
+ # A stray anti-aliased pixel or two is noise; a change moves many.
+ return sum(worst.histogram()[_NOISE + 1 :]) > _NOISY_PIXELS
+
+
+def _compare(out: Path, baseline: Path) -> int:
+ changed = []
+ for path in sorted(out.glob("*.png")):
+ ref = baseline / path.name
+ if not ref.exists():
+ changed.append(f"new {path.name}")
+ elif _differs(ref, path):
+ changed.append(f"changed {path.name}")
+ for ref in sorted(baseline.glob("*.png")):
+ if not (out / ref.name).exists():
+ changed.append(f"missing {ref.name}")
+ print("\n".join(changed) or "identical to the baseline")
+ return 1 if changed else 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
+ parser.add_argument("--out", type=Path, default=HERE / "out" / "gui")
+ parser.add_argument("--views", nargs="+", choices=VIEWS, default=list(VIEWS))
+ parser.add_argument(
+ "--widths", nargs="+", choices=list(WIDTHS), default=list(WIDTHS)
+ )
+ parser.add_argument("--themes", nargs="+", choices=THEMES, default=list(THEMES))
+ parser.add_argument("--compare", type=Path, help="baseline folder to diff against")
+ args = parser.parse_args(argv)
+
+ args.out.mkdir(parents=True, exist_ok=True)
+ for old in args.out.glob("*.png"):
+ old.unlink()
+ with tempfile.TemporaryDirectory(prefix="mornye-visual-") as tmp:
+ with _server(Path(tmp)) as url:
+ written = _capture(url, args.out, args.views, args.widths, args.themes)
+ print(f"{len(written)} screenshots in {args.out}")
+ return _compare(args.out, args.compare) if args.compare else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/visual/capture_tui.py b/tests/visual/capture_tui.py
new file mode 100644
index 0000000..2e0a38e
--- /dev/null
+++ b/tests/visual/capture_tui.py
@@ -0,0 +1,123 @@
+"""Draw every TUI panel to SVG: the visual baseline for the redesign.
+
+Fase 0, voce 2 of ``docs/redesign-plan.md``. Each of the twelve panels is
+opened through the sidebar and exported with ``App.export_screenshot`` at
+80×24 (the compact layout) and 120×40, in the default theme and in
+``mornye-light``. The result is a folder of SVGs to compare before and after
+a change — by eye, or with ``--compare`` against a saved baseline.
+
+The app runs against an empty, throw-away home so that nothing from this
+machine — history, installed extensions, saved sessions, paths — ends up in
+a picture that may be committed.
+
+ python tests/visual/capture_tui.py # → tests/visual/out/tui
+ python tests/visual/capture_tui.py --out DIR
+ python tests/visual/capture_tui.py --compare tests/visual/baseline/tui
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parents[1]
+
+SIZES: tuple[tuple[int, int], ...] = ((80, 24), (120, 40))
+THEMES: tuple[str, ...] = ("mornye-dark", "mornye-light")
+
+#: Settling cycles after switching panel. The slowest panels in the suite
+#: (extensions, tracklist) need 15; a picture taken earlier is half drawn.
+_SETTLE = 15
+
+
+def _isolate(home: Path) -> None:
+ """Point every path Mornye reads at an empty home, before it is imported."""
+ os.environ["HOME"] = str(home)
+ os.environ["USERPROFILE"] = str(home)
+ os.environ["MORNYE_DATA_DIR"] = str(home / ".mornye")
+ os.environ["MORNYE_CACHE_DIR"] = str(home / ".mornye" / ".cache")
+ # Shown in the Configuration panel; fixed so it reads the same anywhere.
+ os.environ["MORNYE_DOWNLOAD_DIR"] = "/Music/Mornye"
+ os.environ.pop("NO_COLOR", None)
+
+
+def _normalise(svg: str, home: Path) -> str:
+ """Strip what changes between runs without the look changing."""
+ svg = svg.replace(str(home), "~")
+ # The banner shows the release; a version bump is not a change of look.
+ svg = re.sub(r"v\d+\.\d+\.\d+", "v0.0.0", svg)
+ # Textual names the SVG's classes after a hash of the content.
+ return re.sub(r"terminal-\d+", "terminal", svg)
+
+
+async def _capture(out: Path, home: Path) -> list[Path]:
+ from mornye.tui.app import MODES, MornyeTui
+ from mornye.tui.config_state import ConfigState
+
+ written: list[Path] = []
+ for width, height in SIZES:
+ for theme in THEMES:
+ state = ConfigState(
+ url="https://open.spotify.com/album/example",
+ output_dir="/Music/Mornye",
+ services=["tidal"],
+ )
+ app = MornyeTui(state)
+ async with app.run_test(size=(width, height)) as pilot:
+ app.theme = theme
+ for _ in range(_SETTLE):
+ await pilot.pause()
+ sidebar = app.query_one("#sidebar")
+ for index, (key, _label) in enumerate(MODES):
+ sidebar.index = index # type: ignore[attr-defined]
+ for _ in range(_SETTLE):
+ await pilot.pause()
+ name = f"{index:02d}-{key}_{width}x{height}_{theme}.svg"
+ path = out / name
+ svg = app.export_screenshot(title=f"{key} {width}x{height}")
+ path.write_text(_normalise(svg, home), encoding="utf-8")
+ written.append(path)
+ return written
+
+
+def _compare(out: Path, baseline: Path) -> int:
+ changed = []
+ for path in sorted(out.glob("*.svg")):
+ ref = baseline / path.name
+ if not ref.exists():
+ changed.append(f"new {path.name}")
+ elif ref.read_text(encoding="utf-8") != path.read_text(encoding="utf-8"):
+ changed.append(f"changed {path.name}")
+ for ref in sorted(baseline.glob("*.svg")):
+ if not (out / ref.name).exists():
+ changed.append(f"missing {ref.name}")
+ print("\n".join(changed) or "identical to the baseline")
+ return 1 if changed else 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n")[0])
+ parser.add_argument("--out", type=Path, default=HERE / "out" / "tui")
+ parser.add_argument("--compare", type=Path, help="baseline folder to diff against")
+ args = parser.parse_args(argv)
+
+ args.out.mkdir(parents=True, exist_ok=True)
+ for old in args.out.glob("*.svg"):
+ old.unlink()
+ with tempfile.TemporaryDirectory(prefix="mornye-visual-") as tmp:
+ home = Path(tmp)
+ _isolate(home)
+ sys.path.insert(0, str(ROOT))
+ written = asyncio.run(_capture(args.out, home))
+ print(f"{len(written)} snapshots in {args.out}")
+ return _compare(args.out, args.compare) if args.compare else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/uv.lock b/uv.lock
index 7dfc85e..24d90cd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1465,7 +1465,7 @@ wheels = [
[[package]]
name = "mornye"
-version = "2.0.5"
+version = "2.0.6"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },