Repository navigation
feat(home): fluid editorial home prototype behind a dev-only flag #905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+5,180
−3
Closed
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2c6dd7e
feat(home): add the fluid editorial home prototype behind a dev-only …
InstaZDLL 7934b88
fix(home): address the local review of the fluid editorial prototype
InstaZDLL 4a212a9
fix(home): second local review pass on the fluid editorial prototype
InstaZDLL 505c4b1
fix(home): zero the mood counts on an empty analysis and keep a last …
InstaZDLL 64dd2f5
fix(home): address the coderabbit review of the fluid editorial proto…
InstaZDLL 6654ae4
fix(home): filter albumless plays in sql and close a failed snapshot …
InstaZDLL fcb4066
feat(home): let the prototype flag come from localstorage for tauri dev
InstaZDLL 61338a9
feat(home): second pass on the fluid editorial prototype
InstaZDLL File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { useEffect, useState, type CSSProperties } from "react"; | ||
| import { useTranslation } from "react-i18next"; | ||
| // DM Sans upright is bundled by `main.tsx` for the Liquid skin; the | ||
| // italic axis is loaded here only, for the prototype's emphasis. | ||
| import "@fontsource-variable/dm-sans/opsz-italic.css"; | ||
| import "./fluid-editorial.css"; | ||
| import { FluidHome } from "./home/FluidHome"; | ||
| import { Atmosphere } from "./shell/Atmosphere"; | ||
| import { FePlayerBar } from "./shell/FePlayerBar"; | ||
| import { FeSidebar } from "./shell/FeSidebar"; | ||
| import { FeTopBar } from "./shell/FeTopBar"; | ||
| import { FeStateProvider } from "./state"; | ||
| import { useFe } from "./useFe"; | ||
|
|
||
| /** | ||
| * Fluid Editorial — an isolated Home prototype. | ||
| * | ||
| * Mounted by `main.tsx` when the URL carries `?proto=fluid-editorial`, | ||
| * in development builds only. It renders the whole desktop shell | ||
| * (sidebar, top bar, content column, player bar) on mock state so the | ||
| * direction can be judged as a screen, not a component — while the | ||
| * production `AppLayout`, `HomeView` and every provider stay untouched. | ||
| */ | ||
| export default function FluidEditorialApp() { | ||
| const { i18n } = useTranslation(); | ||
| useEffect(() => { | ||
| const previous = document.title; | ||
| document.title = "WaveFlow — Fluid Editorial (prototype)"; | ||
| return () => { | ||
| document.title = previous; | ||
| }; | ||
| }, []); | ||
| return ( | ||
| <FeStateProvider language={i18n.resolvedLanguage ?? i18n.language}> | ||
| <Shell /> | ||
| </FeStateProvider> | ||
| ); | ||
| } | ||
|
|
||
| function Shell() { | ||
| const { atmosphere } = useFe(); | ||
| const [collapsed, setCollapsed] = useState(false); | ||
| const vars = { | ||
| ["--fe-accent" as string]: atmosphere.accent, | ||
| ["--fe-accent-ink" as string]: atmosphere.accentInk, | ||
| ["--fe-glow" as string]: atmosphere.glow, | ||
| ["--fe-glow-deep" as string]: atmosphere.glowDeep, | ||
| transition: "--fe-accent 600ms, --fe-glow 600ms", | ||
| } as CSSProperties; | ||
|
|
||
| return ( | ||
| <div className="fe flex h-screen flex-col overflow-hidden" style={vars}> | ||
| <div className="flex min-h-0 flex-1"> | ||
| <FeSidebar collapsed={collapsed} /> | ||
| <div className="relative flex min-w-0 flex-1 flex-col"> | ||
| <Atmosphere /> | ||
| <FeTopBar | ||
| collapsed={collapsed} | ||
| onToggleSidebar={() => setCollapsed((c) => !c)} | ||
| /> | ||
| <main className="relative flex-1 overflow-y-auto px-8 pb-8 pt-3 xl:px-12 2xl:px-14"> | ||
| <FluidHome /> | ||
| </main> | ||
| </div> | ||
| </div> | ||
| <FePlayerBar /> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # Fluid Editorial — prototype de la page d'accueil | ||
|
|
||
| Exploration visuelle, **pas une refonte approuvée**. Tout vit dans ce | ||
| dossier (plus un script, une entrée dans `main.tsx` et un middleware dev | ||
| dans `vite.config.ts`) : supprimer `src/prototype/`, `scripts/fluid-proto-fixtures.ts`, | ||
| `prototypes/` et les deux branches `?proto=` suffit à le retirer. | ||
|
|
||
| ## Ouvrir le prototype | ||
|
|
||
| ```bash | ||
| bun run scripts/fluid-proto-fixtures.ts # une fois : pochettes + fixtures depuis votre profil | ||
| bun run dev # Vite seul, pas de coquille Tauri | ||
| ``` | ||
|
|
||
| Puis ouvrir **http://localhost:1420/?proto=fluid-editorial** dans un navigateur. | ||
|
|
||
| Dans la vraie coquille (`bun run tauri dev`), la fenêtre charge l'URL sans | ||
| paramètre : ouvrir les outils de développement et taper | ||
| `localStorage.setItem("waveflow.proto", "fluid-editorial")`, puis recharger | ||
| (`localStorage.removeItem("waveflow.proto")` pour revenir à l'accueil normal). | ||
|
|
||
| - Le script lit `%APPDATA%/app.waveflow/waveflow/profiles/2` par défaut | ||
| (un autre dossier de profil se passe en argument). Il copie une soixantaine | ||
| de pochettes redimensionnées dans `prototypes/fluid-editorial/covers/` | ||
| (dossier **gitignoré**, servi uniquement par `vite dev` sous `/fluid-proto/`) | ||
| et écrit `fixtures.generated.json` (titres, artistes, compteurs, palette | ||
| par pochette — commité, sans image). | ||
| - Le script lit une **copie** de `data.db` + WAL + SHM (jamais la base en | ||
| place). Si l'app écrit pendant la copie, le triplet peut être incohérent : | ||
| le script sonde la copie et recommence jusqu'à trois fois, puis échoue | ||
| avec un message explicite — relancez-le quand l'app est au repos. | ||
| - Sans les pochettes, chaque tuile retombe sur un dégradé tiré de sa palette : | ||
| la composition tient, en plus plat. | ||
| - La langue suit celle de l'app (`waveflow-language` dans localStorage) : | ||
| français ou anglais, les 15 autres locales ne sont pas couvertes. | ||
| - Les captures de référence sont dans `prototypes/fluid-editorial/screenshots/` | ||
| (1280×720, 1600×900, 1920×1080), régénérables avec Chrome headless. | ||
|
|
||
| ## Ce qui change par rapport à l'accueil actuel | ||
|
|
||
| | Aujourd'hui | Fluid Editorial | | ||
| | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | Bannière de bienvenue + 4 cartes de statistiques | **Listening Canvas** : salutation en italique éditoriale, les 4 compteurs sur une ligne de texte tertiaire, titre en cours en display 32–52 px, pochette 15–27 rem à droite avec son halo, les 5 derniers disques accrochés à son coin, la file « Ensuite » en 3 lignes denses. Aucune carte. | | ||
| | Fond uni | **Atmosphère** : deux nappes radiales de la couleur moyenne de la pochette + grain. L'accent (bouton, barre active, lignes d'onde, progression) est la teinte vive de la pochette, désaturée et pincée en luminosité pour rester lisible. Retombe sur l'émeraude de la marque quand la pochette est grise. | | ||
| | Radio par humeur : 5 blocs saturés | **Spectre d'humeur** : une bande continue dont l'axe horizontal est le tempo (40→170 BPM). Chaque humeur est un segment avec sa propre onde (plus lente et plate à gauche, plus rapide et haute à droite). Sélection = le segment s'élargit et révèle fenêtre de tempo, nombre de titres, bouton « Lancer la radio ». Radiogroup clavier (←/→, Home/End, Entrée), ondes figées en `prefers-reduced-motion`. | | ||
| | « Pour vous » : bannières 16:7 identiques | **Mosaïque éditoriale** : un album à la une (le plus écouté) en demi-largeur avec typographie magazine sur sa pochette, deux Daily Mix en portrait avec leur 2×2, les autres mixes en puces compactes. | | ||
| | « Récemment ajoutés » : grille uniforme | **Étagère** : le disque le plus récent prend 4 fois la surface et une légende complète ; les autres restent de petits carrés, sans boîte autour. | | ||
| | « Récemment joués » : grille de 160 px | **Journal** : liste sur deux colonnes (titre, artiste · album, il y a…, durée) — 12 lignes dans la hauteur où la grille en montrait 6. | | ||
| | Barre de lecture 96 px, bordure haute | **84 px, sans bordure**, dégradé haut dans la couleur de la pochette. Trois groupes : identité (pochette 56 px, titre 15 px, artiste 12 px, étiquette mono `FLAC · 24/44.1`), transport autour d'un seul bouton plein, panneaux en deux grappes discrètes. **Progression en onde** : la partie lue est une sinusoïde de 2 px d'amplitude, le reste un filet. Aucune commande retirée. | | ||
| | Barre latérale : blocs remplis, libellés gras | Même structure, chrome apaisé : pas de bordure, libellés 11 px médium espacés, état actif = barre d'accent de 2 px, compteurs à droite, **pochettes réelles** sur chaque playlist (2×2). | | ||
| | Barre supérieure vitrée avec bordure | Même ton que le contenu, 52 px, glyphes nus ; seule la recherche garde sa pilule. | | ||
|
|
||
| ### Itération 2 (retours de la première revue) | ||
|
|
||
| - **Occupation de l'écran** : conteneur porté à 1 680 px et gouttières élargies au-delà de 1 536 px ; à 1 920 px le canvas tient en trois colonnes, titre · file · pochette. | ||
| - **Aura de pochette** : la pochette en cours, floutée à 96 px, désaturée et masquée en ellipse, éclaire le titre et la file derrière le canvas (opacité 0,2). Un seul filtre statique, fondu à l'arrivée, coupé en `prefers-reduced-motion`. | ||
| - **Lisibilité** : toutes les tailles secondaires montent d'un cran (0,625 → 0,6875, 0,6875 → 0,75, 0,75 → 0,8125 rem), l'encre tertiaire passe de `#7b7872` à `#8e8b83`, les libellés en capitales à 0,75 rem / 540. | ||
| - **La grande tuile a une raison** : « Dernier ajout · il y a 24 heures » au-dessus de son titre — la mise en avant est le plus récent ajout, pas le premier élément par hasard. | ||
| - **Barre de lecture** : la progression devient le bord supérieur de la barre, une onde sur toute la largeur avec les temps à ses deux extrémités (plus de course pour chercher, même hauteur à 2 px près : 88 px). Le centre ne garde que le transport. | ||
|
|
||
| ### Système visuel | ||
|
|
||
| - **Typographie** : DM Sans Variable (déjà embarquée pour Liquid, axe optique) pour tout ; | ||
| Playfair Display italique — déjà embarquée pour Editorial — réservée à la salutation et | ||
| au chapeau de l'album à la une. Titres en 540–560, interlettrage −0,02 à −0,035 em. | ||
| - **Couleur** : fondation neutre `#0b0b0e` / `#121216` / `#17171d`, encre `#f3f1ec`, | ||
| un seul accent, dérivé de la pochette. La bande d'humeur est la seule exception : le | ||
| tempo est une dimension à part, sa teinte le dit. | ||
| - **Surfaces** : profondeur par ton et espacement, pas par bordures. Rayon 0,625–1,25 rem, | ||
| pilules pour les actions. | ||
| - **Motif** : la courbe d'onde — en-têtes de section, progression du lecteur, segments d'humeur. | ||
| - **Mouvement** : montée en cascade à l'arrivée (opacité + 10 px, 640 ms), lever de 3 px au survol, | ||
| élargissement du segment en 460 ms, dérive des ondes. Tout en `transform` / `opacity` sauf | ||
| le `flex-grow` du segment (5 éléments, 460 ms). `prefers-reduced-motion` coupe tout. | ||
|
|
||
| ## Ce qui est simulé | ||
|
|
||
| - **Lecture** : état local (`state.tsx`). « Reprendre » fait avancer une horloge ; cliquer un | ||
| disque ou une ligne le met « en cours » et recolore la page. Rien ne parle au backend. | ||
| - **Radio par humeur** : « Lancer » choisit un titre dont le tempo tombe dans la fenêtre, au | ||
| lieu d'appeler `start_mood_radio`. Les compteurs viennent d'une requête équivalente sur | ||
| `track_analysis` au moment de la génération. | ||
| - **Navigation** : seule « Accueil » est active ; les autres entrées portent une infobulle. | ||
| - **Recherche, thème, profil, panneaux, volume, périphériques** : rendus, inertes. | ||
| - **Couleur de la pochette** : palette précalculée par `sharp` à la génération, raffinée à | ||
| l'exécution par `dominantColor()` (la fonction de l'app) quand l'image est décodée. | ||
| - Dans un navigateur sans Tauri, un module de l'app journalise une erreur `invoke` au | ||
| chargement (`useAccentChoice`) ; elle vient de l'arbre de production importé par `main.tsx`, | ||
| pas du prototype. | ||
|
|
||
| ## Pistes d'intégration | ||
|
|
||
| Le prototype n'emprunte aux composants de production que `WaveFlowLogo`, `PlaylistIcon`, | ||
| `resolvePlaylistColor` et `dominantColor`. Une intégration passerait par : | ||
|
|
||
| 1. une skin ou un thème « Fluid » exposant `--fe-*` via `applySkin` ; | ||
| 2. `HomeView` recomposé section par section (le canvas, la bande, la mosaïque, l'étagère, le journal) sur `PlayerContext` / `LibraryContext` ; | ||
| 3. `ProgressBar` et `PlayerBar` recevant l'onde et le regroupement ; | ||
| 4. les chaînes de `copy.ts` migrées dans les 17 locales. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| /** | ||
| * Palette → the two colours the page reads. | ||
| * | ||
| * `glow` is for atmosphere: the average tone of the cover, deepened so | ||
| * it sits behind text without competing. `accent` is for ink and | ||
| * controls: the cover's most vivid hue, with its saturation pulled | ||
| * down and its lightness pinned into a band that stays legible on the | ||
| * near-black foundation (and takes dark ink on a filled button). | ||
| */ | ||
|
|
||
| interface Rgb { | ||
| r: number; | ||
| g: number; | ||
| b: number; | ||
| } | ||
|
|
||
| export function hexToRgb(hex: string): Rgb { | ||
| const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); | ||
| if (!m) return { r: 63, g: 63, b: 70 }; | ||
| return { | ||
| r: parseInt(m[1], 16), | ||
| g: parseInt(m[2], 16), | ||
| b: parseInt(m[3], 16), | ||
| }; | ||
| } | ||
|
|
||
| function rgbToHsl({ r, g, b }: Rgb): [number, number, number] { | ||
| const rn = r / 255, | ||
| gn = g / 255, | ||
| bn = b / 255; | ||
| const max = Math.max(rn, gn, bn), | ||
| min = Math.min(rn, gn, bn); | ||
| const l = (max + min) / 2; | ||
| if (max === min) return [0, 0, l]; | ||
| const d = max - min; | ||
| const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); | ||
| let h: number; | ||
| if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0); | ||
| else if (max === gn) h = (bn - rn) / d + 2; | ||
| else h = (rn - gn) / d + 4; | ||
| return [h / 6, s, l]; | ||
| } | ||
|
|
||
| function hslToHex(h: number, s: number, l: number): string { | ||
| const f = (n: number) => { | ||
| const k = (n + h * 12) % 12; | ||
| const a = s * Math.min(l, 1 - l); | ||
| const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); | ||
| return Math.round(c * 255) | ||
| .toString(16) | ||
| .padStart(2, "0"); | ||
| }; | ||
| return `#${f(0)}${f(8)}${f(4)}`; | ||
| } | ||
|
|
||
| export interface Atmosphere { | ||
| accent: string; | ||
| /** Ink that sits on a filled `accent` surface. */ | ||
| accentInk: string; | ||
| glow: string; | ||
| glowDeep: string; | ||
| } | ||
|
|
||
| const BRAND: Atmosphere = { | ||
| accent: "#5ed3a5", | ||
| accentInk: "#0b0b0e", | ||
| glow: "#1f5a48", | ||
| glowDeep: "#0f2b22", | ||
| }; | ||
|
|
||
| export function atmosphereFromPalette( | ||
| palette: string[] | null | undefined, | ||
| ): Atmosphere { | ||
| if (!palette || palette.length < 3) return BRAND; | ||
| const [avg, , vivid] = palette; | ||
| const [h, s] = rgbToHsl(hexToRgb(vivid)); | ||
| const [gh, gs, gl] = rgbToHsl(hexToRgb(avg)); | ||
| // A grey cover gives no hue worth painting with: stay on brand. | ||
| if (s < 0.08 && gs < 0.08) return BRAND; | ||
| const accent = hslToHex(h, Math.min(0.62, s * 0.85 + 0.12), 0.66); | ||
| return { | ||
| accent, | ||
| accentInk: "#0b0b0e", | ||
| glow: hslToHex( | ||
| gh, | ||
| Math.min(0.5, gs * 0.9 + 0.08), | ||
| Math.min(0.34, Math.max(0.2, gl * 0.7)), | ||
| ), | ||
| glowDeep: hslToHex(gh, Math.min(0.45, gs * 0.9), 0.14), | ||
| }; | ||
| } | ||
|
|
||
| /** Same shaping, from a live `dominantColor()` sample. */ | ||
| export function atmosphereFromRgb(vivid: Rgb, base: Atmosphere): Atmosphere { | ||
| const [h, s] = rgbToHsl(vivid); | ||
| if (s < 0.1) return base; | ||
| return { | ||
| ...base, | ||
| accent: hslToHex(h, Math.min(0.62, s * 0.85 + 0.12), 0.66), | ||
| }; | ||
| } | ||
|
|
||
| /** A soft two-stop gradient used wherever a cover is missing. */ | ||
| export function fallbackGradient(palette: string[] | null | undefined): string { | ||
| const [a, b] = | ||
| palette && palette.length >= 2 ? palette : ["#2a2a31", "#141418"]; | ||
| return `linear-gradient(135deg, ${a} 0%, ${b} 100%)`; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Gérer l’accès refusé à
localStorage.Dans un navigateur qui bloque le stockage,
localStorage.getItem(...)lèveSecurityErrorpendant l’évaluation demain.tsx. Sans paramètreproto, l’application ne rend alors aucun écran. Entourer cette lecture d’untry/catchet utilisernullcomme valeur de repli.🤖 Prompt for AI Agents